Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2a1700922 | ||
|
|
6e4f0acddb |
No files matched your search
@@ -80,7 +80,7 @@ services:
|
|||||||
database:
|
database:
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: timescale/timescaledb:2.30.2-pg17@sha256:b346edcdb51a1fd6020e3965e0bd1c9f3406fa6d5fbce1e28f4852587ef934e2
|
image: timescale/timescaledb:2.30.2-pg17
|
||||||
profiles: [postgres, external]
|
profiles: [postgres, external]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""media trash
|
|
||||||
|
|
||||||
Revision ID: c9e2a7f4b1d8
|
|
||||||
Revises: b6d1f4a82e57
|
|
||||||
Create Date: 2026-10-14 03:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision: str = "c9e2a7f4b1d8"
|
|
||||||
down_revision: str | None = "b6d1f4a82e57"
|
|
||||||
branch_labels: str | Sequence[str] | None = None
|
|
||||||
depends_on: str | Sequence[str] | None = None
|
|
||||||
|
|
||||||
_TRASH = """
|
|
||||||
CREATE TABLE media_trash (
|
|
||||||
storage_key text PRIMARY KEY,
|
|
||||||
account_id integer NOT NULL,
|
|
||||||
chat_id bigint NOT NULL,
|
|
||||||
message_id bigint NOT NULL,
|
|
||||||
kind text NOT NULL,
|
|
||||||
mime text,
|
|
||||||
file_name text NOT NULL,
|
|
||||||
file_size bigint,
|
|
||||||
trashed_at timestamptz NOT NULL DEFAULT now()
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.execute(
|
|
||||||
"ALTER TABLE media ADD COLUMN evicted_at timestamptz, "
|
|
||||||
"ADD COLUMN evicted_key text"
|
|
||||||
)
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX ix_media_evicted_unique ON media (unique_id) "
|
|
||||||
"WHERE evicted_at IS NOT NULL"
|
|
||||||
)
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX ix_media_evicted_key ON media (evicted_key) "
|
|
||||||
"WHERE evicted_key IS NOT NULL"
|
|
||||||
)
|
|
||||||
op.execute(_TRASH)
|
|
||||||
op.execute(
|
|
||||||
"CREATE INDEX ix_media_trash_account ON media_trash "
|
|
||||||
"(account_id, trashed_at DESC)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.execute("DROP TABLE IF EXISTS media_trash")
|
|
||||||
op.execute("DROP INDEX IF EXISTS ix_media_evicted_key")
|
|
||||||
op.execute("DROP INDEX IF EXISTS ix_media_evicted_unique")
|
|
||||||
op.execute("ALTER TABLE media DROP COLUMN evicted_key, DROP COLUMN evicted_at")
|
|
||||||
@@ -1,23 +1,19 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
|
|
||||||
QUERYLESS_PREFIXES = ("/mcp",)
|
QUERYLESS_PREFIXES = ("/mcp",)
|
||||||
_PATH_ARG = 2
|
_PATH_ARG = 2
|
||||||
_TOKEN = re.compile(r"(?<=[?&]token=)[^&]+")
|
|
||||||
|
|
||||||
|
|
||||||
class ScrubQueryFilter(logging.Filter):
|
class DropQueryFilter(logging.Filter):
|
||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
args = record.args
|
args = record.args
|
||||||
if isinstance(args, tuple) and len(args) > _PATH_ARG:
|
if isinstance(args, tuple) and len(args) > _PATH_ARG:
|
||||||
path = str(args[_PATH_ARG])
|
path = str(args[_PATH_ARG])
|
||||||
if path.startswith(QUERYLESS_PREFIXES):
|
if path.startswith(QUERYLESS_PREFIXES):
|
||||||
path = path.partition("?")[0]
|
bare = path.partition("?")[0]
|
||||||
else:
|
record.args = (*args[:_PATH_ARG], bare, *args[_PATH_ARG + 1 :])
|
||||||
path = _TOKEN.sub("***", path)
|
|
||||||
record.args = (*args[:_PATH_ARG], path, *args[_PATH_ARG + 1 :])
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def scrub_access_log() -> None:
|
def hide_mcp_query() -> None:
|
||||||
logging.getLogger("uvicorn.access").addFilter(ScrubQueryFilter())
|
logging.getLogger("uvicorn.access").addFilter(DropQueryFilter())
|
||||||
+2
-11
@@ -1,4 +1,3 @@
|
|||||||
import asyncio
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -10,7 +9,7 @@ from fastapi.responses import FileResponse, RedirectResponse
|
|||||||
from fastmcp.utilities.lifespan import combine_lifespans
|
from fastmcp.utilities.lifespan import combine_lifespans
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
|
|
||||||
from api.access_log import scrub_access_log
|
from api.access_log import hide_mcp_query
|
||||||
from api.auth import BearerAuthMiddleware
|
from api.auth import BearerAuthMiddleware
|
||||||
from api.mcp.server import mcp
|
from api.mcp.server import mcp
|
||||||
from api.realtime import hub
|
from api.realtime import hub
|
||||||
@@ -35,22 +34,19 @@ from api.routers import (
|
|||||||
search,
|
search,
|
||||||
shares,
|
shares,
|
||||||
social,
|
social,
|
||||||
storage,
|
|
||||||
stories,
|
stories,
|
||||||
watches,
|
watches,
|
||||||
)
|
)
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
|
from utils.cache import DAY_HEADERS, IMMUTABLE_HEADERS, NO_STORE_HEADERS
|
||||||
from utils.env import env
|
from utils.env import env
|
||||||
from utils.storage import ContentAddressedStorage
|
|
||||||
from utils.trash import purge_loop
|
|
||||||
|
|
||||||
if env.auth.token is None:
|
if env.auth.token is None:
|
||||||
msg = "AUTH__TOKEN is required for the api process"
|
msg = "AUTH__TOKEN is required for the api process"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
_token = env.auth.token.get_secret_value()
|
_token = env.auth.token.get_secret_value()
|
||||||
|
|
||||||
scrub_access_log()
|
hide_mcp_query()
|
||||||
|
|
||||||
mcp_app = mcp.http_app(path="/")
|
mcp_app = mcp.http_app(path="/")
|
||||||
|
|
||||||
@@ -59,11 +55,7 @@ mcp_app = mcp.http_app(path="/")
|
|||||||
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
|
async def lifespan(app_: Starlette) -> AsyncGenerator[None]:
|
||||||
pool = await container.get(asyncpg.Pool)
|
pool = await container.get(asyncpg.Pool)
|
||||||
await hub.start(pool)
|
await hub.start(pool)
|
||||||
purger = asyncio.create_task(
|
|
||||||
purge_loop(pool, await container.get(ContentAddressedStorage))
|
|
||||||
)
|
|
||||||
yield
|
yield
|
||||||
purger.cancel()
|
|
||||||
await hub.stop()
|
await hub.stop()
|
||||||
await app_.state.dishka_container.close()
|
await app_.state.dishka_container.close()
|
||||||
|
|
||||||
@@ -104,7 +96,6 @@ app.include_router(discover.router)
|
|||||||
app.include_router(annotations.router)
|
app.include_router(annotations.router)
|
||||||
app.include_router(watches.router)
|
app.include_router(watches.router)
|
||||||
app.include_router(shares.router)
|
app.include_router(shares.router)
|
||||||
app.include_router(storage.router)
|
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
|
|
||||||
app.mount("/mcp", mcp_app)
|
app.mount("/mcp", mcp_app)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def serve_media_version(
|
|||||||
download: Download = False,
|
download: Download = False,
|
||||||
) -> FileResponse:
|
) -> FileResponse:
|
||||||
version = await get_media_version(pool, version_id)
|
version = await get_media_version(pool, version_id)
|
||||||
if version is None or not storage.exists(version.storage_key):
|
if version is None:
|
||||||
raise HTTPException(status_code=404, detail="media version not found")
|
raise HTTPException(status_code=404, detail="media version not found")
|
||||||
headers = IMMUTABLE_HEADERS
|
headers = IMMUTABLE_HEADERS
|
||||||
if download:
|
if download:
|
||||||
@@ -96,8 +96,6 @@ async def serve_media(
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
|
detail="media not downloaded; enqueue fetch via POST /api/media/fetch",
|
||||||
)
|
)
|
||||||
if not storage.exists(media.storage_key):
|
|
||||||
raise HTTPException(status_code=404, detail="media file is missing")
|
|
||||||
headers = DAY_HEADERS
|
headers = DAY_HEADERS
|
||||||
if download:
|
if download:
|
||||||
headers = _attachment(headers, media.file_name or f"media_{media_id}")
|
headers = _attachment(headers, media.file_name or f"media_{media_id}")
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from utils.policy import overview, repository
|
from utils.policy import repository
|
||||||
from utils.policy.models import (
|
from utils.policy.models import (
|
||||||
CaptureToggles,
|
CaptureToggles,
|
||||||
ChatKind,
|
ChatKind,
|
||||||
ChatMeta,
|
ChatMeta,
|
||||||
PolicyCreate,
|
PolicyCreate,
|
||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
PolicyRuleView,
|
|
||||||
PolicyScopeView,
|
|
||||||
)
|
)
|
||||||
from utils.policy.resolver import resolve
|
from utils.policy.resolver import resolve
|
||||||
|
|
||||||
@@ -52,32 +50,6 @@ async def effective_policy(
|
|||||||
return resolve(chat, folders, policies)
|
return resolve(chat, folders, policies)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/overview")
|
|
||||||
async def policy_overview(
|
|
||||||
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
|
||||||
) -> list[PolicyRuleView]:
|
|
||||||
return await overview.list_rules(pool, account_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scope")
|
|
||||||
async def policy_scope(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
account_id: Annotated[int, Query()],
|
|
||||||
chat_id: Annotated[int | None, Query()] = None,
|
|
||||||
folder_id: Annotated[int | None, Query()] = None,
|
|
||||||
) -> PolicyScopeView:
|
|
||||||
if chat_id is not None:
|
|
||||||
return await overview.chat_scope(pool, account_id, chat_id)
|
|
||||||
scope = (
|
|
||||||
await overview.folder_scope(pool, account_id, folder_id)
|
|
||||||
if folder_id is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if scope is None:
|
|
||||||
raise HTTPException(status_code=404, detail="scope not found")
|
|
||||||
return scope
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
async def create_policy(
|
async def create_policy(
|
||||||
pool: FromDishka[asyncpg.Pool], body: PolicyCreate
|
pool: FromDishka[asyncpg.Pool], body: PolicyCreate
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from utils import trash
|
|
||||||
from utils.cache import NO_STORE_HEADERS
|
|
||||||
from utils.files import resolve_mime
|
|
||||||
from utils.read import storage as storage_read
|
|
||||||
from utils.read.models import (
|
|
||||||
DEFAULT_LIMIT,
|
|
||||||
Page,
|
|
||||||
StorageFileView,
|
|
||||||
StorageScopeView,
|
|
||||||
StorageSummaryView,
|
|
||||||
TrashItemView,
|
|
||||||
TrashOutcome,
|
|
||||||
)
|
|
||||||
from utils.storage import ContentAddressedStorage
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/storage", tags=["storage"], route_class=DishkaRoute)
|
|
||||||
|
|
||||||
|
|
||||||
class TrashRequest(BaseModel):
|
|
||||||
account_id: int
|
|
||||||
media_ids: list[int]
|
|
||||||
|
|
||||||
|
|
||||||
class RestoreRequest(BaseModel):
|
|
||||||
account_id: int
|
|
||||||
keys: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ScopeRequest(BaseModel):
|
|
||||||
account_id: int
|
|
||||||
chat_id: int | None = None
|
|
||||||
folder_id: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PurgeRequest(BaseModel):
|
|
||||||
account_id: int
|
|
||||||
keys: list[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
|
||||||
async def summary(
|
|
||||||
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
|
||||||
) -> StorageSummaryView:
|
|
||||||
return await storage_read.get_summary(pool, account_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files")
|
|
||||||
async def files(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
account_id: Annotated[int, Query()],
|
|
||||||
group: Annotated[str | None, Query()] = None,
|
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
|
||||||
offset: Annotated[int, Query()] = 0,
|
|
||||||
) -> list[StorageFileView]:
|
|
||||||
page = Page(limit=limit, offset=offset)
|
|
||||||
return await storage_read.list_files(pool, account_id, group, page)
|
|
||||||
|
|
||||||
|
|
||||||
async def _chat_ids(
|
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int | None, folder_id: int | None
|
|
||||||
) -> list[int]:
|
|
||||||
chat_ids = await storage_read.scope_chat_ids(pool, account_id, chat_id, folder_id)
|
|
||||||
if chat_ids is None:
|
|
||||||
raise HTTPException(status_code=404, detail="scope not found")
|
|
||||||
return chat_ids
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/scope")
|
|
||||||
async def scope(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
account_id: Annotated[int, Query()],
|
|
||||||
chat_id: Annotated[int | None, Query()] = None,
|
|
||||||
folder_id: Annotated[int | None, Query()] = None,
|
|
||||||
) -> StorageScopeView:
|
|
||||||
chat_ids = await _chat_ids(pool, account_id, chat_id, folder_id)
|
|
||||||
return await storage_read.get_scope(pool, account_id, chat_ids)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/trash")
|
|
||||||
async def trash_items(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
account_id: Annotated[int, Query()],
|
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
|
||||||
offset: Annotated[int, Query()] = 0,
|
|
||||||
) -> list[TrashItemView]:
|
|
||||||
page = Page(limit=limit, offset=offset)
|
|
||||||
return await storage_read.list_trash(pool, account_id, page)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trash/scope")
|
|
||||||
async def trash_scope(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
storage: FromDishka[ContentAddressedStorage],
|
|
||||||
body: ScopeRequest,
|
|
||||||
) -> TrashOutcome:
|
|
||||||
chat_ids = await _chat_ids(pool, body.account_id, body.chat_id, body.folder_id)
|
|
||||||
blobs = await pool.fetch(storage_read.SCOPE_BLOBS, body.account_id, chat_ids)
|
|
||||||
free = [blob for blob in blobs if blob["free"]]
|
|
||||||
return await trash.trash_blobs(pool, storage, body.account_id, free)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trash")
|
|
||||||
async def move_to_trash(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
storage: FromDishka[ContentAddressedStorage],
|
|
||||||
body: TrashRequest,
|
|
||||||
) -> TrashOutcome:
|
|
||||||
return await trash.move_to_trash(pool, storage, body.account_id, body.media_ids)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trash/restore")
|
|
||||||
async def restore(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
storage: FromDishka[ContentAddressedStorage],
|
|
||||||
body: RestoreRequest,
|
|
||||||
) -> TrashOutcome:
|
|
||||||
return await trash.restore(pool, storage, body.account_id, body.keys)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trash/purge")
|
|
||||||
async def purge(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
storage: FromDishka[ContentAddressedStorage],
|
|
||||||
body: PurgeRequest,
|
|
||||||
) -> TrashOutcome:
|
|
||||||
return await trash.purge(pool, storage, body.account_id, body.keys)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/trash/{storage_key}/file")
|
|
||||||
async def serve_trashed(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
storage: FromDishka[ContentAddressedStorage],
|
|
||||||
storage_key: str,
|
|
||||||
) -> FileResponse:
|
|
||||||
item = await storage_read.get_trash_item(pool, storage_key)
|
|
||||||
if item is None or not storage.trashed(storage_key):
|
|
||||||
raise HTTPException(status_code=404, detail="file is not in trash")
|
|
||||||
return FileResponse(
|
|
||||||
storage.trash_url(storage_key),
|
|
||||||
media_type=resolve_mime(item["kind"], item["mime"], item["file_name"]),
|
|
||||||
headers=NO_STORE_HEADERS,
|
|
||||||
)
|
|
||||||
@@ -30,4 +30,4 @@ async def on_deleted_messages(client: PyroClient, messages: list[Message]) -> No
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_deleted_messages.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_deleted_messages.handlers
|
||||||
@@ -49,4 +49,4 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_edited_message.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_edited_message.handlers
|
||||||
@@ -36,4 +36,4 @@ async def on_message(client: PyroClient, message: Message) -> None:
|
|||||||
await safe_transcribe(client, ctx, meta.chat_id, message.id)
|
await safe_transcribe(client, ctx, meta.chat_id, message.id)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_message.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_message.handlers
|
||||||
@@ -27,4 +27,4 @@ async def on_user_status(client: PyroClient, user: User) -> None:
|
|||||||
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
|
await notify_bg_event(ctx.pool, "presence", ctx.account_id, chat_id=user.id)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_user_status.handlers
|
||||||
@@ -38,4 +38,4 @@ async def dispatch(
|
|||||||
await handler(client, update, users, chats)
|
await handler(client, update, users, chats)
|
||||||
|
|
||||||
|
|
||||||
handlers = dispatch.handlers # ty: ignore[unresolved-attribute]
|
handlers = dispatch.handlers
|
||||||
@@ -21,7 +21,7 @@ async def _handle_user(
|
|||||||
raw_user = users.get(update.user_id)
|
raw_user = users.get(update.user_id)
|
||||||
if raw_user is None:
|
if raw_user is None:
|
||||||
return
|
return
|
||||||
fields, photo_file_id, photo_unique_id = await snapshot_from_user(client, raw_user)
|
fields, photo_file_id, photo_unique_id = snapshot_from_user(client, raw_user)
|
||||||
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
|
current = await get_peer(ctx.pool, ctx.account_id, update.user_id)
|
||||||
if current == fields:
|
if current == fields:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ async def on_story(client: PyroClient, story: Story) -> None:
|
|||||||
await save_story(client, client.capture, story)
|
await save_story(client, client.capture, story)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
|
handlers = on_story.handlers
|
||||||
@@ -88,8 +88,8 @@ ON CONFLICT (account_id, chat_id, message_id, storage_key) DO NOTHING
|
|||||||
_INSERT_MEDIA = """
|
_INSERT_MEDIA = """
|
||||||
INSERT INTO media
|
INSERT INTO media
|
||||||
(account_id, chat_id, message_id, kind, storage_key, file_size, mime,
|
(account_id, chat_id, message_id, kind, storage_key, file_size, mime,
|
||||||
ttl_seconds, downloaded, unique_id, evicted_at)
|
ttl_seconds, downloaded, unique_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CASE WHEN $11 THEN now() END)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
|
ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
|
||||||
kind = EXCLUDED.kind,
|
kind = EXCLUDED.kind,
|
||||||
storage_key = EXCLUDED.storage_key,
|
storage_key = EXCLUDED.storage_key,
|
||||||
@@ -97,27 +97,7 @@ ON CONFLICT (account_id, chat_id, message_id) DO UPDATE SET
|
|||||||
mime = EXCLUDED.mime,
|
mime = EXCLUDED.mime,
|
||||||
ttl_seconds = EXCLUDED.ttl_seconds,
|
ttl_seconds = EXCLUDED.ttl_seconds,
|
||||||
downloaded = EXCLUDED.downloaded,
|
downloaded = EXCLUDED.downloaded,
|
||||||
unique_id = EXCLUDED.unique_id,
|
unique_id = EXCLUDED.unique_id
|
||||||
evicted_at = CASE
|
|
||||||
WHEN EXCLUDED.downloaded THEN NULL
|
|
||||||
WHEN media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
|
|
||||||
THEN EXCLUDED.evicted_at
|
|
||||||
ELSE coalesce(media.evicted_at, EXCLUDED.evicted_at) END,
|
|
||||||
evicted_key = CASE WHEN EXCLUDED.downloaded
|
|
||||||
OR media.unique_id IS DISTINCT FROM EXCLUDED.unique_id
|
|
||||||
THEN NULL ELSE media.evicted_key END
|
|
||||||
"""
|
|
||||||
|
|
||||||
_IS_EVICTED = """
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1 FROM media WHERE unique_id = $1 AND evicted_at IS NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_UNEVICT = """
|
|
||||||
UPDATE media SET storage_key = $2, file_size = $3, downloaded = true,
|
|
||||||
evicted_at = NULL, evicted_key = NULL
|
|
||||||
WHERE evicted_key = $2 OR (unique_id = $1 AND evicted_at IS NOT NULL)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -249,7 +229,6 @@ async def insert_media( # noqa: PLR0913
|
|||||||
unique_id: str | None,
|
unique_id: str | None,
|
||||||
*,
|
*,
|
||||||
downloaded: bool,
|
downloaded: bool,
|
||||||
evicted: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@@ -264,7 +243,6 @@ async def insert_media( # noqa: PLR0913
|
|||||||
ttl_seconds,
|
ttl_seconds,
|
||||||
downloaded,
|
downloaded,
|
||||||
unique_id,
|
unique_id,
|
||||||
evicted,
|
|
||||||
)
|
)
|
||||||
if storage_key is not None:
|
if storage_key is not None:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
@@ -281,22 +259,6 @@ async def insert_media( # noqa: PLR0913
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def is_evicted(pool: asyncpg.Pool, unique_id: str | None) -> bool:
|
|
||||||
return unique_id is not None and await pool.fetchval(_IS_EVICTED, unique_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def unevict(
|
|
||||||
pool: asyncpg.Pool, unique_id: str | None, storage_key: str, file_size: int | None
|
|
||||||
) -> bool:
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute(_UNEVICT, unique_id, storage_key, file_size)
|
|
||||||
dropped = await conn.fetchval(
|
|
||||||
"DELETE FROM media_trash WHERE storage_key = $1 RETURNING storage_key",
|
|
||||||
storage_key,
|
|
||||||
)
|
|
||||||
return dropped is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def insert_callbacks(
|
async def insert_callbacks(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ async def _save_user(ctx: CaptureContext, user: User) -> None:
|
|||||||
|
|
||||||
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
|
async def _enrich_chat_meta(client: Client, ctx: CaptureContext, chat_id: int) -> None:
|
||||||
chat = await client.get_chat(chat_id)
|
chat = await client.get_chat(chat_id)
|
||||||
if chat is None:
|
|
||||||
return
|
|
||||||
photo = chat.photo
|
photo = chat.photo
|
||||||
photo_unique_id = photo.big_photo_unique_id if photo else None
|
photo_unique_id = photo.big_photo_unique_id if photo else None
|
||||||
photo_file_id = photo.big_file_id if photo else None
|
photo_file_id = photo.big_file_id if photo else None
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
|
|||||||
|
|
||||||
async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None:
|
async def _fresh_file_id(client: Client, owner_id: int, unique_id: str) -> str | None:
|
||||||
chat = await client.get_chat(owner_id)
|
chat = await client.get_chat(owner_id)
|
||||||
photo = chat.photo if chat else None
|
photo = chat.photo
|
||||||
if photo and photo.big_photo_unique_id == unique_id:
|
if photo and photo.big_photo_unique_id == unique_id:
|
||||||
return photo.big_file_id
|
return photo.big_file_id
|
||||||
photos = client.get_chat_photos(owner_id)
|
photos = client.get_chat_photos(owner_id)
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ async def fetch_media(ctx: JobContext) -> None:
|
|||||||
msg = f"message {chat_id}/{message_id} is unavailable"
|
msg = f"message {chat_id}/{message_id} is unavailable"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
toggles = CaptureToggles(media=True, self_destruct_media=True)
|
toggles = CaptureToggles(media=True, self_destruct_media=True)
|
||||||
ok = await capture_media(
|
ok = await capture_media(client, message, capture, chat_id, message_id, toggles)
|
||||||
client, message, capture, chat_id, message_id, toggles, force=True
|
|
||||||
)
|
|
||||||
if not ok:
|
if not ok:
|
||||||
msg = f"media download failed for {chat_id}/{message_id}"
|
msg = f"media download failed for {chat_id}/{message_id}"
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
@@ -46,7 +46,7 @@ async def _save_found(
|
|||||||
source = _source(peer, users, chats)
|
source = _source(peer, users, chats)
|
||||||
if source is None:
|
if source is None:
|
||||||
return None
|
return None
|
||||||
chat = await Chat._parse_chat(client, source) # noqa: SLF001
|
chat = Chat._parse_chat(client, source) # noqa: SLF001
|
||||||
if chat is None or chat.id is None:
|
if chat is None or chat.id is None:
|
||||||
return None
|
return None
|
||||||
await save_chat(ctx, chat)
|
await save_chat(ctx, chat)
|
||||||
|
|||||||
@@ -106,8 +106,6 @@ async def capture_media( # noqa: PLR0913
|
|||||||
chat_id: int,
|
chat_id: int,
|
||||||
message_id: int,
|
message_id: int,
|
||||||
toggles: CaptureToggles,
|
toggles: CaptureToggles,
|
||||||
*,
|
|
||||||
force: bool = False,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
kind, obj = media_object(message)
|
kind, obj = media_object(message)
|
||||||
if obj is None:
|
if obj is None:
|
||||||
@@ -119,7 +117,6 @@ async def capture_media( # noqa: PLR0913
|
|||||||
mime = getattr(obj, "mime_type", None)
|
mime = getattr(obj, "mime_type", None)
|
||||||
storage_key: str | None = None
|
storage_key: str | None = None
|
||||||
downloaded = False
|
downloaded = False
|
||||||
evicted = False
|
|
||||||
flood: FloodWait | FloodPremiumWait | None = None
|
flood: FloodWait | FloodPremiumWait | None = None
|
||||||
if want:
|
if want:
|
||||||
existing = await repository.current_media(
|
existing = await repository.current_media(
|
||||||
@@ -134,7 +131,7 @@ async def capture_media( # noqa: PLR0913
|
|||||||
storage_key = existing["storage_key"]
|
storage_key = existing["storage_key"]
|
||||||
file_size = existing["file_size"]
|
file_size = existing["file_size"]
|
||||||
downloaded = True
|
downloaded = True
|
||||||
elif force or not await repository.is_evicted(ctx.pool, unique_id):
|
else:
|
||||||
target = _download_target(message)
|
target = _download_target(message)
|
||||||
try:
|
try:
|
||||||
data = await _download(client, target, chat_id, message_id)
|
data = await _download(client, target, chat_id, message_id)
|
||||||
@@ -145,8 +142,6 @@ async def capture_media( # noqa: PLR0913
|
|||||||
storage_key = ctx.storage.put(data)
|
storage_key = ctx.storage.put(data)
|
||||||
file_size = len(data)
|
file_size = len(data)
|
||||||
downloaded = True
|
downloaded = True
|
||||||
else:
|
|
||||||
evicted = True
|
|
||||||
await repository.insert_media(
|
await repository.insert_media(
|
||||||
ctx.pool,
|
ctx.pool,
|
||||||
ctx.account_id,
|
ctx.account_id,
|
||||||
@@ -159,14 +154,7 @@ async def capture_media( # noqa: PLR0913
|
|||||||
ttl,
|
ttl,
|
||||||
unique_id,
|
unique_id,
|
||||||
downloaded=downloaded,
|
downloaded=downloaded,
|
||||||
evicted=evicted,
|
|
||||||
)
|
)
|
||||||
if (
|
|
||||||
force
|
|
||||||
and storage_key is not None
|
|
||||||
and await repository.unevict(ctx.pool, unique_id, storage_key, file_size)
|
|
||||||
):
|
|
||||||
ctx.storage.purge(storage_key)
|
|
||||||
if flood is not None:
|
if flood is not None:
|
||||||
raise flood
|
raise flood
|
||||||
return downloaded or not want
|
return downloaded or not want
|
||||||
@@ -21,10 +21,10 @@ def active_username(usernames: list[raw.types.Username]) -> str | None:
|
|||||||
return usernames[0].username if usernames else None
|
return usernames[0].username if usernames else None
|
||||||
|
|
||||||
|
|
||||||
async def snapshot_from_user(
|
def snapshot_from_user(
|
||||||
client: Client, raw_user: raw.types.User
|
client: Client, raw_user: raw.types.User
|
||||||
) -> tuple[ProfileFields, str | None, str | None]:
|
) -> tuple[ProfileFields, str | None, str | None]:
|
||||||
user = await User._parse(client, raw_user) # noqa: SLF001
|
user = User._parse(client, raw_user) # noqa: SLF001
|
||||||
photo = user.photo if user else None
|
photo = user.photo if user else None
|
||||||
photo_unique_id = photo.big_photo_unique_id if photo else None
|
photo_unique_id = photo.big_photo_unique_id if photo else None
|
||||||
photo_file_id = photo.big_file_id if photo else None
|
photo_file_id = photo.big_file_id if photo else None
|
||||||
|
|||||||
@@ -79,24 +79,6 @@ class FolderSpec:
|
|||||||
is_chatlist: bool = False
|
is_chatlist: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PolicyParent(BaseModel):
|
|
||||||
scope_type: ScopeType
|
|
||||||
scope_id: int | None
|
|
||||||
title: str | None = None
|
|
||||||
toggles: CaptureToggles
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyRuleView(PolicyRecord):
|
|
||||||
title: str | None = None
|
|
||||||
parent: PolicyParent | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PolicyScopeView(BaseModel):
|
|
||||||
title: str | None
|
|
||||||
rule: PolicyRecord | None
|
|
||||||
parent: PolicyParent | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PolicySet:
|
class PolicySet:
|
||||||
chat: dict[int, CaptureToggles] = field(default_factory=dict)
|
chat: dict[int, CaptureToggles] = field(default_factory=dict)
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import asyncpg
|
|
||||||
|
|
||||||
from utils.policy import repository
|
|
||||||
from utils.policy.models import (
|
|
||||||
ChatKind,
|
|
||||||
ChatMeta,
|
|
||||||
FolderSpec,
|
|
||||||
PolicyParent,
|
|
||||||
PolicyRecord,
|
|
||||||
PolicyRuleView,
|
|
||||||
PolicyScopeView,
|
|
||||||
PolicySet,
|
|
||||||
ScopeType,
|
|
||||||
)
|
|
||||||
from utils.policy.resolver import resolve_parent
|
|
||||||
from utils.read import chats
|
|
||||||
from utils.read.models import ChatListItem
|
|
||||||
|
|
||||||
|
|
||||||
def _meta(chat_id: int, chat: ChatListItem | None) -> ChatMeta:
|
|
||||||
if chat_id > 0:
|
|
||||||
kind = ChatKind.DM
|
|
||||||
elif chat is not None and chat.is_broadcast:
|
|
||||||
kind = ChatKind.CHANNEL
|
|
||||||
else:
|
|
||||||
kind = ChatKind.GROUP
|
|
||||||
return ChatMeta(
|
|
||||||
chat_id=chat_id,
|
|
||||||
kind=kind,
|
|
||||||
is_bot=bool(chat and chat.is_bot),
|
|
||||||
is_contact=chat.is_contact if chat else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _without_chats(policies: PolicySet) -> PolicySet:
|
|
||||||
return PolicySet(folder=policies.folder, defaults=policies.defaults)
|
|
||||||
|
|
||||||
|
|
||||||
def _chat_parent(
|
|
||||||
chat_id: int,
|
|
||||||
chat: ChatListItem | None,
|
|
||||||
folders: list[FolderSpec],
|
|
||||||
policies: PolicySet,
|
|
||||||
) -> PolicyParent:
|
|
||||||
return resolve_parent(_meta(chat_id, chat), folders, _without_chats(policies))
|
|
||||||
|
|
||||||
|
|
||||||
async def list_rules(pool: asyncpg.Pool, account_id: int) -> list[PolicyRuleView]:
|
|
||||||
records = await repository.list_policies(pool, account_id)
|
|
||||||
folders = await repository.list_folders(pool, account_id)
|
|
||||||
policies = await repository.load_policy_set(pool, account_id)
|
|
||||||
chat_ids = [
|
|
||||||
record.scope_id
|
|
||||||
for record in records
|
|
||||||
if record.scope_type is ScopeType.CHAT and record.scope_id is not None
|
|
||||||
]
|
|
||||||
known = {
|
|
||||||
chat.chat_id: chat for chat in await chats.get_chats(pool, account_id, chat_ids)
|
|
||||||
}
|
|
||||||
titles = {folder.folder_id: folder.title for folder in folders}
|
|
||||||
rules: list[PolicyRuleView] = []
|
|
||||||
for record in records:
|
|
||||||
rule = PolicyRuleView(**record.model_dump())
|
|
||||||
if record.scope_type is ScopeType.FOLDER and record.scope_id is not None:
|
|
||||||
rule.title = titles.get(record.scope_id)
|
|
||||||
if record.scope_type is ScopeType.CHAT and record.scope_id is not None:
|
|
||||||
chat = known.get(record.scope_id)
|
|
||||||
rule.title = chat.title if chat else None
|
|
||||||
rule.parent = _chat_parent(record.scope_id, chat, folders, policies)
|
|
||||||
rules.append(rule)
|
|
||||||
return rules
|
|
||||||
|
|
||||||
|
|
||||||
async def _rule(
|
|
||||||
pool: asyncpg.Pool, account_id: int, scope_type: ScopeType, scope_id: int
|
|
||||||
) -> PolicyRecord | None:
|
|
||||||
return await repository.find_policy(pool, account_id, scope_type, scope_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def chat_scope(
|
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
|
||||||
) -> PolicyScopeView:
|
|
||||||
folders = await repository.list_folders(pool, account_id)
|
|
||||||
policies = await repository.load_policy_set(pool, account_id)
|
|
||||||
chat = await chats.get_chat(pool, account_id, chat_id)
|
|
||||||
return PolicyScopeView(
|
|
||||||
title=chat.title if chat else None,
|
|
||||||
rule=await _rule(pool, account_id, ScopeType.CHAT, chat_id),
|
|
||||||
parent=_chat_parent(chat_id, chat, folders, policies),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def folder_scope(
|
|
||||||
pool: asyncpg.Pool, account_id: int, folder_id: int
|
|
||||||
) -> PolicyScopeView | None:
|
|
||||||
folder = await repository.get_folder(pool, account_id, folder_id)
|
|
||||||
if folder is None:
|
|
||||||
return None
|
|
||||||
return PolicyScopeView(
|
|
||||||
title=folder.title,
|
|
||||||
rule=await _rule(pool, account_id, ScopeType.FOLDER, folder_id),
|
|
||||||
parent=None,
|
|
||||||
)
|
|
||||||
@@ -1,14 +1,11 @@
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from utils.policy.models import (
|
from utils.policy.models import (
|
||||||
DEFAULT_SCOPE_BY_KIND,
|
|
||||||
CaptureToggles,
|
CaptureToggles,
|
||||||
ChatKind,
|
ChatKind,
|
||||||
ChatMeta,
|
ChatMeta,
|
||||||
FolderSpec,
|
FolderSpec,
|
||||||
PolicyParent,
|
|
||||||
PolicySet,
|
PolicySet,
|
||||||
ScopeType,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,29 +33,14 @@ def folder_contains(folder: FolderSpec, chat: ChatMeta) -> bool:
|
|||||||
return _category_match(folder, chat)
|
return _category_match(folder, chat)
|
||||||
|
|
||||||
|
|
||||||
def resolve_parent(
|
|
||||||
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
|
|
||||||
) -> PolicyParent:
|
|
||||||
for folder in sorted(folders, key=lambda f: f.order_index):
|
|
||||||
toggles = policies.folder.get(folder.folder_id)
|
|
||||||
if toggles is not None and folder_contains(folder, chat):
|
|
||||||
return PolicyParent(
|
|
||||||
scope_type=ScopeType.FOLDER,
|
|
||||||
scope_id=folder.folder_id,
|
|
||||||
title=folder.title,
|
|
||||||
toggles=toggles,
|
|
||||||
)
|
|
||||||
return PolicyParent(
|
|
||||||
scope_type=DEFAULT_SCOPE_BY_KIND[chat.kind],
|
|
||||||
scope_id=None,
|
|
||||||
toggles=policies.defaults.get(chat.kind, CaptureToggles()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve(
|
def resolve(
|
||||||
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
|
chat: ChatMeta, folders: Iterable[FolderSpec], policies: PolicySet
|
||||||
) -> CaptureToggles:
|
) -> CaptureToggles:
|
||||||
chat_override = policies.chat.get(chat.chat_id)
|
chat_override = policies.chat.get(chat.chat_id)
|
||||||
if chat_override is not None:
|
if chat_override is not None:
|
||||||
return chat_override
|
return chat_override
|
||||||
return resolve_parent(chat, folders, policies).toggles
|
for folder in sorted(folders, key=lambda f: f.order_index):
|
||||||
|
toggles = policies.folder.get(folder.folder_id)
|
||||||
|
if toggles is not None and folder_contains(folder, chat):
|
||||||
|
return toggles
|
||||||
|
return policies.defaults.get(chat.kind, CaptureToggles())
|
||||||
@@ -176,18 +176,6 @@ async def list_chats(
|
|||||||
return [_chat_item(row) for row in rows]
|
return [_chat_item(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def folder_chat_ids(
|
|
||||||
pool: asyncpg.Pool, account_id: int, folder: FolderSpec
|
|
||||||
) -> list[int]:
|
|
||||||
rows_sql = _CHAT_ROWS.format(ids=_ALL_IDS)
|
|
||||||
rows = await pool.fetch(
|
|
||||||
f"SELECT chat.chat_id FROM ({rows_sql}) chat WHERE {_folder_filter(1)}", # noqa: S608
|
|
||||||
account_id,
|
|
||||||
*_folder_params(folder),
|
|
||||||
)
|
|
||||||
return [row["chat_id"] for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def get_chat(
|
async def get_chat(
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int
|
pool: asyncpg.Pool, account_id: int, chat_id: int
|
||||||
) -> ChatListItem | None:
|
) -> ChatListItem | None:
|
||||||
|
|||||||
@@ -236,65 +236,6 @@ class MediaVersionView(BaseModel):
|
|||||||
observed_at: datetime
|
observed_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class StorageGroupView(BaseModel):
|
|
||||||
group: str
|
|
||||||
files: int
|
|
||||||
bytes: int
|
|
||||||
|
|
||||||
|
|
||||||
class StorageSummaryView(BaseModel):
|
|
||||||
files: int
|
|
||||||
bytes: int
|
|
||||||
heavy_files: int
|
|
||||||
heavy_bytes: int
|
|
||||||
heavy_threshold: int
|
|
||||||
trash_files: int
|
|
||||||
trash_bytes: int
|
|
||||||
groups: list[StorageGroupView]
|
|
||||||
|
|
||||||
|
|
||||||
class StorageFileView(BaseModel):
|
|
||||||
media_id: int
|
|
||||||
chat_id: int
|
|
||||||
message_id: int
|
|
||||||
kind: str
|
|
||||||
mime: str | None
|
|
||||||
file_name: str
|
|
||||||
file_size: int
|
|
||||||
date: datetime | None
|
|
||||||
refs: int
|
|
||||||
refetchable: bool
|
|
||||||
|
|
||||||
|
|
||||||
class TrashItemView(BaseModel):
|
|
||||||
storage_key: str
|
|
||||||
chat_id: int
|
|
||||||
message_id: int
|
|
||||||
kind: str
|
|
||||||
mime: str | None
|
|
||||||
file_name: str
|
|
||||||
file_size: int | None
|
|
||||||
trashed_at: datetime
|
|
||||||
purge_at: datetime
|
|
||||||
refs: int
|
|
||||||
refetchable: bool
|
|
||||||
|
|
||||||
|
|
||||||
class TrashOutcome(BaseModel):
|
|
||||||
files: int = 0
|
|
||||||
bytes: int = 0
|
|
||||||
keys: list[str] = []
|
|
||||||
blocked: list[int] = []
|
|
||||||
|
|
||||||
|
|
||||||
class StorageScopeView(BaseModel):
|
|
||||||
chats: int
|
|
||||||
files: int
|
|
||||||
bytes: int
|
|
||||||
kept: int
|
|
||||||
gone: int
|
|
||||||
|
|
||||||
|
|
||||||
class AvatarRef(BaseModel):
|
class AvatarRef(BaseModel):
|
||||||
unique_id: str
|
unique_id: str
|
||||||
storage_key: str | None
|
storage_key: str | None
|
||||||
|
|||||||
@@ -1,291 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from utils.files import media_file_name
|
|
||||||
from utils.policy import repository as policy_repository
|
|
||||||
from utils.read import chats
|
|
||||||
from utils.read.models import (
|
|
||||||
Page,
|
|
||||||
StorageFileView,
|
|
||||||
StorageGroupView,
|
|
||||||
StorageScopeView,
|
|
||||||
StorageSummaryView,
|
|
||||||
TrashItemView,
|
|
||||||
)
|
|
||||||
from utils.trash import RETENTION
|
|
||||||
|
|
||||||
HEAVY_BYTES = 100 * 1024 * 1024
|
|
||||||
REFS_CHECKED = 5
|
|
||||||
LOOKUP_CONCURRENCY = 8
|
|
||||||
|
|
||||||
OTHER_GROUP = "other"
|
|
||||||
GROUP_KINDS: dict[str, tuple[str, ...]] = {
|
|
||||||
"video": ("video",),
|
|
||||||
"file": ("document",),
|
|
||||||
"voice": ("video_note", "voice"),
|
|
||||||
"audio": ("audio",),
|
|
||||||
}
|
|
||||||
_KIND_GROUP = {kind: group for group, kinds in GROUP_KINDS.items() for kind in kinds}
|
|
||||||
|
|
||||||
_SUMMARY = """
|
|
||||||
WITH blobs AS (
|
|
||||||
SELECT storage_key, max(file_size) AS size, min(kind) AS kind
|
|
||||||
FROM media
|
|
||||||
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
|
|
||||||
GROUP BY storage_key
|
|
||||||
)
|
|
||||||
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
|
|
||||||
count(*) FILTER (WHERE size >= $2) AS heavy_files,
|
|
||||||
coalesce(sum(size) FILTER (WHERE size >= $2), 0)::bigint AS heavy_bytes
|
|
||||||
FROM blobs GROUP BY kind
|
|
||||||
"""
|
|
||||||
|
|
||||||
_TRASH_TOTAL = """
|
|
||||||
SELECT count(*) AS files, coalesce(sum(file_size), 0)::bigint AS bytes
|
|
||||||
FROM media_trash WHERE account_id = $1
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FILES = """
|
|
||||||
WITH top AS MATERIALIZED (
|
|
||||||
SELECT storage_key, max(file_size) AS file_size, count(*) AS refs
|
|
||||||
FROM media
|
|
||||||
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
|
|
||||||
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
|
|
||||||
GROUP BY storage_key
|
|
||||||
ORDER BY 2 DESC NULLS LAST, 1
|
|
||||||
LIMIT $4 OFFSET $5
|
|
||||||
)
|
|
||||||
SELECT * FROM (
|
|
||||||
SELECT t.storage_key, t.file_size, t.refs, m.id, m.chat_id, m.message_id,
|
|
||||||
m.kind, m.mime, m.ttl_seconds,
|
|
||||||
row_number() OVER (PARTITION BY t.storage_key ORDER BY m.id) AS n
|
|
||||||
FROM top t
|
|
||||||
JOIN media m ON m.storage_key = t.storage_key
|
|
||||||
AND m.account_id = $1 AND m.downloaded
|
|
||||||
) ranked
|
|
||||||
WHERE n <= $6
|
|
||||||
ORDER BY file_size DESC NULLS LAST, storage_key, id
|
|
||||||
"""
|
|
||||||
|
|
||||||
_TRASH = """
|
|
||||||
SELECT storage_key, chat_id, message_id, kind, mime, file_name, file_size,
|
|
||||||
trashed_at
|
|
||||||
FROM media_trash WHERE account_id = $1
|
|
||||||
ORDER BY file_size DESC NULLS LAST, storage_key
|
|
||||||
LIMIT $2 OFFSET $3
|
|
||||||
"""
|
|
||||||
|
|
||||||
SCOPE_BLOBS = """
|
|
||||||
WITH inside AS (
|
|
||||||
SELECT DISTINCT storage_key FROM media
|
|
||||||
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
|
|
||||||
AND downloaded AND storage_key IS NOT NULL
|
|
||||||
), blobs AS (
|
|
||||||
SELECT m.storage_key, max(m.file_size) AS file_size,
|
|
||||||
min(m.id) FILTER (
|
|
||||||
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
|
|
||||||
) AS media_id,
|
|
||||||
bool_and(m.account_id = $1 AND m.chat_id = ANY($2::bigint[])) AS own
|
|
||||||
FROM media m JOIN inside i ON i.storage_key = m.storage_key
|
|
||||||
GROUP BY m.storage_key
|
|
||||||
)
|
|
||||||
SELECT b.storage_key, b.file_size, r.chat_id, r.message_id, r.kind, r.mime,
|
|
||||||
b.own
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM stories s WHERE s.storage_key = b.storage_key)
|
|
||||||
AND NOT EXISTS (SELECT 1 FROM avatars a WHERE a.storage_key = b.storage_key)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM custom_emoji e WHERE e.storage_key = b.storage_key
|
|
||||||
) AS free
|
|
||||||
FROM blobs b JOIN media r ON r.id = b.media_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
_SCOPE_GONE = """
|
|
||||||
SELECT (
|
|
||||||
SELECT count(DISTINCT m.storage_key) FROM media m
|
|
||||||
JOIN messages g ON g.account_id = m.account_id AND g.chat_id = m.chat_id
|
|
||||||
AND g.message_id = m.message_id
|
|
||||||
WHERE m.account_id = $1 AND m.chat_id = ANY($2::bigint[])
|
|
||||||
AND m.downloaded AND m.storage_key IS NOT NULL
|
|
||||||
AND g.deleted_at IS NOT NULL
|
|
||||||
) + (
|
|
||||||
SELECT count(DISTINCT storage_key) FROM media
|
|
||||||
WHERE account_id = $1 AND chat_id = ANY($2::bigint[])
|
|
||||||
AND downloaded AND storage_key IS NOT NULL AND ttl_seconds IS NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_TRASH_REFS = """
|
|
||||||
SELECT * FROM (
|
|
||||||
SELECT evicted_key AS storage_key, chat_id, message_id, kind, ttl_seconds,
|
|
||||||
count(*) OVER (PARTITION BY evicted_key) AS refs,
|
|
||||||
row_number() OVER (PARTITION BY evicted_key ORDER BY id) AS n
|
|
||||||
FROM media
|
|
||||||
WHERE account_id = $1 AND evicted_key = ANY($2::text[])
|
|
||||||
) ranked
|
|
||||||
WHERE n <= $3
|
|
||||||
"""
|
|
||||||
|
|
||||||
_MESSAGE = """
|
|
||||||
SELECT date, deleted_at, raw->$4::text->>'file_name' AS original_name
|
|
||||||
FROM messages
|
|
||||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
|
|
||||||
ORDER BY date DESC LIMIT 1
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
|
|
||||||
if group is None:
|
|
||||||
return None, True
|
|
||||||
if group == OTHER_GROUP:
|
|
||||||
return list(_KIND_GROUP), False
|
|
||||||
return list(GROUP_KINDS.get(group, ())), True
|
|
||||||
|
|
||||||
|
|
||||||
async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView:
|
|
||||||
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES)
|
|
||||||
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
|
|
||||||
groups = {
|
|
||||||
name: StorageGroupView(group=name, files=0, bytes=0)
|
|
||||||
for name in (*GROUP_KINDS, OTHER_GROUP)
|
|
||||||
}
|
|
||||||
for row in rows:
|
|
||||||
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
|
|
||||||
group.files += row["files"]
|
|
||||||
group.bytes += row["bytes"]
|
|
||||||
return StorageSummaryView(
|
|
||||||
files=sum(row["files"] for row in rows),
|
|
||||||
bytes=sum(row["bytes"] for row in rows),
|
|
||||||
heavy_files=sum(row["heavy_files"] for row in rows),
|
|
||||||
heavy_bytes=sum(row["heavy_bytes"] for row in rows),
|
|
||||||
heavy_threshold=HEAVY_BYTES,
|
|
||||||
trash_files=trash["files"],
|
|
||||||
trash_bytes=trash["bytes"],
|
|
||||||
groups=list(groups.values()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _messages(
|
|
||||||
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
|
|
||||||
) -> list[asyncpg.Record | None]:
|
|
||||||
gate = asyncio.Semaphore(LOOKUP_CONCURRENCY)
|
|
||||||
|
|
||||||
async def lookup(ref: asyncpg.Record) -> asyncpg.Record | None:
|
|
||||||
async with gate:
|
|
||||||
return await pool.fetchrow(
|
|
||||||
_MESSAGE, account_id, ref["chat_id"], ref["message_id"], ref["kind"]
|
|
||||||
)
|
|
||||||
|
|
||||||
return await asyncio.gather(*(lookup(ref) for ref in refs))
|
|
||||||
|
|
||||||
|
|
||||||
def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
|
|
||||||
return (
|
|
||||||
message is not None
|
|
||||||
and message["deleted_at"] is None
|
|
||||||
and ref["ttl_seconds"] is None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_files(
|
|
||||||
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
|
|
||||||
) -> list[StorageFileView]:
|
|
||||||
kinds, include = _group_filter(group)
|
|
||||||
refs = await pool.fetch(
|
|
||||||
_FILES, account_id, kinds, include, page.capped_limit, page.offset, REFS_CHECKED
|
|
||||||
)
|
|
||||||
messages = await _messages(pool, account_id, refs)
|
|
||||||
files: dict[str, StorageFileView] = {}
|
|
||||||
for ref, message in zip(refs, messages, strict=True):
|
|
||||||
alive = _alive(ref, message)
|
|
||||||
known = files.get(ref["storage_key"])
|
|
||||||
if known is not None and (known.refetchable or not alive):
|
|
||||||
continue
|
|
||||||
files[ref["storage_key"]] = StorageFileView(
|
|
||||||
media_id=ref["id"],
|
|
||||||
chat_id=ref["chat_id"],
|
|
||||||
message_id=ref["message_id"],
|
|
||||||
kind=ref["kind"],
|
|
||||||
mime=ref["mime"],
|
|
||||||
file_name=media_file_name(
|
|
||||||
ref["kind"],
|
|
||||||
ref["mime"],
|
|
||||||
ref["message_id"],
|
|
||||||
message["original_name"] if message else None,
|
|
||||||
),
|
|
||||||
file_size=ref["file_size"] or 0,
|
|
||||||
date=message["date"] if message else None,
|
|
||||||
refs=ref["refs"],
|
|
||||||
refetchable=alive,
|
|
||||||
)
|
|
||||||
return list(files.values())
|
|
||||||
|
|
||||||
|
|
||||||
async def list_trash(
|
|
||||||
pool: asyncpg.Pool, account_id: int, page: Page
|
|
||||||
) -> list[TrashItemView]:
|
|
||||||
rows = await pool.fetch(_TRASH, account_id, page.capped_limit, page.offset)
|
|
||||||
refs = await pool.fetch(
|
|
||||||
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
|
|
||||||
)
|
|
||||||
messages = await _messages(pool, account_id, refs)
|
|
||||||
alive: dict[str, bool] = defaultdict(bool)
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
names: dict[str, str] = {}
|
|
||||||
for ref, message in zip(refs, messages, strict=True):
|
|
||||||
key = ref["storage_key"]
|
|
||||||
alive[key] |= _alive(ref, message)
|
|
||||||
counts[key] = ref["refs"]
|
|
||||||
if message is not None and message["original_name"]:
|
|
||||||
names.setdefault(key, message["original_name"])
|
|
||||||
return [
|
|
||||||
TrashItemView(
|
|
||||||
**{**dict(row), "file_name": _trash_name(row, names)},
|
|
||||||
purge_at=row["trashed_at"] + RETENTION,
|
|
||||||
refs=counts.get(row["storage_key"], 0),
|
|
||||||
refetchable=alive.get(row["storage_key"], True),
|
|
||||||
)
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _trash_name(row: asyncpg.Record, names: dict[str, str]) -> str:
|
|
||||||
original = names.get(row["storage_key"])
|
|
||||||
if original is None:
|
|
||||||
return row["file_name"]
|
|
||||||
return media_file_name(row["kind"], row["mime"], row["message_id"], original)
|
|
||||||
|
|
||||||
|
|
||||||
async def scope_chat_ids(
|
|
||||||
pool: asyncpg.Pool, account_id: int, chat_id: int | None, folder_id: int | None
|
|
||||||
) -> list[int] | None:
|
|
||||||
if chat_id is not None:
|
|
||||||
return [chat_id]
|
|
||||||
if folder_id is None:
|
|
||||||
return None
|
|
||||||
folder = await policy_repository.get_folder(pool, account_id, folder_id)
|
|
||||||
if folder is None:
|
|
||||||
return None
|
|
||||||
return await chats.folder_chat_ids(pool, account_id, folder)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_scope(
|
|
||||||
pool: asyncpg.Pool, account_id: int, chat_ids: list[int]
|
|
||||||
) -> StorageScopeView:
|
|
||||||
blobs = await pool.fetch(SCOPE_BLOBS, account_id, chat_ids)
|
|
||||||
free = [blob for blob in blobs if blob["free"]]
|
|
||||||
return StorageScopeView(
|
|
||||||
chats=len(chat_ids),
|
|
||||||
files=len(free),
|
|
||||||
bytes=sum(blob["file_size"] or 0 for blob in free),
|
|
||||||
kept=len(blobs) - len(free),
|
|
||||||
gone=await pool.fetchval(_SCOPE_GONE, account_id, chat_ids) if free else 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_trash_item(pool: asyncpg.Pool, storage_key: str) -> asyncpg.Record | None:
|
|
||||||
return await pool.fetchrow(
|
|
||||||
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
|
|
||||||
storage_key,
|
|
||||||
)
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
TRASH_DIR = ".trash"
|
|
||||||
|
|
||||||
|
|
||||||
class ContentAddressedStorage:
|
class ContentAddressedStorage:
|
||||||
def __init__(self, root: str | Path, shard_depth: int = 2) -> None:
|
def __init__(self, root: str | Path, shard_depth: int = 2) -> None:
|
||||||
@@ -29,32 +27,3 @@ class ContentAddressedStorage:
|
|||||||
|
|
||||||
def url(self, key: str) -> str:
|
def url(self, key: str) -> str:
|
||||||
return str(self._path(key))
|
return str(self._path(key))
|
||||||
|
|
||||||
def _trash_path(self, key: str) -> Path:
|
|
||||||
return self._root / TRASH_DIR / key
|
|
||||||
|
|
||||||
def trash(self, key: str) -> None:
|
|
||||||
target = self._trash_path(key)
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._path(key).replace(target)
|
|
||||||
|
|
||||||
def restore(self, key: str) -> bool:
|
|
||||||
source = self._trash_path(key)
|
|
||||||
target = self._path(key)
|
|
||||||
if not source.exists():
|
|
||||||
return target.exists()
|
|
||||||
if target.exists():
|
|
||||||
source.unlink()
|
|
||||||
else:
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
source.replace(target)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def purge(self, key: str) -> None:
|
|
||||||
self._trash_path(key).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def trashed(self, key: str) -> bool:
|
|
||||||
return self._trash_path(key).is_file()
|
|
||||||
|
|
||||||
def trash_url(self, key: str) -> str:
|
|
||||||
return str(self._trash_path(key))
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from utils.files import media_file_name
|
|
||||||
from utils.read.media import get_media
|
|
||||||
from utils.read.models import TrashOutcome
|
|
||||||
from utils.storage import ContentAddressedStorage
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
RETENTION = timedelta(days=7)
|
|
||||||
PURGE_INTERVAL_SECONDS = 3600
|
|
||||||
BATCH = 200
|
|
||||||
|
|
||||||
_SHARED_ELSEWHERE = """
|
|
||||||
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
|
|
||||||
OR EXISTS (SELECT 1 FROM avatars WHERE storage_key = $1)
|
|
||||||
OR EXISTS (SELECT 1 FROM custom_emoji WHERE storage_key = $1)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_EVICT = """
|
|
||||||
UPDATE media SET storage_key = NULL, downloaded = false,
|
|
||||||
evicted_at = now(), evicted_key = $2
|
|
||||||
WHERE storage_key = $1
|
|
||||||
"""
|
|
||||||
|
|
||||||
_INSERT = """
|
|
||||||
INSERT INTO media_trash
|
|
||||||
(storage_key, account_id, chat_id, message_id, kind, mime, file_name, file_size)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
ON CONFLICT (storage_key) DO UPDATE SET trashed_at = now()
|
|
||||||
"""
|
|
||||||
|
|
||||||
_RESTORE = """
|
|
||||||
UPDATE media SET storage_key = $1, downloaded = true,
|
|
||||||
evicted_at = NULL, evicted_key = NULL
|
|
||||||
WHERE evicted_key = $1 OR (
|
|
||||||
evicted_at IS NOT NULL AND unique_id IN (
|
|
||||||
SELECT unique_id FROM media
|
|
||||||
WHERE evicted_key = $1 AND unique_id IS NOT NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
|
|
||||||
_FORGET_KEYS = """
|
|
||||||
UPDATE media SET evicted_key = NULL WHERE evicted_key = ANY($1::text[])
|
|
||||||
"""
|
|
||||||
|
|
||||||
_EVICT_MANY = """
|
|
||||||
UPDATE media SET evicted_key = storage_key, storage_key = NULL,
|
|
||||||
downloaded = false, evicted_at = now()
|
|
||||||
WHERE storage_key = ANY($1::text[])
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
async def move_to_trash(
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
storage: ContentAddressedStorage,
|
|
||||||
account_id: int,
|
|
||||||
media_ids: list[int],
|
|
||||||
) -> TrashOutcome:
|
|
||||||
outcome = TrashOutcome()
|
|
||||||
for media_id in media_ids:
|
|
||||||
media = await get_media(pool, media_id)
|
|
||||||
if media is None or media.account_id != account_id or not media.storage_key:
|
|
||||||
continue
|
|
||||||
key = media.storage_key
|
|
||||||
on_disk = storage.exists(key)
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
|
||||||
if await conn.fetchval(_SHARED_ELSEWHERE, key):
|
|
||||||
outcome.blocked.append(media_id)
|
|
||||||
continue
|
|
||||||
await conn.execute(_EVICT, key, key if on_disk else None)
|
|
||||||
if not on_disk:
|
|
||||||
continue
|
|
||||||
await conn.execute(
|
|
||||||
_INSERT,
|
|
||||||
key,
|
|
||||||
account_id,
|
|
||||||
media.chat_id,
|
|
||||||
media.message_id,
|
|
||||||
media.kind,
|
|
||||||
media.mime,
|
|
||||||
media.file_name,
|
|
||||||
media.file_size,
|
|
||||||
)
|
|
||||||
storage.trash(key)
|
|
||||||
outcome.keys.append(key)
|
|
||||||
outcome.files += 1
|
|
||||||
outcome.bytes += media.file_size or 0
|
|
||||||
return outcome
|
|
||||||
|
|
||||||
|
|
||||||
def _move_batch(storage: ContentAddressedStorage, keys: list[str]) -> None:
|
|
||||||
moved: list[str] = []
|
|
||||||
try:
|
|
||||||
for key in keys:
|
|
||||||
if storage.exists(key):
|
|
||||||
storage.trash(key)
|
|
||||||
moved.append(key)
|
|
||||||
except OSError:
|
|
||||||
for key in moved:
|
|
||||||
storage.restore(key)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def trash_blobs(
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
storage: ContentAddressedStorage,
|
|
||||||
account_id: int,
|
|
||||||
blobs: list[asyncpg.Record],
|
|
||||||
) -> TrashOutcome:
|
|
||||||
outcome = TrashOutcome()
|
|
||||||
for start in range(0, len(blobs), BATCH):
|
|
||||||
batch = blobs[start : start + BATCH]
|
|
||||||
keys = [blob["storage_key"] for blob in batch]
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute(_EVICT_MANY, keys)
|
|
||||||
await conn.executemany(
|
|
||||||
_INSERT,
|
|
||||||
[
|
|
||||||
(
|
|
||||||
blob["storage_key"],
|
|
||||||
account_id,
|
|
||||||
blob["chat_id"],
|
|
||||||
blob["message_id"],
|
|
||||||
blob["kind"],
|
|
||||||
blob["mime"],
|
|
||||||
media_file_name(blob["kind"], blob["mime"], blob["message_id"]),
|
|
||||||
blob["file_size"],
|
|
||||||
)
|
|
||||||
for blob in batch
|
|
||||||
],
|
|
||||||
)
|
|
||||||
await asyncio.to_thread(_move_batch, storage, keys)
|
|
||||||
outcome.keys.extend(keys)
|
|
||||||
outcome.files += len(batch)
|
|
||||||
outcome.bytes += sum(blob["file_size"] or 0 for blob in batch)
|
|
||||||
return outcome
|
|
||||||
|
|
||||||
|
|
||||||
async def restore(
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
storage: ContentAddressedStorage,
|
|
||||||
account_id: int,
|
|
||||||
keys: list[str] | None,
|
|
||||||
) -> TrashOutcome:
|
|
||||||
if keys is None:
|
|
||||||
rows = await pool.fetch(
|
|
||||||
"SELECT storage_key FROM media_trash WHERE account_id = $1", account_id
|
|
||||||
)
|
|
||||||
keys = [row["storage_key"] for row in rows]
|
|
||||||
outcome = TrashOutcome()
|
|
||||||
for key in keys:
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
"DELETE FROM media_trash WHERE storage_key = $1 AND account_id = $2 "
|
|
||||||
"RETURNING file_size",
|
|
||||||
key,
|
|
||||||
account_id,
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
continue
|
|
||||||
if not storage.restore(key):
|
|
||||||
await conn.execute(_FORGET_KEYS, [key])
|
|
||||||
continue
|
|
||||||
await conn.execute(_RESTORE, key)
|
|
||||||
outcome.files += 1
|
|
||||||
outcome.bytes += row["file_size"] or 0
|
|
||||||
return outcome
|
|
||||||
|
|
||||||
|
|
||||||
async def _purge(
|
|
||||||
pool: asyncpg.Pool, storage: ContentAddressedStorage, rows: list[asyncpg.Record]
|
|
||||||
) -> TrashOutcome:
|
|
||||||
keys = [row["storage_key"] for row in rows]
|
|
||||||
for key in keys:
|
|
||||||
storage.purge(key)
|
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute(
|
|
||||||
"DELETE FROM media_trash WHERE storage_key = ANY($1::text[])", keys
|
|
||||||
)
|
|
||||||
await conn.execute(_FORGET_KEYS, keys)
|
|
||||||
return TrashOutcome(
|
|
||||||
files=len(rows), bytes=sum(row["file_size"] or 0 for row in rows)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def purge(
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
storage: ContentAddressedStorage,
|
|
||||||
account_id: int,
|
|
||||||
keys: list[str] | None,
|
|
||||||
) -> TrashOutcome:
|
|
||||||
rows = await pool.fetch(
|
|
||||||
"SELECT storage_key, file_size FROM media_trash WHERE account_id = $1 "
|
|
||||||
"AND ($2::text[] IS NULL OR storage_key = ANY($2))",
|
|
||||||
account_id,
|
|
||||||
keys,
|
|
||||||
)
|
|
||||||
return await _purge(pool, storage, rows)
|
|
||||||
|
|
||||||
|
|
||||||
async def purge_expired(
|
|
||||||
pool: asyncpg.Pool, storage: ContentAddressedStorage
|
|
||||||
) -> TrashOutcome:
|
|
||||||
rows = await pool.fetch(
|
|
||||||
"SELECT storage_key, file_size FROM media_trash "
|
|
||||||
"WHERE trashed_at < now() - $1::interval",
|
|
||||||
RETENTION,
|
|
||||||
)
|
|
||||||
return await _purge(pool, storage, rows)
|
|
||||||
|
|
||||||
|
|
||||||
async def purge_loop(pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
outcome = await purge_expired(pool, storage)
|
|
||||||
if outcome.files:
|
|
||||||
logger.info(
|
|
||||||
"Trash purged: %s files, %s bytes", outcome.files, outcome.bytes
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Trash purge failed")
|
|
||||||
await asyncio.sleep(PURGE_INTERVAL_SECONDS)
|
|
||||||
@@ -95,19 +95,6 @@ export async function request<T>(
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamUrl(path: string): string {
|
|
||||||
const query = auth.token ? `?token=${encodeURIComponent(auth.token)}` : "";
|
|
||||||
return `${BASE}${path}${query}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mediaUrl(mediaId: number): string {
|
|
||||||
return streamUrl(`/media/${mediaId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trashedUrl(storageKey: string): string {
|
|
||||||
return streamUrl(`/storage/trash/${storageKey}/file`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MediaResult =
|
export type MediaResult =
|
||||||
| { state: "ready"; url: string; mime: string | null }
|
| { state: "ready"; url: string; mime: string | null }
|
||||||
| { state: "not-downloaded" }
|
| { state: "not-downloaded" }
|
||||||
|
|||||||
@@ -26,21 +26,12 @@ import type {
|
|||||||
PolicyChatKind,
|
PolicyChatKind,
|
||||||
PolicyCreate,
|
PolicyCreate,
|
||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
PolicyRule,
|
|
||||||
PolicyScope,
|
|
||||||
PresenceHourly,
|
PresenceHourly,
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
ReactionView,
|
ReactionView,
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
ScopeTarget,
|
|
||||||
SearchHit,
|
SearchHit,
|
||||||
StorageFile,
|
|
||||||
StorageGroupName,
|
|
||||||
StorageScope,
|
|
||||||
StorageSummary,
|
|
||||||
StoryView,
|
StoryView,
|
||||||
TrashItem,
|
|
||||||
TrashOutcome,
|
|
||||||
VolumeBucket,
|
VolumeBucket,
|
||||||
Watch,
|
Watch,
|
||||||
WatchedBot,
|
WatchedBot,
|
||||||
@@ -152,23 +143,6 @@ export function deletePolicy(id: number): Promise<void> {
|
|||||||
return request<void>(`/policy/${id}`, { method: "DELETE" });
|
return request<void>(`/policy/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeQuery(target: ScopeTarget): Record<string, number> {
|
|
||||||
return "chatId" in target
|
|
||||||
? { chat_id: target.chatId }
|
|
||||||
: { folder_id: target.folderId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPolicyOverview(): Promise<PolicyRule[]> {
|
|
||||||
return request<PolicyRule[]>("/policy/overview", { account: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPolicyScope(target: ScopeTarget): Promise<PolicyScope> {
|
|
||||||
return request<PolicyScope>("/policy/scope", {
|
|
||||||
account: true,
|
|
||||||
query: scopeQuery(target),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function effectivePolicy(query: {
|
export function effectivePolicy(query: {
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
is_bot?: boolean;
|
is_bot?: boolean;
|
||||||
@@ -607,59 +581,3 @@ export function addWatchedBot(token: string): Promise<WatchedBot> {
|
|||||||
export function removeWatchedBot(botId: number): Promise<void> {
|
export function removeWatchedBot(botId: number): Promise<void> {
|
||||||
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageSummary(): Promise<StorageSummary> {
|
|
||||||
return request<StorageSummary>("/storage/summary", { account: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStorageFiles(
|
|
||||||
group: StorageGroupName | null,
|
|
||||||
page: Page = {}
|
|
||||||
): Promise<StorageFile[]> {
|
|
||||||
return request<StorageFile[]>("/storage/files", {
|
|
||||||
account: true,
|
|
||||||
query: { group, ...page },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTrash(page: Page = {}): Promise<TrashItem[]> {
|
|
||||||
return request<TrashItem[]>("/storage/trash", {
|
|
||||||
account: true,
|
|
||||||
query: { ...page },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStorageScope(target: ScopeTarget): Promise<StorageScope> {
|
|
||||||
return request<StorageScope>("/storage/scope", {
|
|
||||||
account: true,
|
|
||||||
query: scopeQuery(target),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trashScope(target: ScopeTarget): Promise<TrashOutcome> {
|
|
||||||
return request<TrashOutcome>("/storage/trash/scope", {
|
|
||||||
body: { account_id: accounts.selectedId, ...scopeQuery(target) },
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
|
|
||||||
return request<TrashOutcome>("/storage/trash", {
|
|
||||||
body: { account_id: accounts.selectedId, media_ids: mediaIds },
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restoreFromTrash(keys: string[] | null): Promise<TrashOutcome> {
|
|
||||||
return request<TrashOutcome>("/storage/trash/restore", {
|
|
||||||
body: { account_id: accounts.selectedId, keys },
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function purgeTrash(keys: string[] | null): Promise<TrashOutcome> {
|
|
||||||
return request<TrashOutcome>("/storage/trash/purge", {
|
|
||||||
body: { account_id: accounts.selectedId, keys },
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ export type InlineMedia =
|
|||||||
| { state: "missing" };
|
| { state: "missing" };
|
||||||
|
|
||||||
export interface ViewerItem {
|
export interface ViewerItem {
|
||||||
caption?: string;
|
|
||||||
downloaded: boolean;
|
downloaded: boolean;
|
||||||
fileName: string | null;
|
fileName: string | null;
|
||||||
fileSize: number | null;
|
fileSize: number | null;
|
||||||
@@ -27,7 +26,6 @@ export interface ViewerItem {
|
|||||||
mediaId: number | null;
|
mediaId: number | null;
|
||||||
messageId: number;
|
messageId: number;
|
||||||
mime: string | null;
|
mime: string | null;
|
||||||
src?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function viewerItemsFrom(
|
export function viewerItemsFrom(
|
||||||
@@ -104,16 +102,6 @@ export function visualKind(kind: string): VisualKind {
|
|||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
const STREAM_KINDS = new Set(["video", "video_note", "voice", "audio"]);
|
|
||||||
const STREAM_MIME_PREFIXES = ["video/", "audio/"];
|
|
||||||
|
|
||||||
export function isStreamable(kind: string, mime: string | null): boolean {
|
|
||||||
return (
|
|
||||||
STREAM_KINDS.has(kind) ||
|
|
||||||
STREAM_MIME_PREFIXES.some((prefix) => mime?.startsWith(prefix))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_CACHED = 200;
|
const MAX_CACHED = 200;
|
||||||
|
|
||||||
const ready = new Map<string, InlineMedia>();
|
const ready = new Map<string, InlineMedia>();
|
||||||
@@ -142,26 +130,6 @@ function cacheKey(account: number, chatId: number, messageId: number): string {
|
|||||||
return `${account}:${chatId}:${messageId}`;
|
return `${account}:${chatId}:${messageId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function forget(cache: Map<string, InlineMedia>, key: string) {
|
|
||||||
const stale = cache.get(key);
|
|
||||||
cache.delete(key);
|
|
||||||
if (stale?.state === "ready") {
|
|
||||||
URL.revokeObjectURL(stale.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function forgetMedia(
|
|
||||||
chatId: number,
|
|
||||||
messageId: number,
|
|
||||||
mediaId: number
|
|
||||||
): void {
|
|
||||||
const account = accounts.selectedId;
|
|
||||||
if (account !== null) {
|
|
||||||
forget(ready, cacheKey(account, chatId, messageId));
|
|
||||||
forget(byId, `${account}:${mediaId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolve(
|
async function resolve(
|
||||||
chatId: number,
|
chatId: number,
|
||||||
messageId: number
|
messageId: number
|
||||||
|
|||||||
@@ -423,24 +423,6 @@ export interface PolicyRecord extends CaptureToggles {
|
|||||||
scope_type: PolicyScopeType;
|
scope_type: PolicyScopeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PolicyParent {
|
|
||||||
scope_id: number | null;
|
|
||||||
scope_type: PolicyScopeType;
|
|
||||||
title: string | null;
|
|
||||||
toggles: CaptureToggles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolicyRule extends PolicyRecord {
|
|
||||||
parent: PolicyParent | null;
|
|
||||||
title: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PolicyScope {
|
|
||||||
parent: PolicyParent | null;
|
|
||||||
rule: PolicyRecord | null;
|
|
||||||
title: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PolicyChatKind = "dm" | "group" | "channel";
|
export type PolicyChatKind = "dm" | "group" | "channel";
|
||||||
|
|
||||||
export interface PolicyCreate extends CaptureToggles {
|
export interface PolicyCreate extends CaptureToggles {
|
||||||
@@ -578,66 +560,3 @@ export interface WatchedBot {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StorageGroupName = "video" | "file" | "voice" | "audio" | "other";
|
|
||||||
|
|
||||||
export interface StorageGroup {
|
|
||||||
bytes: number;
|
|
||||||
files: number;
|
|
||||||
group: StorageGroupName;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageSummary {
|
|
||||||
bytes: number;
|
|
||||||
files: number;
|
|
||||||
groups: StorageGroup[];
|
|
||||||
heavy_bytes: number;
|
|
||||||
heavy_files: number;
|
|
||||||
heavy_threshold: number;
|
|
||||||
trash_bytes: number;
|
|
||||||
trash_files: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageFile {
|
|
||||||
chat_id: number;
|
|
||||||
date: string | null;
|
|
||||||
file_name: string;
|
|
||||||
file_size: number;
|
|
||||||
kind: string;
|
|
||||||
media_id: number;
|
|
||||||
message_id: number;
|
|
||||||
mime: string | null;
|
|
||||||
refetchable: boolean;
|
|
||||||
refs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrashItem {
|
|
||||||
chat_id: number;
|
|
||||||
file_name: string;
|
|
||||||
file_size: number | null;
|
|
||||||
kind: string;
|
|
||||||
message_id: number;
|
|
||||||
mime: string | null;
|
|
||||||
purge_at: string;
|
|
||||||
refetchable: boolean;
|
|
||||||
refs: number;
|
|
||||||
storage_key: string;
|
|
||||||
trashed_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrashOutcome {
|
|
||||||
blocked: number[];
|
|
||||||
bytes: number;
|
|
||||||
files: number;
|
|
||||||
keys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StorageScope {
|
|
||||||
bytes: number;
|
|
||||||
chats: number;
|
|
||||||
files: number;
|
|
||||||
gone: number;
|
|
||||||
kept: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ScopeTarget = { chatId: number } | { folderId: number };
|
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
import { discover } from "#lib/stores/discover.svelte.js";
|
import { discover } from "#lib/stores/discover.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
|
||||||
import { toasts } from "#lib/stores/toasts.svelte.js";
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
@@ -187,11 +186,6 @@
|
|||||||
onselect={() => ui.openPanel("stories")}
|
onselect={() => ui.openPanel("stories")}
|
||||||
>Сторис</ContextMenuItem
|
>Сторис</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem
|
|
||||||
icon="settings"
|
|
||||||
onselect={() => policyUi.edit({ chatId }, title)}
|
|
||||||
>Политика захвата</ContextMenuItem
|
|
||||||
>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import { formatListDate } from "#lib/format/datetime.js";
|
import { formatListDate } from "#lib/format/datetime.js";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
import { peers } from "#lib/stores/peers.svelte.js";
|
import { peers } from "#lib/stores/peers.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
@@ -109,11 +108,6 @@
|
|||||||
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
|
<ContextMenuItem icon="play-story" onselect={() => openWith("stories")}
|
||||||
>Сторис</ContextMenuItem
|
>Сторис</ContextMenuItem
|
||||||
>
|
>
|
||||||
<ContextMenuItem
|
|
||||||
icon="settings"
|
|
||||||
onselect={() => policyUi.edit({ chatId: chat.chat_id }, title)}
|
|
||||||
>Политика захвата</ContextMenuItem
|
|
||||||
>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
|
||||||
import { folders } from "#lib/stores/folders.svelte.js";
|
import { folders } from "#lib/stores/folders.svelte.js";
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
@@ -24,12 +21,6 @@
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
function editPolicy(tab: Tab) {
|
|
||||||
if (tab.id !== null) {
|
|
||||||
policyUi.edit({ folderId: tab.id }, tab.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let containerEl = $state<HTMLDivElement>();
|
let containerEl = $state<HTMLDivElement>();
|
||||||
let indicatorEl = $state<HTMLDivElement>();
|
let indicatorEl = $state<HTMLDivElement>();
|
||||||
let clipPath = $state("");
|
let clipPath = $state("");
|
||||||
@@ -73,31 +64,9 @@
|
|||||||
|
|
||||||
<div class="container" bind:this={containerEl} class:ready={clipPath !== ""}>
|
<div class="container" bind:this={containerEl} class:ready={clipPath !== ""}>
|
||||||
{#each tabs as tab (tab.id)}
|
{#each tabs as tab (tab.id)}
|
||||||
{#if tab.id === null}
|
|
||||||
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
|
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
|
||||||
<ContextMenu>
|
|
||||||
{#snippet children({
|
|
||||||
props,
|
|
||||||
})}
|
|
||||||
<button
|
|
||||||
{...props}
|
|
||||||
class="tab"
|
|
||||||
onclick={() => folders.select(tab.id)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet menu()}
|
|
||||||
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
|
|
||||||
Политика захвата
|
|
||||||
</ContextMenuItem>
|
|
||||||
{/snippet}
|
|
||||||
</ContextMenu>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Dialog } from "bits-ui";
|
import { Dialog } from "bits-ui";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { type MediaResult, mediaUrl, requestMedia } from "#lib/api/client.js";
|
import { type MediaResult, requestMedia } from "#lib/api/client.js";
|
||||||
import { downloadMedia } from "#lib/api/download.js";
|
import { downloadMedia } from "#lib/api/download.js";
|
||||||
import { fetchMedia, getMessageMedia } from "#lib/api/endpoints.js";
|
import { fetchMedia, getMessageMedia } from "#lib/api/endpoints.js";
|
||||||
import {
|
import { isPreviewable, type ViewerItem } from "#lib/api/media.js";
|
||||||
isPreviewable,
|
|
||||||
isStreamable,
|
|
||||||
type ViewerItem,
|
|
||||||
} from "#lib/api/media.js";
|
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
||||||
@@ -23,7 +19,6 @@
|
|||||||
chatId: number;
|
chatId: number;
|
||||||
index: number;
|
index: number;
|
||||||
items: ViewerItem[];
|
items: ViewerItem[];
|
||||||
ontrash?: (item: ViewerItem) => void;
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,18 +27,14 @@
|
|||||||
index = $bindable(),
|
index = $bindable(),
|
||||||
chatId,
|
chatId,
|
||||||
items,
|
items,
|
||||||
ontrash,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const STREAM_BYTES = 32 * 1024 * 1024;
|
|
||||||
|
|
||||||
let kind = $state("");
|
let kind = $state("");
|
||||||
let messageId = $state<number | null>(null);
|
let messageId = $state<number | null>(null);
|
||||||
let currentMediaId = $state<number | null>(null);
|
let currentMediaId = $state<number | null>(null);
|
||||||
let fileName = $state<string | null>(null);
|
let fileName = $state<string | null>(null);
|
||||||
let fileSize = $state<number | null>(null);
|
let fileSize = $state<number | null>(null);
|
||||||
let fileOnly = $state(false);
|
let fileOnly = $state(false);
|
||||||
let streamed = $state(false);
|
|
||||||
let result = $state<MediaResult | null>(null);
|
let result = $state<MediaResult | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
@@ -62,54 +53,35 @@
|
|||||||
currentMediaId !== null && (fileOnly || result?.state === "ready")
|
currentMediaId !== null && (fileOnly || result?.state === "ready")
|
||||||
);
|
);
|
||||||
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
|
const title = $derived(fileName ?? mediaKindLabel(kind) ?? "Медиа");
|
||||||
const caption = $derived(items[index]?.caption);
|
|
||||||
|
|
||||||
function revoke() {
|
function revoke() {
|
||||||
if (result?.state === "ready" && result.url && !streamed) {
|
if (result?.state === "ready" && result.url) {
|
||||||
URL.revokeObjectURL(result.url);
|
URL.revokeObjectURL(result.url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function streamSource(
|
|
||||||
item: ViewerItem,
|
|
||||||
mediaId: number | null,
|
|
||||||
size: number | null
|
|
||||||
): string | null {
|
|
||||||
if (item.src) {
|
|
||||||
return item.src;
|
|
||||||
}
|
|
||||||
const heavy = size !== null && size > STREAM_BYTES;
|
|
||||||
return mediaId !== null && heavy && isStreamable(kind, item.mime)
|
|
||||||
? mediaUrl(mediaId)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(item: ViewerItem) {
|
async function load(item: ViewerItem) {
|
||||||
revoke();
|
revoke();
|
||||||
loading = true;
|
loading = true;
|
||||||
result = null;
|
result = null;
|
||||||
currentMediaId = null;
|
currentMediaId = null;
|
||||||
fileOnly = false;
|
fileOnly = false;
|
||||||
streamed = false;
|
|
||||||
({ kind, messageId, fileName, fileSize } = item);
|
({ kind, messageId, fileName, fileSize } = item);
|
||||||
token += 1;
|
token += 1;
|
||||||
const current = token;
|
const current = token;
|
||||||
try {
|
try {
|
||||||
let { mediaId, downloaded, mime: mimeType } = item;
|
let { mediaId, downloaded, mime: mimeType } = item;
|
||||||
let { fileName: name, fileSize: size } = item;
|
let { fileName: name, fileSize: size } = item;
|
||||||
if (mediaId === null && !item.src) {
|
if (mediaId === null) {
|
||||||
const meta = await getMessageMedia(chatId, item.messageId);
|
const meta = await getMessageMedia(chatId, item.messageId);
|
||||||
({ id: mediaId, downloaded, kind, mime: mimeType } = meta);
|
({ id: mediaId, downloaded, kind, mime: mimeType } = meta);
|
||||||
({ file_name: name, file_size: size } = meta);
|
({ file_name: name, file_size: size } = meta);
|
||||||
}
|
}
|
||||||
const plainFile = downloaded && !isPreviewable(kind, mimeType);
|
const plainFile = downloaded && !isPreviewable(kind, mimeType);
|
||||||
const source = downloaded ? streamSource(item, mediaId, size) : null;
|
|
||||||
let next: MediaResult;
|
let next: MediaResult;
|
||||||
if (plainFile) {
|
if (plainFile) {
|
||||||
next = { mime: mimeType, state: "ready", url: "" };
|
next = { mime: mimeType, state: "ready", url: "" };
|
||||||
} else if (source !== null) {
|
} else if (downloaded) {
|
||||||
next = { mime: mimeType, state: "ready", url: source };
|
|
||||||
} else if (downloaded && mediaId !== null) {
|
|
||||||
next = await requestMedia(mediaId);
|
next = await requestMedia(mediaId);
|
||||||
} else {
|
} else {
|
||||||
next = { state: "not-downloaded" } as MediaResult;
|
next = { state: "not-downloaded" } as MediaResult;
|
||||||
@@ -120,7 +92,6 @@
|
|||||||
fileName = name;
|
fileName = name;
|
||||||
fileSize = size;
|
fileSize = size;
|
||||||
fileOnly = plainFile;
|
fileOnly = plainFile;
|
||||||
streamed = source !== null && !plainFile;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
@@ -165,14 +136,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function trash() {
|
|
||||||
const item = items[index];
|
|
||||||
if (item && ontrash) {
|
|
||||||
open = false;
|
|
||||||
ontrash(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
const next = index + delta;
|
const next = index + delta;
|
||||||
if (next >= 0 && next < items.length) {
|
if (next >= 0 && next < items.length) {
|
||||||
@@ -218,21 +181,8 @@
|
|||||||
<Dialog.Overlay class="media-overlay" />
|
<Dialog.Overlay class="media-overlay" />
|
||||||
<Dialog.Content class="media-content">
|
<Dialog.Content class="media-content">
|
||||||
<Dialog.Title class="media-title">{title}</Dialog.Title>
|
<Dialog.Title class="media-title">{title}</Dialog.Title>
|
||||||
{#if caption}
|
|
||||||
<span class="media-caption">{caption}</span>
|
|
||||||
{/if}
|
|
||||||
<div class="media-actions">
|
<div class="media-actions">
|
||||||
{#if canSave}
|
{#if canSave}
|
||||||
{#if ontrash}
|
|
||||||
<button
|
|
||||||
aria-label="В корзину"
|
|
||||||
class="media-action"
|
|
||||||
onclick={trash}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.375rem" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
{#snippet children({
|
{#snippet children({
|
||||||
props,
|
props,
|
||||||
@@ -284,7 +234,7 @@
|
|||||||
class="media-video"
|
class="media-video"
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
preload={streamed ? "metadata" : "auto"}
|
preload="auto"
|
||||||
src={result.url}
|
src={result.url}
|
||||||
use:poster
|
use:poster
|
||||||
></video>
|
></video>
|
||||||
@@ -298,12 +248,10 @@
|
|||||||
{#if fileSize}
|
{#if fileSize}
|
||||||
<p class="file-size">{formatBytes(fileSize)}</p>
|
<p class="file-size">{formatBytes(fileSize)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if currentMediaId !== null}
|
|
||||||
<button class="media-download" onclick={save} type="button">
|
<button class="media-download" onclick={save} type="button">
|
||||||
<Icon name="download" />
|
<Icon name="download" />
|
||||||
Скачать файл
|
Скачать файл
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:else if result?.state === "not-downloaded"}
|
{:else if result?.state === "not-downloaded"}
|
||||||
<div class="media-message">
|
<div class="media-message">
|
||||||
@@ -375,20 +323,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.media-caption {
|
|
||||||
position: absolute;
|
|
||||||
top: 2.75rem;
|
|
||||||
left: 1.25rem;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: min(24rem, calc(100% - 14rem));
|
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: rgba(255, 255, 255, 0.6);
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-counter {
|
.media-counter {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1rem;
|
top: 1rem;
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
|
import AlertsPanel from "#lib/components/alerts/AlertsPanel.svelte";
|
||||||
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
|
import AnnotationsPanel from "#lib/components/annotations/AnnotationsPanel.svelte";
|
||||||
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
|
import BotsPanel from "#lib/components/bots/BotsPanel.svelte";
|
||||||
import StoragePanel from "#lib/components/disk/StoragePanel.svelte";
|
|
||||||
import TrashPanel from "#lib/components/disk/TrashPanel.svelte";
|
|
||||||
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
|
import JobsPanel from "#lib/components/jobs/JobsPanel.svelte";
|
||||||
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
import PolicyEditor from "#lib/components/policy/PolicyEditor.svelte";
|
||||||
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
import AnalyticsPanel from "#lib/components/presence/AnalyticsPanel.svelte";
|
||||||
@@ -18,8 +16,6 @@
|
|||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
import WatchesPanel from "#lib/components/watches/WatchesPanel.svelte";
|
||||||
import { accounts } from "#lib/stores/accounts.svelte.js";
|
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
|
||||||
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
import { type RightPanel, ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
const titles: Record<RightPanel, string> = {
|
const titles: Record<RightPanel, string> = {
|
||||||
@@ -27,7 +23,7 @@
|
|||||||
annotations: "Заметки",
|
annotations: "Заметки",
|
||||||
bots: "Боты",
|
bots: "Боты",
|
||||||
callbacks: "Callback-данные",
|
callbacks: "Callback-данные",
|
||||||
jobs: "Бэкфилл и задачи",
|
jobs: "Данные и хранилище",
|
||||||
links: "Ссылки",
|
links: "Ссылки",
|
||||||
policy: "Политика захвата",
|
policy: "Политика захвата",
|
||||||
presence: "Аналитика",
|
presence: "Аналитика",
|
||||||
@@ -35,33 +31,14 @@
|
|||||||
reactions: "Реакции",
|
reactions: "Реакции",
|
||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
shares: "Файлы по ссылке",
|
shares: "Файлы по ссылке",
|
||||||
storage: "Хранилище",
|
|
||||||
"storage-trash": "Корзина",
|
|
||||||
stories: "Сторис",
|
stories: "Сторис",
|
||||||
"stories-all": "Все сторис",
|
"stories-all": "Все сторис",
|
||||||
versions: "Версии",
|
versions: "Версии",
|
||||||
watches: "Отслеживания",
|
watches: "Отслеживания",
|
||||||
};
|
};
|
||||||
|
|
||||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
|
||||||
"storage-trash": "storage",
|
|
||||||
};
|
|
||||||
|
|
||||||
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="right-header">
|
<header class="right-header">
|
||||||
{#if parent}
|
|
||||||
<Button
|
|
||||||
aria-label="Назад"
|
|
||||||
onclick={() => ui.openPanel(parent)}
|
|
||||||
round
|
|
||||||
smaller
|
|
||||||
variant="translucent"
|
|
||||||
>
|
|
||||||
<Icon name="arrow-left" />
|
|
||||||
</Button>
|
|
||||||
{:else}
|
|
||||||
<Button
|
<Button
|
||||||
aria-label="Закрыть"
|
aria-label="Закрыть"
|
||||||
onclick={() => ui.closePanel()}
|
onclick={() => ui.closePanel()}
|
||||||
@@ -71,7 +48,6 @@
|
|||||||
>
|
>
|
||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
|
||||||
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
|
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -79,9 +55,7 @@
|
|||||||
{#if ui.rightPanel === "profile"}
|
{#if ui.rightPanel === "profile"}
|
||||||
<ProfilePanel />
|
<ProfilePanel />
|
||||||
{:else if ui.rightPanel === "policy"}
|
{:else if ui.rightPanel === "policy"}
|
||||||
{#key `${accounts.selectedId}:${policyUi.revision}`}
|
|
||||||
<PolicyEditor />
|
<PolicyEditor />
|
||||||
{/key}
|
|
||||||
{:else if ui.rightPanel === "jobs"}
|
{:else if ui.rightPanel === "jobs"}
|
||||||
<JobsPanel />
|
<JobsPanel />
|
||||||
{:else if ui.rightPanel === "presence"}
|
{:else if ui.rightPanel === "presence"}
|
||||||
@@ -108,14 +82,6 @@
|
|||||||
<SharesPanel />
|
<SharesPanel />
|
||||||
{:else if ui.rightPanel === "bots"}
|
{:else if ui.rightPanel === "bots"}
|
||||||
<BotsPanel />
|
<BotsPanel />
|
||||||
{:else if ui.rightPanel === "storage"}
|
|
||||||
{#key accounts.selectedId}
|
|
||||||
<StoragePanel />
|
|
||||||
{/key}
|
|
||||||
{:else if ui.rightPanel === "storage-trash"}
|
|
||||||
{#key accounts.selectedId}
|
|
||||||
<TrashPanel />
|
|
||||||
{/key}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: Snippet;
|
|
||||||
failure?: string | null;
|
|
||||||
note?: string;
|
|
||||||
title: string;
|
|
||||||
warn?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { title, note, warn = false, failure, children }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bar">
|
|
||||||
<div class="info">
|
|
||||||
<div class="title">{title}</div>
|
|
||||||
{#if note}
|
|
||||||
<div class="note" class:warn>{note}</div>
|
|
||||||
{/if}
|
|
||||||
{#if failure}
|
|
||||||
<div class="note failed">{failure}</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{@render children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.bar {
|
|
||||||
position: sticky;
|
|
||||||
z-index: 1;
|
|
||||||
bottom: 0;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
min-height: 4rem;
|
|
||||||
padding: 0.625rem 0.75rem calc(0.625rem + env(safe-area-inset-bottom)) 1rem;
|
|
||||||
border-top: 1px solid var(--color-borders);
|
|
||||||
|
|
||||||
background-color: var(--color-background);
|
|
||||||
box-shadow: 0 -0.5rem 1.5rem var(--color-light-shadow);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar :global(.Button) {
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding-inline: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
|
|
||||||
&.warn {
|
|
||||||
color: var(--color-storage-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.failed {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,681 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
|
||||||
import { mediaUrl } from "#lib/api/client.js";
|
|
||||||
import { downloadMedia } from "#lib/api/download.js";
|
|
||||||
import {
|
|
||||||
getStorageFiles,
|
|
||||||
getStorageSummary,
|
|
||||||
moveToTrash,
|
|
||||||
restoreFromTrash,
|
|
||||||
} from "#lib/api/endpoints.js";
|
|
||||||
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
|
|
||||||
import type {
|
|
||||||
StorageFile,
|
|
||||||
StorageGroupName,
|
|
||||||
StorageSummary,
|
|
||||||
TrashOutcome,
|
|
||||||
} from "#lib/api/types.js";
|
|
||||||
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
|
|
||||||
import StorageRow from "#lib/components/disk/StorageRow.svelte";
|
|
||||||
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
|
||||||
import { formatListDate } from "#lib/format/datetime.js";
|
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
|
||||||
import {
|
|
||||||
filesLabel,
|
|
||||||
GROUP_LABELS,
|
|
||||||
messagesLabel,
|
|
||||||
} from "#lib/format/storage.js";
|
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
|
||||||
import { isMobile } from "#lib/viewport.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
const PAGE = 30;
|
|
||||||
const UNDO_MS = 12_000;
|
|
||||||
const UNDO_GUARD_MS = 600;
|
|
||||||
|
|
||||||
let summary = $state<StorageSummary | null>(null);
|
|
||||||
let files = $state<StorageFile[]>([]);
|
|
||||||
let group = $state<StorageGroupName | null>(null);
|
|
||||||
let loading = $state(false);
|
|
||||||
let failed = $state(false);
|
|
||||||
let done = $state(false);
|
|
||||||
let busy = $state(false);
|
|
||||||
let undo = $state<{ files: StorageFile[]; outcome: TrashOutcome } | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
let problem = $state<string | null>(null);
|
|
||||||
let viewerOpen = $state(false);
|
|
||||||
let viewerIndex = $state(0);
|
|
||||||
const selected = new SvelteSet<number>();
|
|
||||||
let token = 0;
|
|
||||||
let undoTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let undoShownAt = 0;
|
|
||||||
|
|
||||||
const heaviest = $derived(files[0]?.file_size ?? 0);
|
|
||||||
const chosen = $derived(files.filter((file) => selected.has(file.media_id)));
|
|
||||||
const chosenBytes = $derived(
|
|
||||||
chosen.reduce((sum, file) => sum + file.file_size, 0)
|
|
||||||
);
|
|
||||||
const chosenGone = $derived(
|
|
||||||
chosen.filter((file) => !file.refetchable).length
|
|
||||||
);
|
|
||||||
const groups = $derived(
|
|
||||||
summary?.groups.filter((item) => item.files > 0 || item.group === group) ??
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
const viewerItems = $derived<ViewerItem[]>(
|
|
||||||
files.map((file) => ({
|
|
||||||
caption: `${formatBytes(file.file_size)} · ${chatTitle(file.chat_id)}`,
|
|
||||||
downloaded: true,
|
|
||||||
fileName: file.file_name,
|
|
||||||
fileSize: file.file_size,
|
|
||||||
kind: file.kind,
|
|
||||||
mediaId: file.media_id,
|
|
||||||
messageId: file.message_id,
|
|
||||||
mime: file.mime,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
function chatTitle(chatId: number): string {
|
|
||||||
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSummary() {
|
|
||||||
try {
|
|
||||||
summary = await getStorageSummary();
|
|
||||||
} catch {
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loading || done) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = token;
|
|
||||||
loading = true;
|
|
||||||
failed = false;
|
|
||||||
try {
|
|
||||||
const batch = await getStorageFiles(group, {
|
|
||||||
limit: PAGE,
|
|
||||||
offset: files.length,
|
|
||||||
});
|
|
||||||
if (current !== token) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chats.ensureMany(batch.map((file) => file.chat_id));
|
|
||||||
files = [...files, ...batch];
|
|
||||||
done = batch.length < PAGE;
|
|
||||||
} catch {
|
|
||||||
if (current === token) {
|
|
||||||
failed = true;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (current === token) {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload() {
|
|
||||||
token += 1;
|
|
||||||
files = [];
|
|
||||||
done = false;
|
|
||||||
loading = false;
|
|
||||||
selected.clear();
|
|
||||||
loadSummary();
|
|
||||||
loadMore();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(mediaId: number) {
|
|
||||||
problem = null;
|
|
||||||
if (!selected.delete(mediaId)) {
|
|
||||||
selected.add(mediaId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideUndo() {
|
|
||||||
clearTimeout(undoTimer);
|
|
||||||
undo = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUndo(outcome: TrashOutcome, trashed: StorageFile[]) {
|
|
||||||
hideUndo();
|
|
||||||
if (outcome.files > 0) {
|
|
||||||
undo = { files: trashed, outcome };
|
|
||||||
undoShownAt = Date.now();
|
|
||||||
undoTimer = setTimeout(hideUndo, UNDO_MS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function trash(mediaIds: number[]) {
|
|
||||||
if (busy || mediaIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
const outcome = await moveToTrash(mediaIds);
|
|
||||||
const moved = new Set(
|
|
||||||
mediaIds.filter((id) => !outcome.blocked.includes(id))
|
|
||||||
);
|
|
||||||
const trashed = files.filter((file) => moved.has(file.media_id));
|
|
||||||
for (const file of trashed) {
|
|
||||||
forgetMedia(file.chat_id, file.message_id, file.media_id);
|
|
||||||
}
|
|
||||||
files = files.filter((file) => !moved.has(file.media_id));
|
|
||||||
selected.clear();
|
|
||||||
showUndo(outcome, trashed);
|
|
||||||
if (outcome.blocked.length > 0) {
|
|
||||||
problem = "Файл занят в сторис или аватаре и остался на диске";
|
|
||||||
}
|
|
||||||
await loadSummary();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось переместить в корзину";
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreLast() {
|
|
||||||
if (undo === null || Date.now() - undoShownAt < UNDO_GUARD_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const restored = undo;
|
|
||||||
hideUndo();
|
|
||||||
try {
|
|
||||||
await restoreFromTrash(restored.outcome.keys);
|
|
||||||
files = [...files, ...restored.files].sort(
|
|
||||||
(a, b) => b.file_size - a.file_size
|
|
||||||
);
|
|
||||||
await loadSummary();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось вернуть файлы";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(file: StorageFile) {
|
|
||||||
try {
|
|
||||||
await downloadMedia(file.media_id);
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось скачать файл";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open(index: number) {
|
|
||||||
viewerIndex = index;
|
|
||||||
viewerOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function jump(file: StorageFile) {
|
|
||||||
ui.requestJump(file.chat_id, file.message_id);
|
|
||||||
goto(`/app/${file.chat_id}`);
|
|
||||||
if (isMobile()) {
|
|
||||||
ui.closePanel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pick(next: StorageGroupName) {
|
|
||||||
group = group === next ? null : next;
|
|
||||||
problem = null;
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(reload);
|
|
||||||
onDestroy(() => clearTimeout(undoTimer));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="storage">
|
|
||||||
{#if summary}
|
|
||||||
{#if summary.files > 0}
|
|
||||||
<section class="summary">
|
|
||||||
<div class="hero">
|
|
||||||
<span class="total">{formatBytes(summary.bytes)}</span>
|
|
||||||
<span class="count">
|
|
||||||
{filesLabel(summary.files)}
|
|
||||||
{summary.trash_bytes > 0
|
|
||||||
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
|
|
||||||
: "на диске"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="stack">
|
|
||||||
{#each groups as item (item.group)}
|
|
||||||
<span
|
|
||||||
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
|
|
||||||
style:background-color="var(--color-storage-{item.group})"
|
|
||||||
style:flex-grow={item.bytes}
|
|
||||||
></span>
|
|
||||||
{/each}
|
|
||||||
{#if summary.trash_bytes > 0}
|
|
||||||
<span
|
|
||||||
class="trashed"
|
|
||||||
title="Корзина: {formatBytes(summary.trash_bytes)}"
|
|
||||||
style:flex-grow={summary.trash_bytes}
|
|
||||||
></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="legend">
|
|
||||||
{#each groups as item (item.group)}
|
|
||||||
<button
|
|
||||||
aria-pressed={group === item.group}
|
|
||||||
class="chip"
|
|
||||||
onclick={() => pick(item.group)}
|
|
||||||
type="button"
|
|
||||||
class:on={group === item.group}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="dot"
|
|
||||||
style:background-color="var(--color-storage-{item.group})"
|
|
||||||
></span>
|
|
||||||
{GROUP_LABELS[item.group]}
|
|
||||||
<span class="value">{formatBytes(item.bytes)}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="trash-link"
|
|
||||||
onclick={() => ui.openPanel("storage-trash")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.375rem" />
|
|
||||||
<span class="label">Корзина</span>
|
|
||||||
<span class="value">
|
|
||||||
{#if summary.trash_files > 0}
|
|
||||||
<span class="swatch"></span>
|
|
||||||
{filesLabel(summary.trash_files)}
|
|
||||||
· {formatBytes(summary.trash_bytes)}
|
|
||||||
{:else}
|
|
||||||
пусто
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<Icon name="next" size="1.125rem" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if files.length > 0}
|
|
||||||
<div class="section-title">
|
|
||||||
<h3>Самые тяжёлые</h3>
|
|
||||||
{#if summary && group === null && summary.heavy_files > 0}
|
|
||||||
<span>
|
|
||||||
{filesLabel(summary.heavy_files)}
|
|
||||||
крупнее {formatBytes(summary.heavy_threshold)}
|
|
||||||
= {formatBytes(summary.heavy_bytes)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="list">
|
|
||||||
{#each files as file, index (file.media_id)}
|
|
||||||
<StorageRow
|
|
||||||
fileName={file.file_name}
|
|
||||||
kind={file.kind}
|
|
||||||
mime={file.mime}
|
|
||||||
note={file.refs > 1 ? messagesLabel(file.refs) : undefined}
|
|
||||||
onopen={() => open(index)}
|
|
||||||
ontoggle={() => toggle(file.media_id)}
|
|
||||||
refetchable={file.refetchable}
|
|
||||||
selected={selected.has(file.media_id)}
|
|
||||||
size={file.file_size}
|
|
||||||
src={mediaUrl(file.media_id)}
|
|
||||||
sub={chatTitle(file.chat_id)}
|
|
||||||
tail={formatListDate(file.date)}
|
|
||||||
weight={heaviest > 0 ? file.file_size / heaviest : 0}
|
|
||||||
>
|
|
||||||
{#snippet menu()}
|
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
|
|
||||||
Открыть
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem icon="reply" onselect={() => jump(file)}>
|
|
||||||
Перейти к сообщению
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem icon="download" onselect={() => save(file)}>
|
|
||||||
Скачать файл
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
icon="delete"
|
|
||||||
onselect={() => trash([file.media_id])}
|
|
||||||
>
|
|
||||||
В корзину
|
|
||||||
</ContextMenuItem>
|
|
||||||
{/snippet}
|
|
||||||
</StorageRow>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="state"><Spinner /></div>
|
|
||||||
{:else if failed}
|
|
||||||
<div class="state">
|
|
||||||
<p>Не удалось загрузить хранилище.</p>
|
|
||||||
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
|
||||||
</div>
|
|
||||||
{:else if files.length === 0}
|
|
||||||
<div class="state">
|
|
||||||
<p>
|
|
||||||
{group === null
|
|
||||||
? "На диске нет скачанных файлов."
|
|
||||||
: "В этой группе нет файлов."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else if !done}
|
|
||||||
<button class="more" onclick={loadMore} type="button">
|
|
||||||
Показать ещё
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if chosen.length > 0}
|
|
||||||
<SelectionBar
|
|
||||||
failure={problem}
|
|
||||||
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
|
|
||||||
title="{filesLabel(chosen.length)} · {formatBytes(chosenBytes)}"
|
|
||||||
warn
|
|
||||||
>
|
|
||||||
<Button onclick={() => selected.clear()} smaller variant="text">
|
|
||||||
Снять
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={busy}
|
|
||||||
loading={busy}
|
|
||||||
onclick={() => trash([...selected])}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.25rem" />
|
|
||||||
<span>В корзину</span>
|
|
||||||
</Button>
|
|
||||||
</SelectionBar>
|
|
||||||
{:else if undo}
|
|
||||||
<SelectionBar
|
|
||||||
failure={problem}
|
|
||||||
note="{formatBytes(undo.outcome.bytes)} освободится через 7 дней"
|
|
||||||
title="{filesLabel(undo.outcome.files)} в корзине"
|
|
||||||
>
|
|
||||||
<Button onclick={restoreLast} smaller variant="text">
|
|
||||||
<Icon name="undo" size="1.25rem" />
|
|
||||||
<span>Вернуть</span>
|
|
||||||
</Button>
|
|
||||||
</SelectionBar>
|
|
||||||
{:else if problem}
|
|
||||||
<SelectionBar failure={problem} title="Не получилось">
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
problem = null;
|
|
||||||
}}
|
|
||||||
smaller
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
Понятно
|
|
||||||
</Button>
|
|
||||||
</SelectionBar>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MediaViewer
|
|
||||||
chatId={files[viewerIndex]?.chat_id ?? 0}
|
|
||||||
items={viewerItems}
|
|
||||||
ontrash={(item) => {
|
|
||||||
if (item.mediaId !== null) {
|
|
||||||
trash([item.mediaId]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
bind:index={viewerIndex}
|
|
||||||
bind:open={viewerOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.storage {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
padding: 1rem 1rem 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0 0.625rem;
|
|
||||||
align-items: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.total {
|
|
||||||
font-size: 1.875rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
line-height: 1.2;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stack {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
height: 0.625rem;
|
|
||||||
margin-top: 0.875rem;
|
|
||||||
|
|
||||||
span {
|
|
||||||
min-width: 0.1875rem;
|
|
||||||
border-radius: 2px;
|
|
||||||
|
|
||||||
&.trashed {
|
|
||||||
background-color: var(--color-storage-trash);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
border-start-start-radius: 0.3125rem;
|
|
||||||
border-end-start-radius: 0.3125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-start-end-radius: 0.3125rem;
|
|
||||||
border-end-end-radius: 0.3125rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.375rem 0.25rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.375rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
height: 1.75rem;
|
|
||||||
padding: 0 0.5625rem 0 0.4375rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.875rem;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--color-text);
|
|
||||||
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
|
|
||||||
transition:
|
|
||||||
background-color 0.15s ease-out,
|
|
||||||
box-shadow 0.15s ease-out;
|
|
||||||
|
|
||||||
&.on {
|
|
||||||
background-color: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--color-primary) 12%,
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
height: 2.25rem;
|
|
||||||
padding: 0 0.75rem 0 0.625rem;
|
|
||||||
border-radius: 1.125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:not(.on):hover {
|
|
||||||
background-color: var(--color-background-secondary-accent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 0.5rem;
|
|
||||||
height: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trash-link {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 3.25rem;
|
|
||||||
padding: 0 1rem 0 1.125rem;
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid var(--color-borders);
|
|
||||||
border-bottom: 1px solid var(--color-borders);
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
:global(.icon) {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.375rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.swatch {
|
|
||||||
width: 0.5rem;
|
|
||||||
height: 0.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--color-storage-trash);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
padding: 0.875rem 1rem 0.375rem;
|
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: inherit;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--color-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
flex: 1;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.more {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
width: calc(100% - 1rem);
|
|
||||||
margin: 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
import StorageThumb from "#lib/components/disk/StorageThumb.svelte";
|
|
||||||
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
|
||||||
import { groupOf } from "#lib/format/storage.js";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
fileName: string;
|
|
||||||
kind: string;
|
|
||||||
lead?: string;
|
|
||||||
menu: Snippet;
|
|
||||||
mime: string | null;
|
|
||||||
note?: string;
|
|
||||||
onopen: () => void;
|
|
||||||
ontoggle: () => void;
|
|
||||||
refetchable: boolean;
|
|
||||||
selected: boolean;
|
|
||||||
size: number | null;
|
|
||||||
src: string;
|
|
||||||
sub: string;
|
|
||||||
tail?: string;
|
|
||||||
weight: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
fileName,
|
|
||||||
kind,
|
|
||||||
mime,
|
|
||||||
src,
|
|
||||||
lead,
|
|
||||||
sub,
|
|
||||||
tail,
|
|
||||||
note,
|
|
||||||
size,
|
|
||||||
weight,
|
|
||||||
refetchable,
|
|
||||||
selected,
|
|
||||||
onopen,
|
|
||||||
ontoggle,
|
|
||||||
menu,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const MIN_WEIGHT = 0.02;
|
|
||||||
|
|
||||||
const scale = $derived(Math.max(MIN_WEIGHT, Math.min(1, weight)));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="row"
|
|
||||||
style:--group="var(--color-storage-{groupOf(kind)})"
|
|
||||||
class:selected
|
|
||||||
>
|
|
||||||
<span class="check">
|
|
||||||
<input
|
|
||||||
aria-label="Выбрать {fileName}"
|
|
||||||
checked={selected}
|
|
||||||
onchange={ontoggle}
|
|
||||||
type="checkbox"
|
|
||||||
>
|
|
||||||
<span class="circle"><Icon name="check" size="0.875rem" /></span>
|
|
||||||
</span>
|
|
||||||
<ContextMenu {menu}>
|
|
||||||
{#snippet children({
|
|
||||||
props,
|
|
||||||
})}
|
|
||||||
<button {...props} class="main" onclick={onopen} type="button">
|
|
||||||
<StorageThumb {fileName} {kind} {mime} {src} />
|
|
||||||
<span class="text">
|
|
||||||
<span class="name">{fileName}</span>
|
|
||||||
<span class="sub">
|
|
||||||
{#if lead}
|
|
||||||
<span class="rigid">{`${lead} · `}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="fluid">{sub}</span>
|
|
||||||
{#if tail}
|
|
||||||
<span class="rigid">{` · ${tail}`}</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
{#if note}
|
|
||||||
<span class="note">{note}</span>
|
|
||||||
{/if}
|
|
||||||
{#if !refetchable}
|
|
||||||
<span class="gone">Не скачать заново</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
<span class="size">{formatBytes(size)}</span>
|
|
||||||
<span class="weight"><span style:--scale={scale}></span></span>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</ContextMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-left: 0.4375rem;
|
|
||||||
|
|
||||||
&.selected {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.check {
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
align-self: stretch;
|
|
||||||
width: 2.5rem;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
|
|
||||||
input {
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 1.375rem;
|
|
||||||
height: 1.375rem;
|
|
||||||
border: 2px solid var(--color-borders-input);
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
color: transparent;
|
|
||||||
|
|
||||||
transition:
|
|
||||||
background-color 0.15s ease-out,
|
|
||||||
border-color 0.15s ease-out;
|
|
||||||
|
|
||||||
input:checked + & {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-white);
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus-visible + & {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
flex: 1;
|
|
||||||
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
|
||||||
column-gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.5rem 1rem 0.625rem 0.3125rem;
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
color: var(--color-text);
|
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name,
|
|
||||||
.fluid {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rigid {
|
|
||||||
flex-shrink: 0;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub,
|
|
||||||
.note,
|
|
||||||
.gone {
|
|
||||||
margin-top: 0.125rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.gone {
|
|
||||||
color: var(--color-storage-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.size {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.weight {
|
|
||||||
grid-column: 2 / 4;
|
|
||||||
|
|
||||||
height: 0.1875rem;
|
|
||||||
margin-top: 0.4375rem;
|
|
||||||
border-radius: 0.125rem;
|
|
||||||
|
|
||||||
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
|
|
||||||
|
|
||||||
span {
|
|
||||||
transform: scaleX(var(--scale));
|
|
||||||
transform-origin: 0 50%;
|
|
||||||
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
height: 100%;
|
|
||||||
border-radius: inherit;
|
|
||||||
|
|
||||||
background-color: var(--group);
|
|
||||||
|
|
||||||
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { visible } from "#lib/actions/visible.js";
|
|
||||||
import { visualKind } from "#lib/api/media.js";
|
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
|
||||||
import { fileExtension, groupOf } from "#lib/format/storage.js";
|
|
||||||
import { poster } from "#lib/media/poster.js";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
fileName: string;
|
|
||||||
kind: string;
|
|
||||||
mime: string | null;
|
|
||||||
src: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { src, kind, mime, fileName }: Props = $props();
|
|
||||||
|
|
||||||
const GLYPHS: Record<string, string> = {
|
|
||||||
audio: "volume-2",
|
|
||||||
other: "photo",
|
|
||||||
video: "video",
|
|
||||||
voice: "microphone",
|
|
||||||
};
|
|
||||||
|
|
||||||
let near = $state(false);
|
|
||||||
let shown = $state(false);
|
|
||||||
let failed = $state(false);
|
|
||||||
|
|
||||||
const group = $derived(groupOf(kind));
|
|
||||||
const extension = $derived(group === "file" ? fileExtension(fileName) : "");
|
|
||||||
const glyph = $derived(
|
|
||||||
kind === "video_note" ? "video" : (GLYPHS[group] ?? "document")
|
|
||||||
);
|
|
||||||
const preview = $derived.by(() => {
|
|
||||||
if (mime?.startsWith("image/") || kind === "photo") {
|
|
||||||
return "image";
|
|
||||||
}
|
|
||||||
if (mime?.startsWith("video/") || visualKind(kind) === "video") {
|
|
||||||
return "video";
|
|
||||||
}
|
|
||||||
return "none";
|
|
||||||
});
|
|
||||||
const loadable = $derived(near && !failed);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span
|
|
||||||
class="thumb"
|
|
||||||
style:--group="var(--color-storage-{group})"
|
|
||||||
use:visible={() => {
|
|
||||||
near = true;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{#if extension}
|
|
||||||
<span class="extension">{extension}</span>
|
|
||||||
{:else}
|
|
||||||
<Icon name={glyph} size="1.375rem" />
|
|
||||||
{/if}
|
|
||||||
{#if loadable && preview === "image"}
|
|
||||||
<!-- biome-ignore lint/a11y/noNoninteractiveElementInteractions: onload is not a user interaction -->
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
decoding="async"
|
|
||||||
onerror={() => {
|
|
||||||
failed = true;
|
|
||||||
}}
|
|
||||||
onload={() => {
|
|
||||||
shown = true;
|
|
||||||
}}
|
|
||||||
{src}
|
|
||||||
class:shown
|
|
||||||
>
|
|
||||||
{:else if loadable && preview === "video"}
|
|
||||||
<video
|
|
||||||
muted
|
|
||||||
onerror={() => {
|
|
||||||
failed = true;
|
|
||||||
}}
|
|
||||||
onloadeddata={() => {
|
|
||||||
shown = true;
|
|
||||||
}}
|
|
||||||
playsinline
|
|
||||||
preload="metadata"
|
|
||||||
{src}
|
|
||||||
class:shown
|
|
||||||
use:poster
|
|
||||||
></video>
|
|
||||||
{#if shown}
|
|
||||||
<span class="play"><Icon name="play" size="0.875rem" /></span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.thumb {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
color: var(--color-white);
|
|
||||||
|
|
||||||
background-color: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--group) var(--storage-tile-mix),
|
|
||||||
black
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.extension {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
video {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
opacity: 0;
|
|
||||||
object-fit: cover;
|
|
||||||
|
|
||||||
transition: opacity 0.2s ease-out;
|
|
||||||
|
|
||||||
&.shown {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.play {
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 1.5rem;
|
|
||||||
height: 1.5rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
background-color: rgb(0 0 0 / 45%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,559 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Dialog } from "bits-ui";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
|
||||||
import { trashedUrl } from "#lib/api/client.js";
|
|
||||||
import {
|
|
||||||
getStorageSummary,
|
|
||||||
getTrash,
|
|
||||||
purgeTrash,
|
|
||||||
restoreFromTrash,
|
|
||||||
} from "#lib/api/endpoints.js";
|
|
||||||
import type { ViewerItem } from "#lib/api/media.js";
|
|
||||||
import type { TrashItem } from "#lib/api/types.js";
|
|
||||||
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
|
|
||||||
import StorageRow from "#lib/components/disk/StorageRow.svelte";
|
|
||||||
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
|
||||||
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
|
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
|
||||||
import { plural } from "#lib/format/shares.js";
|
|
||||||
import {
|
|
||||||
filesLabel,
|
|
||||||
messagesLabel,
|
|
||||||
purgeLabel,
|
|
||||||
} from "#lib/format/storage.js";
|
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
|
||||||
import { ui } from "#lib/stores/ui.svelte.js";
|
|
||||||
import { isMobile } from "#lib/viewport.js";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
|
|
||||||
const PAGE = 30;
|
|
||||||
|
|
||||||
let items = $state<TrashItem[]>([]);
|
|
||||||
let total = $state({ bytes: 0, files: 0 });
|
|
||||||
let done = $state(false);
|
|
||||||
let everything = $state(false);
|
|
||||||
let loading = $state(true);
|
|
||||||
let failed = $state(false);
|
|
||||||
let busy = $state(false);
|
|
||||||
let doomed = $state<TrashItem[] | null>(null);
|
|
||||||
let problem = $state<string | null>(null);
|
|
||||||
let viewerOpen = $state(false);
|
|
||||||
let viewerIndex = $state(0);
|
|
||||||
const selected = new SvelteSet<string>();
|
|
||||||
|
|
||||||
const heaviest = $derived(
|
|
||||||
Math.max(0, ...items.map((item) => item.file_size ?? 0))
|
|
||||||
);
|
|
||||||
const dialogOpen = $derived(doomed !== null || everything);
|
|
||||||
const chosen = $derived(
|
|
||||||
items.filter((item) => selected.has(item.storage_key))
|
|
||||||
);
|
|
||||||
const chosenGone = $derived(
|
|
||||||
chosen.filter((item) => !item.refetchable).length
|
|
||||||
);
|
|
||||||
const doomedGone = $derived(
|
|
||||||
doomed?.filter((item) => !item.refetchable).length ?? 0
|
|
||||||
);
|
|
||||||
const viewerItems = $derived<ViewerItem[]>(
|
|
||||||
items.map((item) => ({
|
|
||||||
caption: `${formatBytes(item.file_size)} · ${chatTitle(item.chat_id)}`,
|
|
||||||
downloaded: true,
|
|
||||||
fileName: item.file_name,
|
|
||||||
fileSize: item.file_size,
|
|
||||||
kind: item.kind,
|
|
||||||
mediaId: null,
|
|
||||||
messageId: item.message_id,
|
|
||||||
mime: item.mime,
|
|
||||||
src: trashedUrl(item.storage_key),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
function bytesOf(list: TrashItem[]): number {
|
|
||||||
return list.reduce((sum, item) => sum + (item.file_size ?? 0), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function chatTitle(chatId: number): string {
|
|
||||||
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTotal() {
|
|
||||||
const summary = await getStorageSummary();
|
|
||||||
total = { bytes: summary.trash_bytes, files: summary.trash_files };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
loading = true;
|
|
||||||
failed = false;
|
|
||||||
try {
|
|
||||||
const batch = await getTrash({ limit: PAGE, offset: items.length });
|
|
||||||
chats.ensureMany(batch.map((item) => item.chat_id));
|
|
||||||
items = [...items, ...batch];
|
|
||||||
done = batch.length < PAGE;
|
|
||||||
} catch {
|
|
||||||
failed = true;
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
items = [];
|
|
||||||
done = false;
|
|
||||||
selected.clear();
|
|
||||||
await Promise.all([loadMore(), loadTotal().catch(() => undefined)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(key: string) {
|
|
||||||
problem = null;
|
|
||||||
if (!selected.delete(key)) {
|
|
||||||
selected.add(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drop(keys: string[]) {
|
|
||||||
const gone = new Set(keys);
|
|
||||||
items = items.filter((item) => !gone.has(item.storage_key));
|
|
||||||
selected.clear();
|
|
||||||
loadTotal().catch(() => undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restore(list: TrashItem[]) {
|
|
||||||
if (busy || list.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
|
||||||
problem = null;
|
|
||||||
const keys = list.map((item) => item.storage_key);
|
|
||||||
try {
|
|
||||||
await restoreFromTrash(keys);
|
|
||||||
drop(keys);
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось вернуть файлы";
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreAll() {
|
|
||||||
if (busy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
await restoreFromTrash(null);
|
|
||||||
await load();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось вернуть файлы";
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function purge() {
|
|
||||||
if (busy || (doomed === null && !everything)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
busy = true;
|
|
||||||
problem = null;
|
|
||||||
const keys = doomed?.map((item) => item.storage_key) ?? null;
|
|
||||||
try {
|
|
||||||
await purgeTrash(keys);
|
|
||||||
doomed = null;
|
|
||||||
everything = false;
|
|
||||||
await load();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось удалить файлы";
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function open(index: number) {
|
|
||||||
viewerIndex = index;
|
|
||||||
viewerOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function jump(item: TrashItem) {
|
|
||||||
ui.requestJump(item.chat_id, item.message_id);
|
|
||||||
goto(`/app/${item.chat_id}`);
|
|
||||||
if (isMobile()) {
|
|
||||||
ui.closePanel();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(load);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="trash">
|
|
||||||
<p class="note">
|
|
||||||
Файлы лежат здесь 7 дней, потом удаляются с диска. До этого их можно
|
|
||||||
вернуть.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if total.files > 0}
|
|
||||||
<button class="act" disabled={busy} onclick={restoreAll} type="button">
|
|
||||||
<Icon name="undo" size="1.375rem" />
|
|
||||||
<span class="label">Вернуть всё</span>
|
|
||||||
<span class="value">{filesLabel(total.files)}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="act danger"
|
|
||||||
onclick={() => {
|
|
||||||
everything = true;
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.375rem" />
|
|
||||||
<span class="label">Очистить корзину</span>
|
|
||||||
<span class="value">{formatBytes(total.bytes)}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="list">
|
|
||||||
{#each items as item, index (item.storage_key)}
|
|
||||||
<StorageRow
|
|
||||||
fileName={item.file_name}
|
|
||||||
kind={item.kind}
|
|
||||||
lead={purgeLabel(item.purge_at)}
|
|
||||||
mime={item.mime}
|
|
||||||
note={item.refs > 1 ? messagesLabel(item.refs) : undefined}
|
|
||||||
onopen={() => open(index)}
|
|
||||||
ontoggle={() => toggle(item.storage_key)}
|
|
||||||
refetchable={item.refetchable}
|
|
||||||
selected={selected.has(item.storage_key)}
|
|
||||||
size={item.file_size}
|
|
||||||
src={trashedUrl(item.storage_key)}
|
|
||||||
sub={chatTitle(item.chat_id)}
|
|
||||||
weight={heaviest > 0 ? (item.file_size ?? 0) / heaviest : 0}
|
|
||||||
>
|
|
||||||
{#snippet menu()}
|
|
||||||
<ContextMenuItem icon="open-in-new-tab" onselect={() => open(index)}>
|
|
||||||
Открыть
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem icon="undo" onselect={() => restore([item])}>
|
|
||||||
Вернуть
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem icon="reply" onselect={() => jump(item)}>
|
|
||||||
Перейти к сообщению
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
icon="delete"
|
|
||||||
onselect={() => {
|
|
||||||
doomed = [item];
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Удалить насовсем
|
|
||||||
</ContextMenuItem>
|
|
||||||
{/snippet}
|
|
||||||
</StorageRow>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="state"><Spinner /></div>
|
|
||||||
{:else if failed}
|
|
||||||
<div class="state">
|
|
||||||
<p>Не удалось загрузить корзину.</p>
|
|
||||||
<Button onclick={load} smaller variant="text">Повторить</Button>
|
|
||||||
</div>
|
|
||||||
{:else if items.length === 0}
|
|
||||||
<div class="state"><p>Корзина пуста.</p></div>
|
|
||||||
{:else if !done}
|
|
||||||
<button class="more" onclick={loadMore} type="button">
|
|
||||||
Показать ещё
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if chosen.length > 0}
|
|
||||||
<SelectionBar
|
|
||||||
failure={dialogOpen ? null : problem}
|
|
||||||
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
|
|
||||||
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
|
|
||||||
warn
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
doomed = chosen;
|
|
||||||
}}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={busy}
|
|
||||||
loading={busy}
|
|
||||||
onclick={() => restore(chosen)}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
>
|
|
||||||
<Icon name="undo" size="1.25rem" />
|
|
||||||
<span>Вернуть</span>
|
|
||||||
</Button>
|
|
||||||
</SelectionBar>
|
|
||||||
{:else if problem && !dialogOpen}
|
|
||||||
<SelectionBar failure={problem} title="Не получилось">
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
problem = null;
|
|
||||||
}}
|
|
||||||
smaller
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
Понятно
|
|
||||||
</Button>
|
|
||||||
</SelectionBar>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Dialog.Root
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
if (!next) {
|
|
||||||
doomed = null;
|
|
||||||
everything = false;
|
|
||||||
problem = null;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
open={doomed !== null || everything}
|
|
||||||
>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay class="dialog-overlay" />
|
|
||||||
<Dialog.Content class="dialog-content">
|
|
||||||
<header class="dialog-head">
|
|
||||||
<Dialog.Title class="dialog-title">
|
|
||||||
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
|
|
||||||
</Dialog.Title>
|
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
|
||||||
<Icon name="close" size="1.25rem" />
|
|
||||||
</Dialog.Close>
|
|
||||||
</header>
|
|
||||||
<div class="dialog-body">
|
|
||||||
{#if everything}
|
|
||||||
<p class="confirm">
|
|
||||||
{filesLabel(total.files)}, {formatBytes(total.bytes)},
|
|
||||||
{plural(
|
|
||||||
total.files,
|
|
||||||
"будет удалён",
|
|
||||||
"будут удалены",
|
|
||||||
"будут удалены"
|
|
||||||
)}
|
|
||||||
с диска.
|
|
||||||
</p>
|
|
||||||
<p class="confirm rest">
|
|
||||||
То, что ещё есть в Telegram, потом можно скачать заново. Файлы из
|
|
||||||
удалённых там сообщений пропадут насовсем.
|
|
||||||
</p>
|
|
||||||
{:else if doomed}
|
|
||||||
<p class="confirm">
|
|
||||||
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
|
|
||||||
{plural(
|
|
||||||
doomed.length,
|
|
||||||
"будет удалён",
|
|
||||||
"будут удалены",
|
|
||||||
"будут удалены"
|
|
||||||
)}
|
|
||||||
с диска.
|
|
||||||
</p>
|
|
||||||
{#if doomedGone > 0}
|
|
||||||
<p class="confirm warn">
|
|
||||||
<Icon name="warning" size="1.25rem" />
|
|
||||||
<span>
|
|
||||||
{filesLabel(doomedGone)}
|
|
||||||
скачать заново нельзя: в Telegram
|
|
||||||
{doomedGone === 1 ? "его" : "их"}
|
|
||||||
уже нет.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if doomedGone < doomed.length}
|
|
||||||
<p class="confirm rest">
|
|
||||||
{doomedGone === 0 ? "Потом их" : "Остальные"}
|
|
||||||
можно скачать из Telegram заново.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if problem}
|
|
||||||
<p class="confirm failed">{problem}.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<Dialog.Close>
|
|
||||||
{#snippet child({
|
|
||||||
props,
|
|
||||||
})}
|
|
||||||
<Button {...props} pill variant="text">Отмена</Button>
|
|
||||||
{/snippet}
|
|
||||||
</Dialog.Close>
|
|
||||||
<Button
|
|
||||||
disabled={busy}
|
|
||||||
loading={busy}
|
|
||||||
onclick={purge}
|
|
||||||
pill
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
||||||
<MediaViewer
|
|
||||||
chatId={items[viewerIndex]?.chat_id ?? 0}
|
|
||||||
items={viewerItems}
|
|
||||||
bind:index={viewerIndex}
|
|
||||||
bind:open={viewerOpen}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.trash {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.note {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.act {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 0.875rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 3rem;
|
|
||||||
padding: 0 1rem 0 1.125rem;
|
|
||||||
border: 0;
|
|
||||||
border-bottom: 1px solid var(--color-borders);
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
&.danger {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid currentcolor;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:not(:disabled):hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.more {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
width: calc(100% - 1rem);
|
|
||||||
margin: 0.5rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.list {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.25rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
|
|
||||||
&.rest,
|
|
||||||
&.failed {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.failed {
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.warn {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.625rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
|
|
||||||
color: var(--color-storage-warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CaptureToggles } from "#lib/api/types.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
disabled?: boolean;
|
||||||
|
onchange: (key: keyof CaptureToggles, value: boolean) => void;
|
||||||
|
toggles: CaptureToggles;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { toggles, disabled = false, onchange }: Props = $props();
|
||||||
|
|
||||||
|
const rows: { key: keyof CaptureToggles; label: string }[] = [
|
||||||
|
{ key: "messages", label: "Сообщения" },
|
||||||
|
{ key: "media", label: "Медиа" },
|
||||||
|
{ key: "self_destruct_media", label: "Самоуничтожающиеся" },
|
||||||
|
{ key: "stt", label: "Расшифровка голоса" },
|
||||||
|
{ key: "reactions", label: "Реакции" },
|
||||||
|
{ key: "track_edits_deletes", label: "Правки и удаления" },
|
||||||
|
{ key: "profile_history", label: "История профиля" },
|
||||||
|
{ key: "stories", label: "Сторис" },
|
||||||
|
{ key: "presence", label: "Presence" },
|
||||||
|
{ key: "backfill", label: "Бэкфилл" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toggles">
|
||||||
|
{#each rows as row (row.key)}
|
||||||
|
<button
|
||||||
|
aria-checked={toggles[row.key]}
|
||||||
|
class="toggle-row"
|
||||||
|
{disabled}
|
||||||
|
onclick={() => onchange(row.key, !toggles[row.key])}
|
||||||
|
role="switch"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span class="label">{row.label}</span>
|
||||||
|
<span class="switch" class:on={toggles[row.key]}>
|
||||||
|
<span class="knob"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.toggle-row {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-align: start;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 1.375rem;
|
||||||
|
padding: 0.125rem;
|
||||||
|
border-radius: 0.6875rem;
|
||||||
|
|
||||||
|
background-color: var(--color-borders);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.knob {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background-color: var(--color-white);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
|
||||||
|
.on & {
|
||||||
|
transform: translateX(0.875rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,640 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import {
|
|
||||||
createPolicy,
|
|
||||||
deletePolicy,
|
|
||||||
getPolicyScope,
|
|
||||||
getStorageScope,
|
|
||||||
restoreFromTrash,
|
|
||||||
trashScope,
|
|
||||||
updatePolicy,
|
|
||||||
} from "#lib/api/endpoints.js";
|
|
||||||
import type {
|
|
||||||
CaptureToggles,
|
|
||||||
PolicyParent,
|
|
||||||
PolicyRecord,
|
|
||||||
PolicyScopeType,
|
|
||||||
ScopeTarget,
|
|
||||||
StorageScope,
|
|
||||||
TrashOutcome,
|
|
||||||
} from "#lib/api/types.js";
|
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
|
||||||
import { formatBytes } from "#lib/format/media.js";
|
|
||||||
import {
|
|
||||||
parentLabel,
|
|
||||||
pickToggles,
|
|
||||||
preset,
|
|
||||||
sameToggles,
|
|
||||||
summary,
|
|
||||||
TOGGLE_KEYS,
|
|
||||||
TOGGLE_LABELS,
|
|
||||||
type ToggleKey,
|
|
||||||
} from "#lib/format/policy.js";
|
|
||||||
import { plural } from "#lib/format/shares.js";
|
|
||||||
import { filesLabel } from "#lib/format/storage.js";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onchange?: () => void;
|
|
||||||
parent?: PolicyParent | null;
|
|
||||||
rule?: PolicyRecord | null;
|
|
||||||
scopeId: number | null;
|
|
||||||
scopeType: PolicyScopeType;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
scopeType,
|
|
||||||
scopeId,
|
|
||||||
rule: initialRule,
|
|
||||||
parent: initialParent,
|
|
||||||
onchange,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const PRESETS: { label: string; toggles: CaptureToggles }[] = [
|
|
||||||
{ label: "Всё", toggles: preset("all") },
|
|
||||||
{ label: "Только сообщения", toggles: preset(["messages"]) },
|
|
||||||
{ label: "Ничего", toggles: preset([]) },
|
|
||||||
];
|
|
||||||
|
|
||||||
let rule = $state<PolicyRecord | null>(null);
|
|
||||||
let parent = $state<PolicyParent | null>(null);
|
|
||||||
let loading = $state(false);
|
|
||||||
let saving = $state(false);
|
|
||||||
let problem = $state<string | null>(null);
|
|
||||||
let files = $state<StorageScope | null>(null);
|
|
||||||
let confirming = $state(false);
|
|
||||||
let moving = $state(false);
|
|
||||||
let moved = $state<TrashOutcome | null>(null);
|
|
||||||
let removing = $state(false);
|
|
||||||
|
|
||||||
const isChat = $derived(scopeType === "chat");
|
|
||||||
const isFolder = $derived(scopeType === "folder");
|
|
||||||
const target = $derived<ScopeTarget | null>(scopeTarget());
|
|
||||||
const shown = $derived<CaptureToggles | null>(
|
|
||||||
rule ?? parent?.toggles ?? null
|
|
||||||
);
|
|
||||||
const hint = $derived(inheritance());
|
|
||||||
|
|
||||||
function scopeTarget(): ScopeTarget | null {
|
|
||||||
if (scopeId === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (scopeType === "chat") {
|
|
||||||
return { chatId: scopeId };
|
|
||||||
}
|
|
||||||
return scopeType === "folder" ? { folderId: scopeId } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function inheritance(): string {
|
|
||||||
if (isChat && parent) {
|
|
||||||
const from = parentLabel(parent.scope_type, parent.title);
|
|
||||||
return rule
|
|
||||||
? `Без правила было бы как ${from}: ${summary(parent.toggles).toLowerCase()}.`
|
|
||||||
: `Сейчас как ${from}. Измени что-нибудь, и у чата появится своё правило.`;
|
|
||||||
}
|
|
||||||
if (isFolder) {
|
|
||||||
return rule
|
|
||||||
? "Действует на чаты папки, у которых нет своего правила."
|
|
||||||
: "У папки нет правила, чаты идут по умолчаниям. Выбери набор, чтобы его создать.";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function differs(key: ToggleKey): boolean {
|
|
||||||
return (
|
|
||||||
rule !== null && parent !== null && rule[key] !== parent.toggles[key]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(next: CaptureToggles) {
|
|
||||||
if (saving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
rule = rule
|
|
||||||
? await updatePolicy(rule.id, next)
|
|
||||||
: await createPolicy({
|
|
||||||
...next,
|
|
||||||
scope_id: scopeId,
|
|
||||||
scope_type: scopeType,
|
|
||||||
});
|
|
||||||
onchange?.();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось сохранить правило";
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flip(key: ToggleKey) {
|
|
||||||
if (shown !== null) {
|
|
||||||
save({ ...pickToggles(shown), [key]: !shown[key] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove() {
|
|
||||||
if (rule === null || saving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
await deletePolicy(rule.id);
|
|
||||||
rule = null;
|
|
||||||
removing = false;
|
|
||||||
onchange?.();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось удалить правило";
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFiles() {
|
|
||||||
if (target === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
files = await getStorageScope(target);
|
|
||||||
} catch {
|
|
||||||
files = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveFiles() {
|
|
||||||
if (target === null || moving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
moving = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
moved = await trashScope(target);
|
|
||||||
confirming = false;
|
|
||||||
await loadFiles();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось переместить файлы в корзину";
|
|
||||||
} finally {
|
|
||||||
moving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function undoMove() {
|
|
||||||
if (moved === null || moving) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
moving = true;
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
await restoreFromTrash(moved.keys);
|
|
||||||
moved = null;
|
|
||||||
await loadFiles();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось вернуть файлы";
|
|
||||||
} finally {
|
|
||||||
moving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function where(scope: StorageScope): string {
|
|
||||||
return isFolder
|
|
||||||
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
|
|
||||||
: "в этом чате";
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
rule = initialRule ?? null;
|
|
||||||
parent = initialParent ?? null;
|
|
||||||
if (target === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (initialRule === undefined) {
|
|
||||||
loading = true;
|
|
||||||
try {
|
|
||||||
({ rule, parent } = await getPolicyScope(target));
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось загрузить правило";
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await loadFiles();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
{#if loading}
|
|
||||||
<div class="center"><Spinner /></div>
|
|
||||||
{:else}
|
|
||||||
{#if hint}
|
|
||||||
<p class="hint">{hint}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="presets">
|
|
||||||
{#each PRESETS as item (item.label)}
|
|
||||||
<button
|
|
||||||
aria-pressed={shown !== null && sameToggles(shown, item.toggles)}
|
|
||||||
class="preset"
|
|
||||||
disabled={saving}
|
|
||||||
onclick={() => save(item.toggles)}
|
|
||||||
type="button"
|
|
||||||
class:on={shown !== null && sameToggles(shown, item.toggles)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if shown}
|
|
||||||
<div class="toggles">
|
|
||||||
{#each TOGGLE_KEYS as key (key)}
|
|
||||||
<button
|
|
||||||
aria-checked={shown[key]}
|
|
||||||
class="toggle"
|
|
||||||
disabled={saving}
|
|
||||||
onclick={() => flip(key)}
|
|
||||||
role="switch"
|
|
||||||
type="button"
|
|
||||||
class:changed={differs(key)}
|
|
||||||
>
|
|
||||||
<span class="label">{TOGGLE_LABELS[key]}</span>
|
|
||||||
<span class="switch" class:on={shown[key]}>
|
|
||||||
<span class="knob"></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if problem}
|
|
||||||
<p class="problem">{problem}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if moved && moved.files > 0}
|
|
||||||
<div class="files">
|
|
||||||
<div class="files-text">
|
|
||||||
<span>{filesLabel(moved.files)} в корзине</span>
|
|
||||||
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
|
|
||||||
</div>
|
|
||||||
<Button disabled={moving} onclick={undoMove} smaller variant="text">
|
|
||||||
<Icon name="undo" size="1.25rem" />
|
|
||||||
<span>Вернуть</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{:else if files && files.files > 0}
|
|
||||||
<div class="files">
|
|
||||||
{#if confirming}
|
|
||||||
<div class="files-text">
|
|
||||||
<span>
|
|
||||||
{filesLabel(files.files)}, {formatBytes(files.bytes)}, уйдут в
|
|
||||||
корзину и перестанут скачиваться сами.
|
|
||||||
</span>
|
|
||||||
{#if files.gone > 0}
|
|
||||||
<small class="warn">
|
|
||||||
{files.gone}
|
|
||||||
из них потом не скачать заново
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
<div class="files-actions">
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
confirming = false;
|
|
||||||
}}
|
|
||||||
smaller
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={moving}
|
|
||||||
loading={moving}
|
|
||||||
onclick={moveFiles}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.25rem" />
|
|
||||||
<span>В корзину</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="files-text">
|
|
||||||
<span>{formatBytes(files.bytes)} уже скачано</span>
|
|
||||||
<small>{filesLabel(files.files)} {where(files)}</small>
|
|
||||||
{#if files.kept > 0}
|
|
||||||
<small>
|
|
||||||
Ещё {filesLabel(files.kept)} есть и в других чатах, они
|
|
||||||
останутся
|
|
||||||
</small>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
confirming = true;
|
|
||||||
}}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
variant="gray"
|
|
||||||
>
|
|
||||||
<Icon name="delete" size="1.25rem" />
|
|
||||||
<span>В корзину</span>
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if rule && (isChat || isFolder)}
|
|
||||||
{#if removing}
|
|
||||||
<div class="removal">
|
|
||||||
<span>Удалить правило?</span>
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
removing = false;
|
|
||||||
}}
|
|
||||||
smaller
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={saving}
|
|
||||||
onclick={remove}
|
|
||||||
pill
|
|
||||||
smaller
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="remove"
|
|
||||||
onclick={() => {
|
|
||||||
removing = true;
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Удалить правило
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.card {
|
|
||||||
padding: 0.25rem 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 1.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.5rem 1rem 0.25rem;
|
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.presets {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
|
|
||||||
margin: 0.5rem 1rem 0.25rem;
|
|
||||||
padding: 0.125rem;
|
|
||||||
border-radius: 0.625rem;
|
|
||||||
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
height: 2rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
transition:
|
|
||||||
background-color 0.15s ease-out,
|
|
||||||
color 0.15s ease-out;
|
|
||||||
|
|
||||||
&.on {
|
|
||||||
color: var(--color-text);
|
|
||||||
background-color: var(--color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
height: 2.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0 0.5rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
min-width: 0;
|
|
||||||
height: 2.75rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.625rem;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
&.changed .label {
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:not(:disabled):hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
overflow: hidden;
|
|
||||||
flex: 1;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 1.375rem;
|
|
||||||
padding: 0.125rem;
|
|
||||||
border-radius: 0.6875rem;
|
|
||||||
|
|
||||||
background-color: var(--color-borders-input);
|
|
||||||
|
|
||||||
transition: background-color 0.15s ease-out;
|
|
||||||
|
|
||||||
&.on {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.knob {
|
|
||||||
width: 1.125rem;
|
|
||||||
height: 1.125rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
background-color: var(--color-white);
|
|
||||||
|
|
||||||
transition: transform 0.15s ease-out;
|
|
||||||
|
|
||||||
.on & {
|
|
||||||
transform: translateX(0.875rem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.problem {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.25rem 1rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.files {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
margin: 0.5rem 1rem 0.25rem;
|
|
||||||
padding: 0.625rem 0.5rem 0.625rem 0.875rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
|
|
||||||
:global(.Button) {
|
|
||||||
gap: 0.375rem;
|
|
||||||
padding-inline: 0.875rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-text {
|
|
||||||
display: flex;
|
|
||||||
flex: 1 1 10rem;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
|
|
||||||
small {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
|
|
||||||
&.warn {
|
|
||||||
color: var(--color-storage-warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.removal {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
margin: 0.25rem 0.5rem 0;
|
|
||||||
padding-left: 0.5rem;
|
|
||||||
|
|
||||||
font-size: 0.875rem;
|
|
||||||
|
|
||||||
span {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
margin: 0.25rem 0.5rem 0;
|
|
||||||
padding: 0.5rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-error);
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-error);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Dialog } from "bits-ui";
|
|
||||||
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
|
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
|
||||||
import { policyUi } from "#lib/stores/policy.svelte.js";
|
|
||||||
|
|
||||||
const target = $derived(policyUi.target);
|
|
||||||
const isChat = $derived(target !== null && "chatId" in target);
|
|
||||||
const scopeId = $derived.by(() => {
|
|
||||||
if (target === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return "chatId" in target ? target.chatId : target.folderId;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dialog.Root bind:open={policyUi.open}>
|
|
||||||
<Dialog.Portal>
|
|
||||||
<Dialog.Overlay class="dialog-overlay" />
|
|
||||||
<Dialog.Content class="dialog-content">
|
|
||||||
<header class="dialog-head">
|
|
||||||
<div class="heading">
|
|
||||||
<Dialog.Title class="dialog-title">{policyUi.title}</Dialog.Title>
|
|
||||||
<span class="kind">
|
|
||||||
{isChat ? "Политика захвата чата" : "Политика захвата папки"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
|
||||||
<Icon name="close" size="1.25rem" />
|
|
||||||
</Dialog.Close>
|
|
||||||
</header>
|
|
||||||
<div class="body custom-scroll">
|
|
||||||
{#key target}
|
|
||||||
<PolicyCard
|
|
||||||
onchange={() => policyUi.touch()}
|
|
||||||
{scopeId}
|
|
||||||
scopeType={isChat ? "chat" : "folder"}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.heading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kind {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,369 +1,241 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
|
||||||
import {
|
import {
|
||||||
|
createPolicy,
|
||||||
deletePolicy,
|
deletePolicy,
|
||||||
getPolicyOverview,
|
|
||||||
listFolders,
|
listFolders,
|
||||||
|
listPolicies,
|
||||||
|
updatePolicy,
|
||||||
} from "#lib/api/endpoints.js";
|
} from "#lib/api/endpoints.js";
|
||||||
import type { Folder, PolicyRule, PolicyScopeType } from "#lib/api/types.js";
|
import type {
|
||||||
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
|
CaptureToggles,
|
||||||
import Avatar from "#lib/components/ui/Avatar.svelte";
|
Folder,
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
PolicyRecord,
|
||||||
|
PolicyScopeType,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import CaptureToggleList from "#lib/components/policy/CaptureToggleList.svelte";
|
||||||
import Icon from "#lib/components/ui/Icon.svelte";
|
import Icon from "#lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "#lib/components/ui/Spinner.svelte";
|
import Spinner from "#lib/components/ui/Spinner.svelte";
|
||||||
import {
|
import { accounts } from "#lib/stores/accounts.svelte.js";
|
||||||
type Chip,
|
|
||||||
contentChips,
|
|
||||||
DEFAULT_LABELS,
|
|
||||||
diffChips,
|
|
||||||
sameToggles,
|
|
||||||
summary,
|
|
||||||
} from "#lib/format/policy.js";
|
|
||||||
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
import { createChatPicker } from "#lib/stores/chat-picker.svelte.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.js";
|
import { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { toasts } from "#lib/stores/toasts.svelte.js";
|
||||||
|
|
||||||
interface Draft {
|
let policies = $state<PolicyRecord[]>([]);
|
||||||
scopeId: number;
|
|
||||||
scopeType: "folder" | "chat";
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_ORDER: PolicyScopeType[] = [
|
|
||||||
"default_dm",
|
|
||||||
"default_group",
|
|
||||||
"default_channel",
|
|
||||||
];
|
|
||||||
const DEFAULT_ICONS: Partial<Record<PolicyScopeType, string>> = {
|
|
||||||
default_channel: "channel",
|
|
||||||
default_dm: "user",
|
|
||||||
default_group: "group",
|
|
||||||
};
|
|
||||||
|
|
||||||
let rules = $state<PolicyRule[]>([]);
|
|
||||||
let folders = $state<Folder[]>([]);
|
let folders = $state<Folder[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let failed = $state(false);
|
let pickFolder = $state(false);
|
||||||
let problem = $state<string | null>(null);
|
let pickChat = $state(false);
|
||||||
let expanded = $state<string | null>(null);
|
|
||||||
let draft = $state<Draft | null>(null);
|
|
||||||
let picking = $state<"folder" | "chat" | null>(null);
|
|
||||||
let chatFilter = $state("");
|
let chatFilter = $state("");
|
||||||
|
|
||||||
const picker = createChatPicker();
|
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
|
||||||
|
{ label: "Личные чаты", scope: "default_dm" },
|
||||||
|
{ label: "Группы", scope: "default_group" },
|
||||||
|
{ label: "Каналы", scope: "default_channel" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL_FALSE: CaptureToggles = {
|
||||||
|
backfill: false,
|
||||||
|
media: false,
|
||||||
|
messages: false,
|
||||||
|
presence: false,
|
||||||
|
profile_history: false,
|
||||||
|
reactions: false,
|
||||||
|
self_destruct_media: false,
|
||||||
|
stories: false,
|
||||||
|
stt: false,
|
||||||
|
track_edits_deletes: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function pickToggles(t: CaptureToggles): CaptureToggles {
|
||||||
|
return {
|
||||||
|
backfill: t.backfill,
|
||||||
|
media: t.media,
|
||||||
|
messages: t.messages,
|
||||||
|
presence: t.presence,
|
||||||
|
profile_history: t.profile_history,
|
||||||
|
reactions: t.reactions,
|
||||||
|
self_destruct_media: t.self_destruct_media,
|
||||||
|
stories: t.stories,
|
||||||
|
stt: t.stt,
|
||||||
|
track_edits_deletes: t.track_edits_deletes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const defaults = $derived(
|
const defaults = $derived(
|
||||||
DEFAULT_ORDER.map((scope) =>
|
DEFAULT_SCOPES.map((d) => ({
|
||||||
rules.find((rule) => rule.scope_type === scope)
|
label: d.label,
|
||||||
).filter((rule): rule is PolicyRule => rule !== undefined)
|
record: policies.find((p) => p.scope_type === d.scope),
|
||||||
|
})).filter((d): d is { label: string; record: PolicyRecord } =>
|
||||||
|
Boolean(d.record)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const folderRules = $derived(
|
const folderPolicies = $derived(
|
||||||
rules
|
policies.filter((p) => p.scope_type === "folder")
|
||||||
.filter((rule) => rule.scope_type === "folder")
|
|
||||||
.toSorted((a, b) => folderOrder(a.scope_id) - folderOrder(b.scope_id))
|
|
||||||
);
|
);
|
||||||
const chatRules = $derived(
|
const chatPolicies = $derived(
|
||||||
rules
|
policies.filter((p) => p.scope_type === "chat")
|
||||||
.filter((rule) => rule.scope_type === "chat")
|
|
||||||
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
|
|
||||||
);
|
);
|
||||||
const freeFolders = $derived(
|
const availableFolders = $derived(
|
||||||
folders.filter(
|
folders.filter(
|
||||||
(folder) =>
|
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
|
||||||
!folderRules.some((rule) => rule.scope_id === folder.folder_id)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const freeChats = $derived(
|
const picker = createChatPicker();
|
||||||
|
const availableChats = $derived(
|
||||||
picker.results.filter(
|
picker.results.filter(
|
||||||
(chat) => !chatRules.some((rule) => rule.scope_id === chat.chat_id)
|
(c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
function folderOrder(id: number | null): number {
|
$effect(() => {
|
||||||
return folders.find((folder) => folder.folder_id === id)?.order_index ?? 0;
|
picker.search(chatFilter);
|
||||||
}
|
|
||||||
|
|
||||||
function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
|
|
||||||
return `${scopeType}:${scopeId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function title(rule: PolicyRule): string {
|
|
||||||
if (rule.scope_type === "chat") {
|
|
||||||
return rule.title ?? `Чат ${rule.scope_id}`;
|
|
||||||
}
|
|
||||||
if (rule.scope_type === "folder") {
|
|
||||||
return rule.title ?? `Папка ${rule.scope_id}`;
|
|
||||||
}
|
|
||||||
return DEFAULT_LABELS[rule.scope_type] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function redundant(rule: PolicyRule): boolean {
|
|
||||||
return rule.parent !== null && sameToggles(rule, rule.parent.toggles);
|
|
||||||
}
|
|
||||||
|
|
||||||
function chips(rule: PolicyRule): Chip[] {
|
|
||||||
return rule.parent
|
|
||||||
? diffChips(rule, rule.parent.toggles)
|
|
||||||
: contentChips(rule);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reveal(node: HTMLElement) {
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
node.parentElement?.scrollIntoView({ block: "nearest" });
|
|
||||||
});
|
});
|
||||||
observer.observe(node);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
function draftBefore(rule: PolicyRule): boolean {
|
$effect(() => {
|
||||||
if (draft?.scopeType !== "folder") {
|
chats.ensureMany(
|
||||||
return false;
|
chatPolicies
|
||||||
}
|
.map((policy) => policy.scope_id)
|
||||||
const slot = folderOrder(draft.scopeId);
|
.filter((id): id is number => id !== null)
|
||||||
const next = folderRules.find((item) => folderOrder(item.scope_id) > slot);
|
|
||||||
return next?.id === rule.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const draftLast = $derived(
|
|
||||||
draft?.scopeType === "folder" &&
|
|
||||||
!folderRules.some((rule) => draftBefore(rule))
|
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function folderTitle(id: number | null): string {
|
||||||
|
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatTitle(id: number | null): string {
|
||||||
|
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
try {
|
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
|
||||||
[rules, folders] = await Promise.all([
|
|
||||||
getPolicyOverview(),
|
|
||||||
listFolders(),
|
|
||||||
]);
|
|
||||||
failed = false;
|
|
||||||
if (
|
|
||||||
draft &&
|
|
||||||
rules.some(
|
|
||||||
(rule) =>
|
|
||||||
rule.scope_type === draft?.scopeType &&
|
|
||||||
rule.scope_id === draft?.scopeId
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
expanded = keyOf(draft.scopeType, draft.scopeId);
|
|
||||||
draft = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggle(
|
||||||
|
record: PolicyRecord,
|
||||||
|
key: keyof CaptureToggles,
|
||||||
|
value: boolean
|
||||||
|
) {
|
||||||
|
const next = { ...pickToggles(record), [key]: value };
|
||||||
|
policies = policies.map((p) =>
|
||||||
|
p.id === record.id ? { ...p, [key]: value } : p
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const saved = await updatePolicy(record.id, next);
|
||||||
|
policies = policies.map((p) => (p.id === record.id ? saved : p));
|
||||||
} catch {
|
} catch {
|
||||||
failed = true;
|
toasts.error("Не удалось сохранить политику");
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addOverride(scope: "folder" | "chat", scopeId: number) {
|
||||||
|
try {
|
||||||
|
const record = await createPolicy({
|
||||||
|
...ALL_FALSE,
|
||||||
|
scope_id: scopeId,
|
||||||
|
scope_type: scope,
|
||||||
|
});
|
||||||
|
policies = [...policies, record];
|
||||||
|
pickFolder = false;
|
||||||
|
pickChat = false;
|
||||||
|
chatFilter = "";
|
||||||
|
} catch {
|
||||||
|
toasts.error("Не удалось создать оверрайд");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOverride(record: PolicyRecord) {
|
||||||
|
policies = policies.filter((p) => p.id !== record.id);
|
||||||
|
try {
|
||||||
|
await deletePolicy(record.id);
|
||||||
|
} catch {
|
||||||
|
toasts.error("Не удалось удалить оверрайд");
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(_account: number | null) {
|
||||||
|
loading = true;
|
||||||
|
chats.load();
|
||||||
|
try {
|
||||||
|
await reload();
|
||||||
|
} catch {
|
||||||
|
toasts.error("Не удалось загрузить политики");
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggle(rule: PolicyRule) {
|
$effect(() => {
|
||||||
const key = keyOf(rule.scope_type, rule.scope_id);
|
load(accounts.selectedId);
|
||||||
expanded = expanded === key ? null : key;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function drop(rule: PolicyRule) {
|
|
||||||
problem = null;
|
|
||||||
try {
|
|
||||||
await deletePolicy(rule.id);
|
|
||||||
await reload();
|
|
||||||
} catch {
|
|
||||||
problem = "Не удалось убрать правило";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function start(scopeType: "folder" | "chat", scopeId: number, name: string) {
|
|
||||||
draft = { scopeId, scopeType, title: name };
|
|
||||||
picking = null;
|
|
||||||
chatFilter = "";
|
|
||||||
expanded = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePicker(kind: "folder" | "chat") {
|
|
||||||
picking = picking === kind ? null : kind;
|
|
||||||
if (picking === "chat") {
|
|
||||||
picker.search(chatFilter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
chats.load();
|
|
||||||
reload();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet lead(
|
{#snippet card(
|
||||||
rule: PolicyRule
|
record: PolicyRecord,
|
||||||
|
title: string,
|
||||||
|
onremove?: () => void
|
||||||
)}
|
)}
|
||||||
{#if rule.scope_type === "chat" && rule.scope_id !== null}
|
<div class="card">
|
||||||
<Avatar
|
<div class="card-head">
|
||||||
avatar={{ id: rule.scope_id, kind: rule.scope_id > 0 ? "peer" : "chat" }}
|
<span class="card-title">{title}</span>
|
||||||
colorKey={rule.scope_id}
|
{#if record.account_id === null}
|
||||||
hasAvatar={chats.byId(rule.scope_id)?.has_avatar ?? false}
|
<span class="shared">для всех аккаунтов</span>
|
||||||
name={title(rule)}
|
|
||||||
size={2.25}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<span class="glyph">
|
|
||||||
<Icon name={DEFAULT_ICONS[rule.scope_type] ?? "folder"} size="1.5rem" />
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{#if onremove && record.account_id !== null}
|
||||||
|
|
||||||
{#snippet row(
|
|
||||||
rule: PolicyRule,
|
|
||||||
plain: boolean
|
|
||||||
)}
|
|
||||||
{@const key = keyOf(rule.scope_type, rule.scope_id)}
|
|
||||||
{@const open = expanded === key}
|
|
||||||
{@const extra = !plain && redundant(rule)}
|
|
||||||
<div class="rule" class:open>
|
|
||||||
<div class="line">
|
|
||||||
<button
|
<button
|
||||||
aria-expanded={open}
|
aria-label="Удалить оверрайд"
|
||||||
class="main"
|
class="remove"
|
||||||
onclick={() => toggle(rule)}
|
onclick={onremove}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{@render lead(rule)}
|
<Icon name="close" size="1.125rem" />
|
||||||
<span class="text">
|
|
||||||
<span class="name">{title(rule)}</span>
|
|
||||||
{#if plain}
|
|
||||||
<span class="sub">{summary(rule)}</span>
|
|
||||||
{:else if extra}
|
|
||||||
<span class="sub">Ничего не меняет</span>
|
|
||||||
{:else}
|
|
||||||
<span class="chips">
|
|
||||||
{#each chips(rule) as chip (chip.sign + chip.label)}
|
|
||||||
<span class="chip {chip.sign}" title={chip.hint}>
|
|
||||||
{chip.sign === "plus" ? "+" : "−"}
|
|
||||||
{chip.label}
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{#if extra}
|
|
||||||
<Button onclick={() => drop(rule)} smaller variant="text">
|
|
||||||
Убрать
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
aria-hidden="true"
|
|
||||||
class="chevron"
|
|
||||||
onclick={() => toggle(rule)}
|
|
||||||
tabindex="-1"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name={open ? "up" : "down"} size="1.125rem" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if open}
|
|
||||||
<div class="editor" {@attach reveal}>
|
|
||||||
<PolicyCard
|
|
||||||
onchange={reload}
|
|
||||||
parent={rule.parent}
|
|
||||||
{rule}
|
|
||||||
scopeId={rule.scope_id}
|
|
||||||
scopeType={rule.scope_type}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
<CaptureToggleList
|
||||||
|
onchange={(key, value) => toggle(record, key, value)}
|
||||||
{#snippet pending(
|
toggles={record}
|
||||||
item: Draft
|
|
||||||
)}
|
|
||||||
<div class="rule open">
|
|
||||||
<div class="line">
|
|
||||||
<div class="main static">
|
|
||||||
{#if item.scopeType === "chat"}
|
|
||||||
<Avatar
|
|
||||||
avatar={{
|
|
||||||
id: item.scopeId,
|
|
||||||
kind: item.scopeId > 0 ? "peer" : "chat",
|
|
||||||
}}
|
|
||||||
colorKey={item.scopeId}
|
|
||||||
hasAvatar={chats.byId(item.scopeId)?.has_avatar ?? false}
|
|
||||||
name={item.title}
|
|
||||||
size={2.25}
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
|
||||||
<span class="glyph"><Icon name="folder" size="1.5rem" /></span>
|
|
||||||
{/if}
|
|
||||||
<span class="text">
|
|
||||||
<span class="name">{item.title}</span>
|
|
||||||
<span class="sub">Новое правило</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onclick={() => {
|
|
||||||
draft = null;
|
|
||||||
}}
|
|
||||||
smaller
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div class="editor" {@attach reveal}>
|
|
||||||
<PolicyCard
|
|
||||||
onchange={reload}
|
|
||||||
scopeId={item.scopeId}
|
|
||||||
scopeType={item.scopeType}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="state"><Spinner /></div>
|
<div class="state"><Spinner /></div>
|
||||||
{:else if failed}
|
|
||||||
<div class="state">
|
|
||||||
<p>Не удалось загрузить политику.</p>
|
|
||||||
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="policy">
|
<div class="editor">
|
||||||
{#if problem}
|
|
||||||
<p class="problem">{problem}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title"><h3>По умолчанию</h3></div>
|
<div class="section-title">По умолчанию</div>
|
||||||
{#each defaults as rule (rule.id)}
|
{#each defaults as item (item.record.id)}
|
||||||
{@render row(rule, true)}
|
{@render card(item.record, item.label)}
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">
|
<div class="section-title">Оверрайды папок</div>
|
||||||
<h3>Папки</h3>
|
{#each folderPolicies as record (record.id)}
|
||||||
<span>Что сохраняют. Из подходящих папок побеждает первая.</span>
|
{@render card(record, folderTitle(record.scope_id), () =>
|
||||||
</div>
|
removeOverride(record)
|
||||||
{#each folderRules as rule (rule.id)}
|
)}
|
||||||
{#if draft && draftBefore(rule)}
|
|
||||||
{@render pending(draft)}
|
|
||||||
{/if}
|
|
||||||
{@render row(rule, false)}
|
|
||||||
{/each}
|
{/each}
|
||||||
{#if draft && draftLast}
|
{#if availableFolders.length > 0}
|
||||||
{@render pending(draft)}
|
|
||||||
{/if}
|
|
||||||
{#if freeFolders.length > 0}
|
|
||||||
<button
|
<button
|
||||||
aria-expanded={picking === "folder"}
|
|
||||||
class="add"
|
class="add"
|
||||||
onclick={() => togglePicker("folder")}
|
onclick={() => {
|
||||||
|
pickFolder = !pickFolder;
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="add" size="1.25rem" />
|
<Icon name="add" size="1.25rem" />
|
||||||
<span>Правило для папки</span>
|
<span>Добавить папку</span>
|
||||||
</button>
|
</button>
|
||||||
{#if picking === "folder"}
|
{#if pickFolder}
|
||||||
<div class="picker">
|
<div class="picker">
|
||||||
{#each freeFolders as folder (folder.folder_id)}
|
{#each availableFolders as folder (folder.folder_id)}
|
||||||
<button
|
<button
|
||||||
class="pick"
|
class="pick-row"
|
||||||
onclick={() => start("folder", folder.folder_id, folder.title)}
|
onclick={() => addOverride("folder", folder.folder_id)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{folder.title}
|
{folder.title}
|
||||||
@@ -375,50 +247,38 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">
|
<div class="section-title">Оверрайды чатов</div>
|
||||||
<h3>Чаты</h3>
|
{#each chatPolicies as record (record.id)}
|
||||||
<span>Что меняют против папки или умолчания. Важнее папок.</span>
|
{@render card(record, chatTitle(record.scope_id), () =>
|
||||||
</div>
|
removeOverride(record)
|
||||||
{#if draft?.scopeType === "chat"}
|
)}
|
||||||
{@render pending(draft)}
|
|
||||||
{/if}
|
|
||||||
{#each chatRules as rule (rule.id)}
|
|
||||||
{@render row(rule, false)}
|
|
||||||
{/each}
|
{/each}
|
||||||
<button
|
<button
|
||||||
aria-expanded={picking === "chat"}
|
|
||||||
class="add"
|
class="add"
|
||||||
onclick={() => togglePicker("chat")}
|
onclick={() => {
|
||||||
|
pickChat = !pickChat;
|
||||||
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="add" size="1.25rem" />
|
<Icon name="add" size="1.25rem" />
|
||||||
<span>Правило для чата</span>
|
<span>Добавить чат</span>
|
||||||
</button>
|
</button>
|
||||||
{#if picking === "chat"}
|
{#if pickChat}
|
||||||
<div class="picker">
|
<div class="picker">
|
||||||
<input
|
<input
|
||||||
aria-label="Поиск чата"
|
|
||||||
class="filter"
|
class="filter"
|
||||||
oninput={() => picker.search(chatFilter)}
|
|
||||||
placeholder="Поиск чата"
|
placeholder="Поиск чата"
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={chatFilter}
|
bind:value={chatFilter}
|
||||||
>
|
>
|
||||||
{#each freeChats as chat (chat.chat_id)}
|
{#each availableChats as chat (chat.chat_id)}
|
||||||
<button
|
<button
|
||||||
class="pick"
|
class="pick-row"
|
||||||
onclick={() =>
|
onclick={() => addOverride("chat", chat.chat_id)}
|
||||||
start(
|
|
||||||
"chat",
|
|
||||||
chat.chat_id,
|
|
||||||
chat.title ?? `Чат ${chat.chat_id}`
|
|
||||||
)}
|
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{chat.title ?? `Чат ${chat.chat_id}`}
|
{chat.title ?? `Чат ${chat.chat_id}`}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
|
||||||
<p class="nothing">Ничего не найдено.</p>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -429,213 +289,81 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.state {
|
.state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.policy {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.problem {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.75rem 1rem 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
|
|
||||||
padding: 0.875rem 1rem 0.375rem;
|
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
line-height: 1.35;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: inherit;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.rule.open {
|
|
||||||
border-bottom: 1px solid var(--color-borders);
|
|
||||||
|
|
||||||
.line {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-right: 0.375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 3.25rem;
|
|
||||||
padding: 0.4375rem 0.5rem 0.4375rem 1rem;
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
color: var(--color-text);
|
|
||||||
text-align: start;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
:global(.icon) {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.static {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:not(.static):hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.chevron {
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
width: 2rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glyph {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 2.25rem;
|
|
||||||
height: 2.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.1875rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
overflow: hidden;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sub {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
height: 1.375rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 0.6875rem;
|
|
||||||
|
|
||||||
font-size: 0.75rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&.plus {
|
|
||||||
color: var(--color-policy-plus);
|
|
||||||
background-color: var(--color-policy-plus-fill);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.minus {
|
|
||||||
color: var(--color-policy-minus);
|
|
||||||
background-color: var(--color-policy-minus-fill);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
padding-bottom: 0.25rem;
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 0.25rem 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add {
|
.add {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2.75rem;
|
padding: 0.625rem 1rem;
|
||||||
padding: 0 1rem 0 1.5rem;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
text-align: start;
|
background-color: transparent;
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -649,10 +377,8 @@
|
|||||||
border: 1px solid var(--color-borders);
|
border: 1px solid var(--color-borders);
|
||||||
border-radius: var(--border-radius-default);
|
border-radius: var(--border-radius-default);
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@@ -661,9 +387,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pick {
|
.pick-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -671,35 +396,15 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-text);
|
|
||||||
text-align: start;
|
text-align: start;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
background: none;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.nothing {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -42,15 +42,10 @@
|
|||||||
label="Политика захвата"
|
label="Политика захвата"
|
||||||
onclick={() => ui.openPanel("policy")}
|
onclick={() => ui.openPanel("policy")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
|
||||||
icon="cloud-download"
|
|
||||||
label="Бэкфилл и задачи"
|
|
||||||
onclick={() => ui.openPanel("jobs")}
|
|
||||||
/>
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="data"
|
icon="data"
|
||||||
label="Хранилище"
|
label="Данные и хранилище"
|
||||||
onclick={() => ui.openPanel("storage")}
|
onclick={() => ui.openPanel("jobs")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="play-story"
|
icon="play-story"
|
||||||
|
|||||||
@@ -32,14 +32,10 @@
|
|||||||
|
|
||||||
let groups = $state<Group[]>([]);
|
let groups = $state<Group[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
const previews = $state<Record<string, string | null>>({});
|
const previews = $state<Record<number, string | null>>({});
|
||||||
const expanded = $state<Record<number, boolean>>({});
|
const expanded = $state<Record<number, boolean>>({});
|
||||||
let token = 0;
|
let token = 0;
|
||||||
|
|
||||||
function previewKey(story: StoryView): string {
|
|
||||||
return `${story.peer_id}:${story.story_id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let viewerOpen = $state(false);
|
let viewerOpen = $state(false);
|
||||||
let viewerIndex = $state(0);
|
let viewerIndex = $state(0);
|
||||||
let viewerItems = $state<StoryView[]>([]);
|
let viewerItems = $state<StoryView[]>([]);
|
||||||
@@ -82,7 +78,7 @@
|
|||||||
kind: "peer" as const,
|
kind: "peer" as const,
|
||||||
name: peerName(peer) || `ID ${peerId}`,
|
name: peerName(peer) || `ID ${peerId}`,
|
||||||
peerId,
|
peerId,
|
||||||
stories: peerStories,
|
stories,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const chat = chatById.get(peerId);
|
const chat = chatById.get(peerId);
|
||||||
@@ -91,7 +87,7 @@
|
|||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
name: chat?.title ?? `Chat ${peerId}`,
|
name: chat?.title ?? `Chat ${peerId}`,
|
||||||
peerId,
|
peerId,
|
||||||
stories: peerStories,
|
stories,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,7 +106,7 @@
|
|||||||
groups = [];
|
groups = [];
|
||||||
loading = false;
|
loading = false;
|
||||||
for (const key of Object.keys(previews)) {
|
for (const key of Object.keys(previews)) {
|
||||||
delete previews[key];
|
delete previews[Number(key)];
|
||||||
}
|
}
|
||||||
for (const key of Object.keys(expanded)) {
|
for (const key of Object.keys(expanded)) {
|
||||||
delete expanded[Number(key)];
|
delete expanded[Number(key)];
|
||||||
@@ -126,14 +122,13 @@
|
|||||||
let active = true;
|
let active = true;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
for (const item of list) {
|
for (const item of list) {
|
||||||
const key = previewKey(item);
|
if (!item.downloaded || previews[item.story_id] !== undefined) {
|
||||||
if (!item.downloaded || previews[key] !== undefined) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
previews[key] = null;
|
previews[item.story_id] = null;
|
||||||
loadStoryMedia(item.peer_id, item.story_id).then((url) => {
|
loadStoryMedia(item.peer_id, item.story_id).then((url) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
previews[key] = url;
|
previews[item.story_id] = url;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -215,7 +210,7 @@
|
|||||||
{#each group.stories as item, index (item.story_id)}
|
{#each group.stories as item, index (item.story_id)}
|
||||||
<StoryTile
|
<StoryTile
|
||||||
onopen={() => openViewer(group, index)}
|
onopen={() => openViewer(group, index)}
|
||||||
preview={previews[previewKey(item)]}
|
preview={previews[item.story_id]}
|
||||||
story={item}
|
story={item}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { CaptureToggles, PolicyScopeType } from "#lib/api/types.js";
|
|
||||||
import { plural } from "#lib/format/shares.js";
|
|
||||||
|
|
||||||
export type ToggleKey = keyof CaptureToggles;
|
|
||||||
|
|
||||||
export const TOGGLE_LABELS: Record<ToggleKey, string> = {
|
|
||||||
backfill: "Бэкфилл",
|
|
||||||
media: "Медиа",
|
|
||||||
messages: "Сообщения",
|
|
||||||
presence: "Присутствие",
|
|
||||||
profile_history: "История профиля",
|
|
||||||
reactions: "Реакции",
|
|
||||||
self_destruct_media: "Исчезающие",
|
|
||||||
stories: "Сторис",
|
|
||||||
stt: "Расшифровка",
|
|
||||||
track_edits_deletes: "Правки, удаления",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TOGGLE_KEYS: ToggleKey[] = [
|
|
||||||
"messages",
|
|
||||||
"media",
|
|
||||||
"self_destruct_media",
|
|
||||||
"stt",
|
|
||||||
"reactions",
|
|
||||||
"track_edits_deletes",
|
|
||||||
"profile_history",
|
|
||||||
"stories",
|
|
||||||
"presence",
|
|
||||||
"backfill",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_LABELS: Partial<Record<PolicyScopeType, string>> = {
|
|
||||||
default_channel: "Каналы",
|
|
||||||
default_dm: "Личные чаты",
|
|
||||||
default_group: "Группы",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PARENT_LABELS: Partial<Record<PolicyScopeType, string>> = {
|
|
||||||
default_channel: "у каналов",
|
|
||||||
default_dm: "у личных чатов",
|
|
||||||
default_group: "у групп",
|
|
||||||
};
|
|
||||||
|
|
||||||
const SHOWN_CHIPS = 3;
|
|
||||||
const SHORT_LIST = 2;
|
|
||||||
|
|
||||||
export interface Chip {
|
|
||||||
hint?: string;
|
|
||||||
label: string;
|
|
||||||
sign: "plus" | "minus";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickToggles(source: CaptureToggles): CaptureToggles {
|
|
||||||
return {
|
|
||||||
backfill: source.backfill,
|
|
||||||
media: source.media,
|
|
||||||
messages: source.messages,
|
|
||||||
presence: source.presence,
|
|
||||||
profile_history: source.profile_history,
|
|
||||||
reactions: source.reactions,
|
|
||||||
self_destruct_media: source.self_destruct_media,
|
|
||||||
stories: source.stories,
|
|
||||||
stt: source.stt,
|
|
||||||
track_edits_deletes: source.track_edits_deletes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function preset(only: ToggleKey[] | "all"): CaptureToggles {
|
|
||||||
const toggles = {} as CaptureToggles;
|
|
||||||
for (const key of TOGGLE_KEYS) {
|
|
||||||
toggles[key] = only === "all" || only.includes(key);
|
|
||||||
}
|
|
||||||
return toggles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sameToggles(a: CaptureToggles, b: CaptureToggles): boolean {
|
|
||||||
return TOGGLE_KEYS.every((key) => a[key] === b[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXCEPT_LABELS: Record<ToggleKey, string> = {
|
|
||||||
backfill: "бэкфилла",
|
|
||||||
media: "медиа",
|
|
||||||
messages: "сообщений",
|
|
||||||
presence: "присутствия",
|
|
||||||
profile_history: "истории профиля",
|
|
||||||
reactions: "реакций",
|
|
||||||
self_destruct_media: "исчезающих",
|
|
||||||
stories: "сторис",
|
|
||||||
stt: "расшифровки",
|
|
||||||
track_edits_deletes: "правок и удалений",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function summary(toggles: CaptureToggles): string {
|
|
||||||
const on = TOGGLE_KEYS.filter((key) => toggles[key]);
|
|
||||||
const off = TOGGLE_KEYS.filter((key) => !toggles[key]);
|
|
||||||
if (off.length === 0) {
|
|
||||||
return "Всё";
|
|
||||||
}
|
|
||||||
if (on.length === 0) {
|
|
||||||
return "Ничего";
|
|
||||||
}
|
|
||||||
if (on.length === 1 && on[0] === "messages") {
|
|
||||||
return "Только сообщения";
|
|
||||||
}
|
|
||||||
if (off.length <= SHORT_LIST) {
|
|
||||||
return `Всё, кроме ${off.map((key) => EXCEPT_LABELS[key]).join(" и ")}`;
|
|
||||||
}
|
|
||||||
const names = on.slice(0, SHORT_LIST).map((key) => TOGGLE_LABELS[key]);
|
|
||||||
const rest = on.length - names.length;
|
|
||||||
return rest > 0 ? `${names.join(", ")} и ещё ${rest}` : names.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parentLabel(
|
|
||||||
scopeType: PolicyScopeType,
|
|
||||||
title: string | null
|
|
||||||
): string {
|
|
||||||
if (scopeType === "folder") {
|
|
||||||
return `у папки «${title ?? "без названия"}»`;
|
|
||||||
}
|
|
||||||
return PARENT_LABELS[scopeType] ?? "по умолчанию";
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelList(keys: ToggleKey[]): string {
|
|
||||||
return keys.map((key) => TOGGLE_LABELS[key]).join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function named(keys: ToggleKey[], sign: Chip["sign"]): Chip[] {
|
|
||||||
return keys.map((key) => ({ label: TOGGLE_LABELS[key], sign }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function counted(keys: ToggleKey[], sign: Chip["sign"]): Chip[] {
|
|
||||||
if (keys.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (keys.length <= SHOWN_CHIPS) {
|
|
||||||
return named(keys, sign);
|
|
||||||
}
|
|
||||||
const word = plural(keys.length, "настройка", "настройки", "настроек");
|
|
||||||
return [{ hint: labelList(keys), label: `${keys.length} ${word}`, sign }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contentChips(rule: CaptureToggles): Chip[] {
|
|
||||||
const on = TOGGLE_KEYS.filter((key) => rule[key]);
|
|
||||||
const off = TOGGLE_KEYS.filter((key) => !rule[key]);
|
|
||||||
if (off.length === 0) {
|
|
||||||
return [{ label: "Всё включено", sign: "plus" }];
|
|
||||||
}
|
|
||||||
if (on.length === 0) {
|
|
||||||
return [{ label: "Всё выключено", sign: "minus" }];
|
|
||||||
}
|
|
||||||
if (off.length <= SHOWN_CHIPS) {
|
|
||||||
return [{ label: "Всё", sign: "plus" }, ...named(off, "minus")];
|
|
||||||
}
|
|
||||||
return [...counted(on, "plus"), ...counted(off, "minus")];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function diffChips(rule: CaptureToggles, base: CaptureToggles): Chip[] {
|
|
||||||
const plus = TOGGLE_KEYS.filter((key) => rule[key] && !base[key]);
|
|
||||||
const minus = TOGGLE_KEYS.filter((key) => !rule[key] && base[key]);
|
|
||||||
if (plus.length > SHOWN_CHIPS && TOGGLE_KEYS.every((key) => rule[key])) {
|
|
||||||
return [{ label: "Всё включено", sign: "plus" }];
|
|
||||||
}
|
|
||||||
if (minus.length > SHOWN_CHIPS && TOGGLE_KEYS.every((key) => !rule[key])) {
|
|
||||||
return [{ label: "Всё выключено", sign: "minus" }];
|
|
||||||
}
|
|
||||||
return [...counted(plus, "plus"), ...counted(minus, "minus")];
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { StorageGroupName } from "#lib/api/types.js";
|
|
||||||
import { plural } from "#lib/format/shares.js";
|
|
||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
|
||||||
const EXTENSION_LIMIT = 4;
|
|
||||||
|
|
||||||
export const GROUP_LABELS: Record<StorageGroupName, string> = {
|
|
||||||
audio: "Аудио",
|
|
||||||
file: "Файлы",
|
|
||||||
other: "Фото и прочее",
|
|
||||||
video: "Видео",
|
|
||||||
voice: "Кружки и голосовые",
|
|
||||||
};
|
|
||||||
|
|
||||||
const KIND_GROUPS: Record<string, StorageGroupName> = {
|
|
||||||
audio: "audio",
|
|
||||||
document: "file",
|
|
||||||
video: "video",
|
|
||||||
video_note: "voice",
|
|
||||||
voice: "voice",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function groupOf(kind: string): StorageGroupName {
|
|
||||||
return KIND_GROUPS[kind] ?? "other";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filesLabel(count: number): string {
|
|
||||||
return `${count.toLocaleString("ru-RU")} ${plural(count, "файл", "файла", "файлов")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function messagesLabel(count: number): string {
|
|
||||||
return `в ${count} ${plural(count, "сообщении", "сообщениях", "сообщениях")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileExtension(fileName: string): string {
|
|
||||||
const tail = fileName.includes(".") ? (fileName.split(".").pop() ?? "") : "";
|
|
||||||
return tail.length > 0 && tail.length <= EXTENSION_LIMIT
|
|
||||||
? tail.toUpperCase()
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function purgeLabel(iso: string): string {
|
|
||||||
const days = Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS);
|
|
||||||
if (days <= 0) {
|
|
||||||
return "сегодня";
|
|
||||||
}
|
|
||||||
if (days === 1) {
|
|
||||||
return "завтра";
|
|
||||||
}
|
|
||||||
return `через ${days} дн.`;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { ScopeTarget } from "#lib/api/types.js";
|
|
||||||
|
|
||||||
function createPolicyUi() {
|
|
||||||
let open = $state(false);
|
|
||||||
let target = $state<ScopeTarget | null>(null);
|
|
||||||
let title = $state("");
|
|
||||||
let revision = $state(0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
edit(next: ScopeTarget, name: string) {
|
|
||||||
target = next;
|
|
||||||
title = name;
|
|
||||||
open = true;
|
|
||||||
},
|
|
||||||
get open() {
|
|
||||||
return open;
|
|
||||||
},
|
|
||||||
set open(value: boolean) {
|
|
||||||
open = value;
|
|
||||||
},
|
|
||||||
get revision() {
|
|
||||||
return revision;
|
|
||||||
},
|
|
||||||
get target() {
|
|
||||||
return target;
|
|
||||||
},
|
|
||||||
get title() {
|
|
||||||
return title;
|
|
||||||
},
|
|
||||||
touch() {
|
|
||||||
revision += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const policyUi = createPolicyUi();
|
|
||||||
@@ -14,9 +14,7 @@ export type RightPanel =
|
|||||||
| "watches"
|
| "watches"
|
||||||
| "alerts"
|
| "alerts"
|
||||||
| "shares"
|
| "shares"
|
||||||
| "bots"
|
| "bots";
|
||||||
| "storage"
|
|
||||||
| "storage-trash";
|
|
||||||
|
|
||||||
export type LeftView = "main" | "settings";
|
export type LeftView = "main" | "settings";
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,4 @@
|
|||||||
html.theme-dark {
|
html.theme-dark {
|
||||||
--color-storage-video: #8774e1;
|
|
||||||
--color-storage-file: #d95926;
|
|
||||||
--color-storage-voice: #199e70;
|
|
||||||
--color-storage-audio: #c98500;
|
|
||||||
--color-storage-other: #3987e5;
|
|
||||||
--color-storage-warning: #fb8c00;
|
|
||||||
--color-storage-trash: #6c6c6c;
|
|
||||||
--storage-tile-mix: 90%;
|
|
||||||
--color-policy-plus: #7fd6a4;
|
|
||||||
--color-policy-plus-fill: #199e7029;
|
|
||||||
--color-policy-minus: #ff8a80;
|
|
||||||
--color-policy-minus-fill: #e5393524;
|
|
||||||
--color-primary: #8774e1;
|
--color-primary: #8774e1;
|
||||||
--color-primary-opacity: #8378db1e;
|
--color-primary-opacity: #8378db1e;
|
||||||
--color-primary-opacity-hover: #8378db40;
|
--color-primary-opacity-hover: #8378db40;
|
||||||
|
|||||||
@@ -133,20 +133,6 @@
|
|||||||
|
|
||||||
--color-warning: #{$color-warning};
|
--color-warning: #{$color-warning};
|
||||||
|
|
||||||
--color-storage-video: #6250d6;
|
|
||||||
--color-storage-file: #c4501c;
|
|
||||||
--color-storage-voice: #12805a;
|
|
||||||
--color-storage-audio: #a36a00;
|
|
||||||
--color-storage-other: #2a78c8;
|
|
||||||
--color-storage-warning: #a85a00;
|
|
||||||
--color-storage-trash: #939799;
|
|
||||||
--storage-tile-mix: 100%;
|
|
||||||
|
|
||||||
--color-policy-plus: #0d6b49;
|
|
||||||
--color-policy-plus-fill: #12805a24;
|
|
||||||
--color-policy-minus: #b3261e;
|
|
||||||
--color-policy-minus-fill: #e5393524;
|
|
||||||
|
|
||||||
--color-yellow: #{$color-yellow};
|
--color-yellow: #{$color-yellow};
|
||||||
|
|
||||||
--color-orange: #{$color-orange};
|
--color-orange: #{$color-orange};
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import ChatList from "#lib/components/ChatList.svelte";
|
import ChatList from "#lib/components/ChatList.svelte";
|
||||||
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
import FolderTabs from "#lib/components/FolderTabs.svelte";
|
||||||
import PolicyDialog from "#lib/components/policy/PolicyDialog.svelte";
|
|
||||||
import RightColumn from "#lib/components/RightColumn.svelte";
|
import RightColumn from "#lib/components/RightColumn.svelte";
|
||||||
import SearchInput from "#lib/components/search/SearchInput.svelte";
|
import SearchInput from "#lib/components/search/SearchInput.svelte";
|
||||||
import SearchResults from "#lib/components/search/SearchResults.svelte";
|
import SearchResults from "#lib/components/search/SearchResults.svelte";
|
||||||
@@ -102,8 +101,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PolicyDialog />
|
|
||||||
|
|
||||||
<ShareDialog
|
<ShareDialog
|
||||||
label={shareUi.label}
|
label={shareUi.label}
|
||||||
onchange={() => shareUi.touch()}
|
onchange={() => shareUi.touch()}
|
||||||
|
|||||||
Reference in new issue
Block a user