Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbf707b430 | ||
|
|
dcd6786ca5 | ||
|
|
32d58193f3 | ||
|
|
59d0e2c8ad | ||
|
|
850d39ad6f | ||
|
|
b8a38092eb | ||
|
|
2836dcb3a9 | ||
|
|
01cf01b629 | ||
|
|
7f124059be | ||
|
|
5eacabd101 | ||
|
|
0362777a51 | ||
|
|
dfc726976b | ||
|
|
cb01d7dff8 | ||
|
|
f646f29ff9 | ||
|
|
945adcf879 | ||
|
|
d213dfdf64 | ||
|
|
566ee09431 | ||
|
|
09c5f78061 | ||
|
|
897421e7c4 | ||
|
|
3223f62dea | ||
|
|
c9c088ed73 | ||
|
|
580927da0a | ||
|
|
06ae3e928f | ||
|
|
55634984fd | ||
|
|
f2c245707a |
No files matched your search
@@ -0,0 +1,57 @@
|
|||||||
|
"""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")
|
||||||
@@ -23,7 +23,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.12.13,<0.13.0"]
|
requires = ["uv_build>=0.13.0,<0.14.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[tool.uv.build-backend]
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
QUERYLESS_PREFIXES = ("/mcp",)
|
QUERYLESS_PREFIXES = ("/mcp",)
|
||||||
_PATH_ARG = 2
|
_PATH_ARG = 2
|
||||||
|
_TOKEN = re.compile(r"(?<=[?&]token=)[^&]+")
|
||||||
|
|
||||||
|
|
||||||
class DropQueryFilter(logging.Filter):
|
class ScrubQueryFilter(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):
|
||||||
bare = path.partition("?")[0]
|
path = path.partition("?")[0]
|
||||||
record.args = (*args[:_PATH_ARG], bare, *args[_PATH_ARG + 1 :])
|
else:
|
||||||
|
path = _TOKEN.sub("***", path)
|
||||||
|
record.args = (*args[:_PATH_ARG], path, *args[_PATH_ARG + 1 :])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def hide_mcp_query() -> None:
|
def scrub_access_log() -> None:
|
||||||
logging.getLogger("uvicorn.access").addFilter(DropQueryFilter())
|
logging.getLogger("uvicorn.access").addFilter(ScrubQueryFilter())
|
||||||
+11
-2
@@ -1,3 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -9,7 +10,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 hide_mcp_query
|
from api.access_log import scrub_access_log
|
||||||
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
|
||||||
@@ -34,19 +35,22 @@ 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()
|
||||||
|
|
||||||
hide_mcp_query()
|
scrub_access_log()
|
||||||
|
|
||||||
mcp_app = mcp.http_app(path="/")
|
mcp_app = mcp.http_app(path="/")
|
||||||
|
|
||||||
@@ -55,7 +59,11 @@ 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()
|
||||||
|
|
||||||
@@ -96,6 +104,7 @@ 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:
|
if version is None or not storage.exists(version.storage_key):
|
||||||
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,6 +96,8 @@ 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,13 +5,15 @@ 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 repository
|
from utils.policy import overview, 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
|
||||||
|
|
||||||
@@ -50,6 +52,32 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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,
|
||||||
|
StorageChatView,
|
||||||
|
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()],
|
||||||
|
chat_id: Annotated[int | None, Query()] = None,
|
||||||
|
) -> StorageSummaryView:
|
||||||
|
return await storage_read.get_summary(pool, account_id, chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files")
|
||||||
|
async def files(
|
||||||
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: Annotated[int, Query()],
|
||||||
|
group: Annotated[str | None, Query()] = None,
|
||||||
|
chat_id: Annotated[int | 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, chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/chats")
|
||||||
|
async def chats(
|
||||||
|
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[StorageChatView]:
|
||||||
|
page = Page(limit=limit, offset=offset)
|
||||||
|
return await storage_read.list_chats(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
|
handlers = on_deleted_messages.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -49,4 +49,4 @@ async def on_edited_message(client: PyroClient, message: Message) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
handlers = on_edited_message.handlers
|
handlers = on_edited_message.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -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
|
handlers = on_message.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -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
|
handlers = on_user_status.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -24,8 +24,9 @@ _MODULES = (
|
|||||||
read_receipts,
|
read_receipts,
|
||||||
transcribed,
|
transcribed,
|
||||||
)
|
)
|
||||||
_REGISTRY: dict[type, RawHandler] = {
|
_REGISTRY: dict[type, list[RawHandler]] = {
|
||||||
update_type: module.handle for module in _MODULES for update_type in module.HANDLES
|
update_type: [module.handle for module in _MODULES if update_type in module.HANDLES]
|
||||||
|
for update_type in {kind for module in _MODULES for kind in module.HANDLES}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -33,9 +34,8 @@ _REGISTRY: dict[type, RawHandler] = {
|
|||||||
async def dispatch(
|
async def dispatch(
|
||||||
client: PyroClient, update: raw.base.Update, users: dict, chats: dict
|
client: PyroClient, update: raw.base.Update, users: dict, chats: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
handler = _REGISTRY.get(type(update))
|
for handler in _REGISTRY.get(type(update), ()):
|
||||||
if handler is not None:
|
|
||||||
await handler(client, update, users, chats)
|
await handler(client, update, users, chats)
|
||||||
|
|
||||||
|
|
||||||
handlers = dispatch.handlers
|
handlers = dispatch.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -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 = snapshot_from_user(client, raw_user)
|
fields, photo_file_id, photo_unique_id = await 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
|
handlers = on_story.handlers # ty: ignore[unresolved-attribute]
|
||||||
@@ -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)
|
ttl_seconds, downloaded, unique_id, evicted_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, CASE WHEN $11 THEN now() END)
|
||||||
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,7 +97,27 @@ 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)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -229,6 +249,7 @@ 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(
|
||||||
@@ -243,6 +264,7 @@ 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(
|
||||||
@@ -259,6 +281,22 @@ 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,6 +36,8 @@ 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
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
from pyrogram import Client
|
from pyrogram import Client
|
||||||
from pyrogram.errors import FileIdInvalid, FileReferenceExpired, FileReferenceInvalid
|
from pyrogram.errors import (
|
||||||
from pyrogram.types import Photo
|
ChannelInvalid,
|
||||||
|
ChannelPrivate,
|
||||||
|
FileIdInvalid,
|
||||||
|
FileReferenceExpired,
|
||||||
|
FileReferenceInvalid,
|
||||||
|
PeerIdInvalid,
|
||||||
|
)
|
||||||
|
from pyrogram.types import ChatPhoto
|
||||||
|
|
||||||
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
|
from userbot.modules.avatars.repository import get_avatar_file, mark_avatar_downloaded
|
||||||
from userbot.modules.download import download_bytes
|
from userbot.modules.download import download_bytes
|
||||||
@@ -8,19 +15,20 @@ from userbot.modules.jobs.context import JobContext
|
|||||||
from userbot.modules.jobs.registry import register
|
from userbot.modules.jobs.registry import register
|
||||||
|
|
||||||
STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
|
STALE_FILE_ID = (FileIdInvalid, FileReferenceExpired, FileReferenceInvalid)
|
||||||
|
UNKNOWN_OWNER = (PeerIdInvalid, ChannelInvalid, ChannelPrivate)
|
||||||
|
|
||||||
|
|
||||||
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)
|
try:
|
||||||
photo = chat.photo
|
chat = await client.get_chat(owner_id)
|
||||||
|
except UNKNOWN_OWNER:
|
||||||
|
return None
|
||||||
|
photo = chat.photo if chat else None
|
||||||
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)
|
async for item in client.get_chat_photos(owner_id):
|
||||||
if photos is None:
|
if isinstance(item, ChatPhoto) and item.big_photo_unique_id == unique_id:
|
||||||
return None
|
return item.big_file_id
|
||||||
async for item in photos:
|
|
||||||
if isinstance(item, Photo) and item.file_unique_id == unique_id:
|
|
||||||
return item.file_id
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ 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(client, message, capture, chat_id, message_id, toggles)
|
ok = await capture_media(
|
||||||
|
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 = Chat._parse_chat(client, source) # noqa: SLF001
|
chat = await 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,6 +106,8 @@ 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:
|
||||||
@@ -117,6 +119,7 @@ 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(
|
||||||
@@ -131,7 +134,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
|
||||||
else:
|
elif force or not await repository.is_evicted(ctx.pool, unique_id):
|
||||||
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)
|
||||||
@@ -142,6 +145,8 @@ 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,
|
||||||
@@ -154,7 +159,14 @@ 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
|
||||||
|
|
||||||
|
|
||||||
def snapshot_from_user(
|
async 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 = User._parse(client, raw_user) # noqa: SLF001
|
user = await 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
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from pyrogram.raw.core import TLObject
|
from pyrogram.raw.core import TLObject
|
||||||
from pyrogram.types import Object
|
from pyrogram.types import Contact, Object
|
||||||
|
|
||||||
EMPTY_JSON = "{}"
|
EMPTY_JSON = "{}"
|
||||||
|
PHONE_KEY = "phone_number"
|
||||||
|
|
||||||
|
|
||||||
|
def _default(obj: Any) -> Any: # noqa: ANN401
|
||||||
|
if isinstance(obj, TLObject):
|
||||||
|
return TLObject.default(obj)
|
||||||
|
data = Object.default(obj)
|
||||||
|
if isinstance(obj, Contact) and isinstance(data, dict) and PHONE_KEY in data:
|
||||||
|
data[PHONE_KEY] = obj.phone_number
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def raw_json(obj: Object | TLObject | None) -> str:
|
def raw_json(obj: Object | TLObject | None) -> str:
|
||||||
return EMPTY_JSON if obj is None else str(obj)
|
if obj is None:
|
||||||
|
return EMPTY_JSON
|
||||||
|
return json.dumps(obj, indent=4, default=_default, ensure_ascii=False)
|
||||||
@@ -79,6 +79,24 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
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,11 +1,14 @@
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,14 +36,29 @@ 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
|
||||||
for folder in sorted(folders, key=lambda f: f.order_index):
|
return resolve_parent(chat, folders, policies).toggles
|
||||||
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,6 +176,18 @@ 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:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from utils.read.models import (
|
|||||||
StickerView,
|
StickerView,
|
||||||
WebPageView,
|
WebPageView,
|
||||||
)
|
)
|
||||||
|
from utils.read.rich_text import rich_text
|
||||||
|
|
||||||
_MEDIA_KEYS = (
|
_MEDIA_KEYS = (
|
||||||
"photo",
|
"photo",
|
||||||
@@ -382,6 +383,8 @@ def build_message_view(
|
|||||||
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
|
row: asyncpg.Record, raw: dict[str, Any], media: list[MediaRef]
|
||||||
) -> MessageView:
|
) -> MessageView:
|
||||||
base = _base_fields(row)
|
base = _base_fields(row)
|
||||||
|
if base["text"] is None:
|
||||||
|
base["text"] = rich_text(raw.get("rich_message"))
|
||||||
via_bot = raw.get("via_bot")
|
via_bot = raw.get("via_bot")
|
||||||
sticker = _sticker(raw)
|
sticker = _sticker(raw)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -236,6 +236,72 @@ 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 StorageChatView(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
files: int
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
CELL_GAP = " | "
|
||||||
|
_TEXT_KEYS = ("summary", "text", "expression", "credit")
|
||||||
|
_INLINE_KEYS = ("text", "alternative_text", "expression")
|
||||||
|
|
||||||
|
|
||||||
|
def _button(button: dict[str, Any]) -> str:
|
||||||
|
label = _inline(button.get("text"))
|
||||||
|
url = button.get("url")
|
||||||
|
return f"{label} ({url})" if isinstance(url, str) else label
|
||||||
|
|
||||||
|
|
||||||
|
def _inline(value: object) -> str:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if isinstance(value, list):
|
||||||
|
return "".join(_inline(item) for item in value)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return ""
|
||||||
|
button = value.get("button")
|
||||||
|
if isinstance(button, dict):
|
||||||
|
return _button(button)
|
||||||
|
return next((_inline(value[key]) for key in _INLINE_KEYS if key in value), "")
|
||||||
|
|
||||||
|
|
||||||
|
def _children(block: dict[str, Any], key: str) -> list[Any]:
|
||||||
|
value = block.get(key)
|
||||||
|
return value if isinstance(value, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _row(row: object) -> str:
|
||||||
|
cells = row if isinstance(row, list) else []
|
||||||
|
return CELL_GAP.join(
|
||||||
|
_inline(cell.get("text")) for cell in cells if isinstance(cell, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _block(block: object) -> list[str]:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
return []
|
||||||
|
lines = [_inline(block[key]) for key in _TEXT_KEYS if key in block]
|
||||||
|
lines += [_row(row) for row in _children(block, "cells")]
|
||||||
|
lines += [
|
||||||
|
_button(button)
|
||||||
|
for button in _children(block, "buttons")
|
||||||
|
if isinstance(button, dict)
|
||||||
|
]
|
||||||
|
for key in ("items", "blocks"):
|
||||||
|
for child in _children(block, key):
|
||||||
|
lines += _block(child)
|
||||||
|
lines += _block(block.get("caption"))
|
||||||
|
lines = [line for line in lines if line]
|
||||||
|
label = block.get("label")
|
||||||
|
if isinstance(label, str) and lines:
|
||||||
|
lines[0] = f"{label} {lines[0]}"
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def rich_text(rich: object) -> str | None:
|
||||||
|
return "\n".join(_block(rich)) or None
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from itertools import groupby
|
||||||
|
from operator import itemgetter
|
||||||
|
|
||||||
|
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,
|
||||||
|
StorageChatView,
|
||||||
|
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
|
||||||
|
AND ($3::bigint IS NULL OR chat_id = $3)
|
||||||
|
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
|
||||||
|
FROM media
|
||||||
|
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
|
||||||
|
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
|
||||||
|
AND ($7::bigint IS NULL OR chat_id = $7)
|
||||||
|
GROUP BY storage_key
|
||||||
|
ORDER BY 2 DESC NULLS LAST, 1
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
)
|
||||||
|
SELECT * FROM (
|
||||||
|
SELECT t.storage_key, t.file_size, m.id, m.chat_id, m.message_id,
|
||||||
|
m.kind, m.mime, m.ttl_seconds,
|
||||||
|
count(*) OVER (PARTITION BY t.storage_key) AS refs,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY t.storage_key
|
||||||
|
ORDER BY (m.chat_id = $7) DESC NULLS LAST, 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, n
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CHATS = """
|
||||||
|
WITH blobs AS (
|
||||||
|
SELECT chat_id, 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
|
||||||
|
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
|
||||||
|
GROUP BY chat_id, storage_key
|
||||||
|
), top AS (
|
||||||
|
SELECT chat_id, coalesce(sum(size), 0)::bigint AS total
|
||||||
|
FROM blobs GROUP BY chat_id
|
||||||
|
ORDER BY 2 DESC, 1
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
)
|
||||||
|
SELECT t.chat_id, t.total, b.kind, count(*) AS files,
|
||||||
|
coalesce(sum(b.size), 0)::bigint AS bytes
|
||||||
|
FROM top t JOIN blobs b USING (chat_id)
|
||||||
|
GROUP BY t.chat_id, t.total, b.kind
|
||||||
|
ORDER BY t.total DESC, t.chat_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
|
||||||
|
|
||||||
|
|
||||||
|
def _groups(rows: list[asyncpg.Record]) -> list[StorageGroupView]:
|
||||||
|
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 list(groups.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_summary(
|
||||||
|
pool: asyncpg.Pool, account_id: int, chat_id: int | None = None
|
||||||
|
) -> StorageSummaryView:
|
||||||
|
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES, chat_id)
|
||||||
|
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
|
||||||
|
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=_groups(rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_chats(
|
||||||
|
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
|
||||||
|
) -> list[StorageChatView]:
|
||||||
|
kinds, include = _group_filter(group)
|
||||||
|
rows = await pool.fetch(
|
||||||
|
_CHATS, account_id, kinds, include, page.capped_limit, page.offset
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
StorageChatView(
|
||||||
|
chat_id=chat_id,
|
||||||
|
files=sum(row["files"] for row in items),
|
||||||
|
bytes=items[0]["total"],
|
||||||
|
groups=_groups(items),
|
||||||
|
)
|
||||||
|
for chat_id, items in (
|
||||||
|
(chat_id, list(chunk))
|
||||||
|
for chat_id, chunk in groupby(rows, key=itemgetter("chat_id"))
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
chat_id: int | None = None,
|
||||||
|
) -> list[StorageFileView]:
|
||||||
|
kinds, include = _group_filter(group)
|
||||||
|
refs = await pool.fetch(
|
||||||
|
_FILES,
|
||||||
|
account_id,
|
||||||
|
kinds,
|
||||||
|
include,
|
||||||
|
page.capped_limit,
|
||||||
|
page.offset,
|
||||||
|
REFS_CHECKED,
|
||||||
|
chat_id,
|
||||||
|
)
|
||||||
|
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"])
|
||||||
|
inside = chat_id is None or ref["chat_id"] == chat_id
|
||||||
|
if known is not None and not (alive and inside and not known.refetchable):
|
||||||
|
known.refetchable = known.refetchable or 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,6 +1,8 @@
|
|||||||
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:
|
||||||
@@ -27,3 +29,32 @@ 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))
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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)
|
||||||
Generated
+241
-166
@@ -1,6 +1,11 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 5
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.15'",
|
||||||
|
"python_full_version == '3.14.*'",
|
||||||
|
"python_full_version < '3.14'",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiofile"
|
name = "aiofile"
|
||||||
@@ -16,16 +21,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alembic"
|
name = "alembic"
|
||||||
version = "1.18.4"
|
version = "1.20.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mako" },
|
{ name = "mako" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -60,34 +65,55 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.32.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
|
{ url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -153,11 +179,27 @@ requires-dist = [
|
|||||||
name = "beartype"
|
name = "beartype"
|
||||||
version = "0.22.9"
|
version = "0.22.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version == '3.14.*'",
|
||||||
|
"python_full_version < '3.14'",
|
||||||
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beartype"
|
||||||
|
version = "0.23.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.15'",
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/74/b7/6f162c0116815b45de8ac81cde5aaacfc67cce4b573fb2e194c3a8c623eb/beartype-0.23.0.tar.gz", hash = "sha256:9d915ce043a2262ee3eb110d1c1c528179f06eb4145e375a750f72ddbdf40fa3", size = 1992744, upload-time = "2026-10-10T07:07:41.909Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/ce/64d48f01d88370a78dc0b3e484e8424cd5c87d5a0ac34c1ac93ad8d24275/beartype-0.23.0-py3-none-any.whl", hash = "sha256:a88b85be516c064d4de2b42045a258a43be97bce5cbc18b777f332027c0979f7", size = 1581485, upload-time = "2026-10-10T07:07:39.543Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cachetools"
|
name = "cachetools"
|
||||||
version = "7.1.4"
|
version = "7.1.4"
|
||||||
@@ -184,15 +226,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2026.5.20"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cffi"
|
name = "cffi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -378,37 +411,39 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.3"
|
version = "0.143.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp"
|
name = "fastmcp"
|
||||||
version = "3.3.1"
|
version = "4.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/a9/5c5a01b6abd5346bf60b97cfd29e4a86661940c27dd562bfcda07fd03519/fastmcp-3.3.1.tar.gz", hash = "sha256:979362ea557de42a5f40342563c7e4b236bcc8e7cd192715f50030695d1a71cd", size = 28681699, upload-time = "2026-05-15T15:50:39.673Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/11/6b1bdada6ccfe647d615ae63f9106f8136aec17971e9361546af01c7d38e/fastmcp-3.3.1-py3-none-any.whl", hash = "sha256:862440c5c4d281363a5995eee59d77f0f7cac1f18869038729cecf03b02fc522", size = 7903, upload-time = "2026-05-15T15:50:36.424Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/81/77610de5d79187455ec4a4eafdc17942365f1bc4e5850633e50159e9afdc/fastmcp-4.1.0-py3-none-any.whl", hash = "sha256:cf19fd603315b48ef1829c8579431e77793feafdcbed51f0dc5607af10a24e9b", size = 8154, upload-time = "2026-10-08T22:57:02.531Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp-slim"
|
name = "fastmcp-slim"
|
||||||
version = "3.3.1"
|
version = "4.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "mcp-types" },
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -416,26 +451,30 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/a0/627103e517e1d0d6f1eec633d5662d13e776f01b45ad188e4f5f7478b438/fastmcp_slim-3.3.1.tar.gz", hash = "sha256:0957835fc59452e143ab2f4b7836d2d2df9b2d9958408edc79ba8b56232b2a88", size = 567007, upload-time = "2026-05-15T15:50:10.426Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b8/88/85aafec43ec9940a360d75f2d8e9517eda01ac38b8b1610b0e9e125d61d3/fastmcp_slim-4.1.0.tar.gz", hash = "sha256:390b2b430195e0cdd0327aff80fe5dceb862b2421baf71de6d4eec92f80f99f8", size = 734647, upload-time = "2026-10-08T22:56:40.642Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/ee/97047f4cc2d7b1d46670d08d8ad01a96e7a748cc01c0b4b351ad8eddbc7a/fastmcp_slim-3.3.1-py3-none-any.whl", hash = "sha256:6cf1c2d77e3adb0d409d6825ed6b0b2a999062973e00b8eea03bd48bf9b4c043", size = 738644, upload-time = "2026-05-15T15:50:08.336Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/6a/d61e1541f4c65872146a576c1379f6f32013fd8c636bcaf0f8e70a04e533/fastmcp_slim-4.1.0-py3-none-any.whl", hash = "sha256:6edb48a78e508ef5ac691bf2a68f3e5000403773655d859eceea1068b894df4a", size = 911585, upload-time = "2026-10-08T22:56:39.136Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
client = [
|
client = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
|
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
{ name = "exceptiongroup" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx2" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
||||||
|
{ name = "starlette" },
|
||||||
]
|
]
|
||||||
server = [
|
server = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
|
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
{ name = "cyclopts" },
|
{ name = "cyclopts" },
|
||||||
{ name = "exceptiongroup" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "griffelib" },
|
{ name = "griffelib" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx2" },
|
||||||
|
{ name = "joserfc" },
|
||||||
{ name = "jsonref" },
|
{ name = "jsonref" },
|
||||||
{ name = "jsonschema-path" },
|
{ name = "jsonschema-path" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
@@ -446,6 +485,7 @@ server = [
|
|||||||
{ name = "pyperclip" },
|
{ name = "pyperclip" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
|
{ name = "starlette" },
|
||||||
{ name = "uncalled-for" },
|
{ name = "uncalled-for" },
|
||||||
{ name = "uvicorn" },
|
{ name = "uvicorn" },
|
||||||
{ name = "watchfiles" },
|
{ name = "watchfiles" },
|
||||||
@@ -454,59 +494,59 @@ server = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.5.1"
|
version = "3.5.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
|
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
|
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -528,16 +568,16 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore2"
|
||||||
version = "1.0.9"
|
version = "2.13.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
|
{ name = "truststore" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -570,36 +610,37 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx"
|
name = "httpx2"
|
||||||
version = "0.28.1"
|
version = "2.13.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||||
{ name = "certifi" },
|
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
|
||||||
{ name = "httpcore" },
|
{ name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
|
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx-sse"
|
name = "httpx2-jsfetch"
|
||||||
version = "0.4.3"
|
version = "1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.17"
|
version = "3.20"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
|
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -726,15 +767,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kurigram"
|
name = "kurigram"
|
||||||
version = "2.2.23"
|
version = "2.2.26"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pyaes" },
|
{ name = "pyaes" },
|
||||||
{ name = "python-socks" },
|
{ name = "python-socks" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/f3/e93fe48fa937cb681c4a8581f4d75da9e9054e35efedaecc9490fb585b45/kurigram-2.2.23.tar.gz", hash = "sha256:b8ccc23cab2875b25134a01f7c632dd64de334804b60c8cf19f20eb3b2ec5c58", size = 589349, upload-time = "2026-05-21T15:12:37.533Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c1/48/65efaaaaf24dc62d6f6ef046843952ff17b5358f9c7119303ceee6d53657/kurigram-2.2.26.tar.gz", hash = "sha256:83b64fd7ab37122d920b1e7c0e221b599d7c52dd5d0d24c79956f01273020c49", size = 748176, upload-time = "2026-09-12T17:45:08.406Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/1b/00ca5e8a39dbf7083e8ad37bf0e3062f3126f78ee3f94cda78d77e5883ff/kurigram-2.2.23-py3-none-any.whl", hash = "sha256:3a5806c76acd0f8fdd9cd1e2f65f84c61f8b2650689c7481c5035a8ad26022d9", size = 5690474, upload-time = "2026-05-21T15:12:35.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/e0/a00020c21725550c9309e6e03787acf7236de2f284c8d9bc57644ae2160a/kurigram-2.2.26-py3-none-any.whl", hash = "sha256:f53cee6119a579b74620f45b5f83d543cd3bd510a8a6a1b4b3852e21603241e9", size = 6044442, upload-time = "2026-09-12T17:45:06.456Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -815,15 +856,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "1.27.2"
|
version = "2.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx2" },
|
||||||
{ name = "httpx-sse" },
|
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
|
{ name = "mcp-types" },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
|
||||||
{ name = "pyjwt", extra = ["crypto"] },
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||||
@@ -833,9 +874,22 @@ dependencies = [
|
|||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp-types"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -870,14 +924,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-api"
|
name = "opentelemetry-api"
|
||||||
version = "1.42.1"
|
version = "1.45.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -912,7 +966,8 @@ name = "py-key-value-aio"
|
|||||||
version = "0.4.5"
|
version = "0.4.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "beartype" },
|
{ name = "beartype", version = "0.22.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||||
|
{ name = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" }
|
||||||
@@ -1025,16 +1080,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-settings"
|
name = "pydantic-settings"
|
||||||
version = "2.14.1"
|
version = "2.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1337,16 +1392,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlmodel"
|
name = "sqlmodel"
|
||||||
version = "0.0.38"
|
version = "0.0.48"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "sqlalchemy" },
|
{ name = "sqlalchemy" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/dd/67/b2c0b771c89c023262dd8fb6ba2cdc6a004e5ff75dd5763051b38ccd133a/sqlmodel-0.0.48.tar.gz", hash = "sha256:5582e87e845e23bb1179a7d8b11a4f5e441b4494528a41ee2fe1d129ffe91543", size = 99329, upload-time = "2026-10-06T21:44:36.391Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/76/6f8221eda15f28471c3887e5a75a62cd7338d20ac03e1e78a110a127d61e/sqlmodel-0.0.48-py3-none-any.whl", hash = "sha256:8d389bf735b03a17508e93e888c13a30ef1ddca22dd8e57add12317401e4112e", size = 31468, upload-time = "2026-10-06T21:44:35.326Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1380,6 +1435,15 @@ version = "1.2.5"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/33/59/7cf5ced989e3139a791d5452d58cb8994de589576b80f9267ba76d794f6c/TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682", size = 37280, upload-time = "2022-11-11T19:55:11.436Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/33/59/7cf5ced989e3139a791d5452d58cb8994de589576b80f9267ba76d794f6c/TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682", size = 37280, upload-time = "2022-11-11T19:55:11.436Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "truststore"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
@@ -1403,29 +1467,28 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uncalled-for"
|
name = "uncalled-for"
|
||||||
version = "0.3.2"
|
version = "0.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6c/54/4651e4c5fe84300f8e03c0390aaad41bcb9c7b81d7783542a3ee690b40ee/uncalled_for-0.4.1.tar.gz", hash = "sha256:6412b19d1b1e7d431981fee01f8b47be7802f48328a48cbe9603acee757ec553", size = 56971, upload-time = "2026-10-09T12:54:34.941Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" },
|
{ url = "https://files.pythonhosted.org/packages/bf/a6/ba763e3812e572de7291676c3a41adfac1ef1209a6acda6bae055970f127/uncalled_for-0.4.1-py3-none-any.whl", hash = "sha256:b611b38879d9fe5088297e7bb28f2ddd12d2d73dad933d93da107fb63eb4058e", size = 14889, upload-time = "2026-10-09T12:54:33.596Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.48.0"
|
version = "0.54.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
standard = [
|
standard = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
||||||
{ name = "httptools" },
|
{ name = "httptools" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
@@ -1436,28 +1499,40 @@ standard = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvloop"
|
name = "uvloop"
|
||||||
version = "0.22.1"
|
version = "0.23.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -95,6 +95,19 @@ 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,12 +26,22 @@ import type {
|
|||||||
PolicyChatKind,
|
PolicyChatKind,
|
||||||
PolicyCreate,
|
PolicyCreate,
|
||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
|
PolicyRule,
|
||||||
|
PolicyScope,
|
||||||
PresenceHourly,
|
PresenceHourly,
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
ReactionView,
|
ReactionView,
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
|
ScopeTarget,
|
||||||
SearchHit,
|
SearchHit,
|
||||||
|
StorageChat,
|
||||||
|
StorageFile,
|
||||||
|
StorageGroupName,
|
||||||
|
StorageScope,
|
||||||
|
StorageSummary,
|
||||||
StoryView,
|
StoryView,
|
||||||
|
TrashItem,
|
||||||
|
TrashOutcome,
|
||||||
VolumeBucket,
|
VolumeBucket,
|
||||||
Watch,
|
Watch,
|
||||||
WatchedBot,
|
WatchedBot,
|
||||||
@@ -143,6 +153,23 @@ 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;
|
||||||
@@ -581,3 +608,75 @@ 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(
|
||||||
|
chatId: number | null = null
|
||||||
|
): Promise<StorageSummary> {
|
||||||
|
return request<StorageSummary>("/storage/summary", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: chatId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageFiles(
|
||||||
|
group: StorageGroupName | null,
|
||||||
|
page: Page = {},
|
||||||
|
chatId: number | null = null
|
||||||
|
): Promise<StorageFile[]> {
|
||||||
|
return request<StorageFile[]>("/storage/files", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: chatId, group, ...page },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStorageChats(
|
||||||
|
group: StorageGroupName | null,
|
||||||
|
page: Page = {}
|
||||||
|
): Promise<StorageChat[]> {
|
||||||
|
return request<StorageChat[]>("/storage/chats", {
|
||||||
|
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,6 +19,7 @@ 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;
|
||||||
@@ -26,6 +27,7 @@ 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(
|
||||||
@@ -102,6 +104,16 @@ 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>();
|
||||||
@@ -130,6 +142,26 @@ 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,6 +423,24 @@ 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 {
|
||||||
@@ -560,3 +578,73 @@ 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 StorageChat {
|
||||||
|
bytes: number;
|
||||||
|
chat_id: number;
|
||||||
|
files: number;
|
||||||
|
groups: StorageGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,6 +15,7 @@
|
|||||||
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";
|
||||||
@@ -186,6 +187,11 @@
|
|||||||
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,6 +7,7 @@
|
|||||||
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";
|
||||||
|
|
||||||
@@ -108,6 +109,11 @@
|
|||||||
<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,5 +1,8 @@
|
|||||||
<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;
|
||||||
@@ -21,6 +24,12 @@
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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("");
|
||||||
@@ -64,9 +73,31 @@
|
|||||||
|
|
||||||
<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)}
|
||||||
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
|
{#if tab.id === null}
|
||||||
{tab.title}
|
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
|
||||||
</button>
|
{tab.title}
|
||||||
|
</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,10 +1,14 @@
|
|||||||
<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, requestMedia } from "#lib/api/client.js";
|
import { type MediaResult, mediaUrl, 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 { isPreviewable, type ViewerItem } from "#lib/api/media.js";
|
import {
|
||||||
|
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";
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
chatId: number;
|
chatId: number;
|
||||||
index: number;
|
index: number;
|
||||||
items: ViewerItem[];
|
items: ViewerItem[];
|
||||||
|
ontrash?: (item: ViewerItem) => void;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,14 +32,18 @@
|
|||||||
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);
|
||||||
@@ -53,35 +62,54 @@
|
|||||||
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) {
|
if (result?.state === "ready" && result.url && !streamed) {
|
||||||
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) {
|
if (mediaId === null && !item.src) {
|
||||||
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 (downloaded) {
|
} else if (source !== null) {
|
||||||
|
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;
|
||||||
@@ -92,6 +120,7 @@
|
|||||||
fileName = name;
|
fileName = name;
|
||||||
fileSize = size;
|
fileSize = size;
|
||||||
fileOnly = plainFile;
|
fileOnly = plainFile;
|
||||||
|
streamed = source !== null && !plainFile;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
@@ -136,6 +165,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -181,8 +218,21 @@
|
|||||||
<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,
|
||||||
@@ -234,7 +284,7 @@
|
|||||||
class="media-video"
|
class="media-video"
|
||||||
controls
|
controls
|
||||||
playsinline
|
playsinline
|
||||||
preload="auto"
|
preload={streamed ? "metadata" : "auto"}
|
||||||
src={result.url}
|
src={result.url}
|
||||||
use:poster
|
use:poster
|
||||||
></video>
|
></video>
|
||||||
@@ -248,10 +298,12 @@
|
|||||||
{#if fileSize}
|
{#if fileSize}
|
||||||
<p class="file-size">{formatBytes(fileSize)}</p>
|
<p class="file-size">{formatBytes(fileSize)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="media-download" onclick={save} type="button">
|
{#if currentMediaId !== null}
|
||||||
<Icon name="download" />
|
<button class="media-download" onclick={save} type="button">
|
||||||
Скачать файл
|
<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">
|
||||||
@@ -323,6 +375,20 @@
|
|||||||
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,6 +2,8 @@
|
|||||||
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";
|
||||||
@@ -16,6 +18,10 @@
|
|||||||
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 { chats } from "#lib/stores/chats.svelte.js";
|
||||||
|
import { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
|
import { storageUi } from "#lib/stores/storage.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> = {
|
||||||
@@ -23,7 +29,7 @@
|
|||||||
annotations: "Заметки",
|
annotations: "Заметки",
|
||||||
bots: "Боты",
|
bots: "Боты",
|
||||||
callbacks: "Callback-данные",
|
callbacks: "Callback-данные",
|
||||||
jobs: "Данные и хранилище",
|
jobs: "Бэкфилл и задачи",
|
||||||
links: "Ссылки",
|
links: "Ссылки",
|
||||||
policy: "Политика захвата",
|
policy: "Политика захвата",
|
||||||
presence: "Аналитика",
|
presence: "Аналитика",
|
||||||
@@ -31,31 +37,65 @@
|
|||||||
reactions: "Реакции",
|
reactions: "Реакции",
|
||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
shares: "Файлы по ссылке",
|
shares: "Файлы по ссылке",
|
||||||
|
storage: "Хранилище",
|
||||||
|
"storage-chat": "Файлы чата",
|
||||||
|
"storage-trash": "Корзина",
|
||||||
stories: "Сторис",
|
stories: "Сторис",
|
||||||
"stories-all": "Все сторис",
|
"stories-all": "Все сторис",
|
||||||
versions: "Версии",
|
versions: "Версии",
|
||||||
watches: "Отслеживания",
|
watches: "Отслеживания",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||||
|
"storage-chat": "storage",
|
||||||
|
"storage-trash": "storage",
|
||||||
|
};
|
||||||
|
|
||||||
|
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
||||||
|
const title = $derived.by(() => {
|
||||||
|
if (ui.rightPanel === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const chat =
|
||||||
|
ui.rightPanel === "storage-chat" && storageUi.chatId !== null
|
||||||
|
? chats.byId(storageUi.chatId)
|
||||||
|
: undefined;
|
||||||
|
return chat?.title ?? titles[ui.rightPanel];
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="right-header">
|
<header class="right-header">
|
||||||
<Button
|
{#if parent}
|
||||||
aria-label="Закрыть"
|
<Button
|
||||||
onclick={() => ui.closePanel()}
|
aria-label="Назад"
|
||||||
round
|
onclick={() => ui.openPanel(parent)}
|
||||||
smaller
|
round
|
||||||
variant="translucent"
|
smaller
|
||||||
>
|
variant="translucent"
|
||||||
<Icon name="close" />
|
>
|
||||||
</Button>
|
<Icon name="arrow-left" />
|
||||||
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
aria-label="Закрыть"
|
||||||
|
onclick={() => ui.closePanel()}
|
||||||
|
round
|
||||||
|
smaller
|
||||||
|
variant="translucent"
|
||||||
|
>
|
||||||
|
<Icon name="close" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<h2>{title}</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="right-body custom-scroll">
|
<div class="right-body custom-scroll">
|
||||||
{#if ui.rightPanel === "profile"}
|
{#if ui.rightPanel === "profile"}
|
||||||
<ProfilePanel />
|
<ProfilePanel />
|
||||||
{:else if ui.rightPanel === "policy"}
|
{:else if ui.rightPanel === "policy"}
|
||||||
<PolicyEditor />
|
{#key `${accounts.selectedId}:${policyUi.revision}`}
|
||||||
|
<PolicyEditor />
|
||||||
|
{/key}
|
||||||
{:else if ui.rightPanel === "jobs"}
|
{:else if ui.rightPanel === "jobs"}
|
||||||
<JobsPanel />
|
<JobsPanel />
|
||||||
{:else if ui.rightPanel === "presence"}
|
{:else if ui.rightPanel === "presence"}
|
||||||
@@ -82,6 +122,18 @@
|
|||||||
<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-chat"}
|
||||||
|
{#key `${accounts.selectedId}:${storageUi.chatId}`}
|
||||||
|
<StoragePanel chatId={storageUi.chatId} />
|
||||||
|
{/key}
|
||||||
|
{:else if ui.rightPanel === "storage-trash"}
|
||||||
|
{#key accounts.selectedId}
|
||||||
|
<TrashPanel />
|
||||||
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,9 +149,15 @@
|
|||||||
border-bottom: 1px solid var(--color-borders);
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
getStorageScope,
|
||||||
|
restoreFromTrash,
|
||||||
|
trashScope,
|
||||||
|
} from "#lib/api/endpoints.js";
|
||||||
|
import type {
|
||||||
|
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 { formatBytes } from "#lib/format/media.js";
|
||||||
|
import { plural } from "#lib/format/shares.js";
|
||||||
|
import { filesLabel } from "#lib/format/storage.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string;
|
||||||
|
onchange?: () => void;
|
||||||
|
target: ScopeTarget;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { target, label, onchange }: Props = $props();
|
||||||
|
|
||||||
|
let files = $state<StorageScope | null>(null);
|
||||||
|
let confirming = $state(false);
|
||||||
|
let moving = $state(false);
|
||||||
|
let moved = $state<TrashOutcome | null>(null);
|
||||||
|
let problem = $state<string | null>(null);
|
||||||
|
|
||||||
|
function where(scope: StorageScope): string {
|
||||||
|
return "folderId" in target
|
||||||
|
? `в ${scope.chats} ${plural(scope.chats, "чате", "чатах", "чатах")} папки`
|
||||||
|
: "в этом чате";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
files = await getStorageScope(target);
|
||||||
|
} catch {
|
||||||
|
files = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function move() {
|
||||||
|
if (moving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moving = true;
|
||||||
|
problem = null;
|
||||||
|
try {
|
||||||
|
moved = await trashScope(target);
|
||||||
|
confirming = false;
|
||||||
|
await load();
|
||||||
|
onchange?.();
|
||||||
|
} catch {
|
||||||
|
problem = "Не удалось переместить файлы в корзину";
|
||||||
|
} finally {
|
||||||
|
moving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undo() {
|
||||||
|
if (moved === null || moving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
moving = true;
|
||||||
|
problem = null;
|
||||||
|
try {
|
||||||
|
await restoreFromTrash(moved.keys);
|
||||||
|
moved = null;
|
||||||
|
await load();
|
||||||
|
onchange?.();
|
||||||
|
} catch {
|
||||||
|
problem = "Не удалось вернуть файлы";
|
||||||
|
} finally {
|
||||||
|
moving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refresh() {
|
||||||
|
return load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if moved && moved.files > 0}
|
||||||
|
<div class="files">
|
||||||
|
<div class="text">
|
||||||
|
<span>{filesLabel(moved.files)} в корзине</span>
|
||||||
|
<small>{formatBytes(moved.bytes)} освободится через 7 дней</small>
|
||||||
|
{#if problem}
|
||||||
|
<small class="failed">{problem}</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button disabled={moving} onclick={undo} 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="text">
|
||||||
|
<span>
|
||||||
|
{filesLabel(files.files)}, {formatBytes(files.bytes)},
|
||||||
|
{plural(
|
||||||
|
files.files,
|
||||||
|
"уйдёт в корзину и перестанет скачиваться сам",
|
||||||
|
"уйдут в корзину и перестанут скачиваться сами",
|
||||||
|
"уйдут в корзину и перестанут скачиваться сами"
|
||||||
|
)}.
|
||||||
|
</span>
|
||||||
|
{#if files.gone > 0}
|
||||||
|
<small class="warn">
|
||||||
|
{files.gone}
|
||||||
|
из них потом не скачать заново
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
{#if problem}
|
||||||
|
<small class="failed">{problem}</small>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
confirming = false;
|
||||||
|
}}
|
||||||
|
smaller
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={moving}
|
||||||
|
loading={moving}
|
||||||
|
onclick={move}
|
||||||
|
pill
|
||||||
|
smaller
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="1.25rem" />
|
||||||
|
<span>В корзину</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text">
|
||||||
|
{#if label}
|
||||||
|
<span>{label}</span>
|
||||||
|
{#if files.kept > 0}
|
||||||
|
<small>
|
||||||
|
{filesLabel(files.files)}
|
||||||
|
· {formatBytes(files.bytes)}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<span>{formatBytes(files.bytes)} уже скачано</span>
|
||||||
|
<small>{filesLabel(files.files)} {where(files)}</small>
|
||||||
|
{/if}
|
||||||
|
{#if files.kept > 0}
|
||||||
|
<small>
|
||||||
|
Ещё {filesLabel(files.kept)} есть и в других чатах,
|
||||||
|
{plural(
|
||||||
|
files.kept,
|
||||||
|
"он останется",
|
||||||
|
"они останутся",
|
||||||
|
"они останутся"
|
||||||
|
)}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onclick={() => {
|
||||||
|
confirming = true;
|
||||||
|
}}
|
||||||
|
pill
|
||||||
|
smaller
|
||||||
|
variant="gray"
|
||||||
|
>
|
||||||
|
<Icon name="delete" size="1.25rem" />
|
||||||
|
<span>В корзину</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if label && files && files.kept > 0}
|
||||||
|
<div class="files">
|
||||||
|
<div class="text">
|
||||||
|
<span>{label}</span>
|
||||||
|
<small>
|
||||||
|
{filesLabel(files.kept)}
|
||||||
|
есть и в других чатах, убрать
|
||||||
|
{plural(files.kept, "его", "их", "их")}
|
||||||
|
можно из списка ниже
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.failed {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { StorageChat } from "#lib/api/types.js";
|
||||||
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
|
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
|
||||||
|
import { formatBytes } from "#lib/format/media.js";
|
||||||
|
import { filesLabel, GROUP_LABELS } from "#lib/format/storage.js";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chat: StorageChat;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
menu: Snippet;
|
||||||
|
onopen: () => void;
|
||||||
|
title: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chat, title, hasAvatar, weight, onopen, menu }: Props = $props();
|
||||||
|
|
||||||
|
const MIN_WEIGHT = 0.02;
|
||||||
|
const AVATAR_REM = 3;
|
||||||
|
|
||||||
|
const scale = $derived(Math.max(MIN_WEIGHT, Math.min(1, weight)));
|
||||||
|
const parts = $derived(chat.groups.filter((item) => item.bytes > 0));
|
||||||
|
const makeup = $derived(
|
||||||
|
parts
|
||||||
|
.map((item) => `${GROUP_LABELS[item.group]}: ${formatBytes(item.bytes)}`)
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ContextMenu {menu}>
|
||||||
|
{#snippet children({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<button {...props} class="row" onclick={onopen} type="button">
|
||||||
|
<Avatar
|
||||||
|
avatar={{ id: chat.chat_id, kind: chat.chat_id > 0 ? "peer" : "chat" }}
|
||||||
|
colorKey={chat.chat_id}
|
||||||
|
{hasAvatar}
|
||||||
|
name={title}
|
||||||
|
size={AVATAR_REM}
|
||||||
|
/>
|
||||||
|
<span class="text">
|
||||||
|
<span class="name">{title}</span>
|
||||||
|
<span class="sub">{filesLabel(chat.files)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="size">{formatBytes(chat.bytes)}</span>
|
||||||
|
<span class="weight" title={makeup}>
|
||||||
|
<span class="fill" style:width="{scale * 100}%">
|
||||||
|
{#each parts as part (part.group)}
|
||||||
|
<span
|
||||||
|
style:background-color="var(--color-storage-{part.group})"
|
||||||
|
style:flex-grow={part.bytes}
|
||||||
|
></span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.row {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 3rem minmax(0, 1fr) auto;
|
||||||
|
column-gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem 0.625rem;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
|
||||||
|
span {
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,878 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
|
import { mediaUrl } from "#lib/api/client.js";
|
||||||
|
import { downloadMedia } from "#lib/api/download.js";
|
||||||
|
import {
|
||||||
|
getStorageChats,
|
||||||
|
getStorageFiles,
|
||||||
|
getStorageSummary,
|
||||||
|
moveToTrash,
|
||||||
|
restoreFromTrash,
|
||||||
|
} from "#lib/api/endpoints.js";
|
||||||
|
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
|
||||||
|
import type {
|
||||||
|
StorageChat,
|
||||||
|
StorageFile,
|
||||||
|
StorageGroupName,
|
||||||
|
StorageSummary,
|
||||||
|
TrashOutcome,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
|
||||||
|
import SelectionBar from "#lib/components/disk/SelectionBar.svelte";
|
||||||
|
import StorageChatRow from "#lib/components/disk/StorageChatRow.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 { policyUi } from "#lib/stores/policy.svelte.js";
|
||||||
|
import {
|
||||||
|
type StorageListing,
|
||||||
|
storageUi,
|
||||||
|
} from "#lib/stores/storage.svelte.js";
|
||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
import { isMobile } from "#lib/viewport.js";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chatId = null }: Props = $props();
|
||||||
|
|
||||||
|
const PAGE = 30;
|
||||||
|
const UNDO_MS = 12_000;
|
||||||
|
const UNDO_GUARD_MS = 600;
|
||||||
|
const LISTINGS: { label: string; value: StorageListing }[] = [
|
||||||
|
{ label: "Файлы", value: "files" },
|
||||||
|
{ label: "Чаты", value: "chats" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let summary = $state<StorageSummary | null>(null);
|
||||||
|
let files = $state<StorageFile[]>([]);
|
||||||
|
let heavyChats = $state<StorageChat[]>([]);
|
||||||
|
let scope = $state<ReturnType<typeof ScopeFiles>>();
|
||||||
|
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 scroller: HTMLElement | null = null;
|
||||||
|
let undoTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let undoShownAt = 0;
|
||||||
|
|
||||||
|
const listing = $derived<StorageListing>(
|
||||||
|
chatId === null ? storageUi.listing : "files"
|
||||||
|
);
|
||||||
|
const shown = $derived(
|
||||||
|
listing === "chats" ? heavyChats.length : files.length
|
||||||
|
);
|
||||||
|
const heaviest = $derived(files[0]?.file_size ?? 0);
|
||||||
|
const heaviestChat = $derived(heavyChats[0]?.bytes ?? 0);
|
||||||
|
const emptiness = $derived.by(() => {
|
||||||
|
if (group !== null) {
|
||||||
|
return "В этой группе нет файлов.";
|
||||||
|
}
|
||||||
|
return chatId === null
|
||||||
|
? "На диске нет скачанных файлов."
|
||||||
|
: "В этом чате нет скачанных файлов.";
|
||||||
|
});
|
||||||
|
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(id: number): string {
|
||||||
|
return chats.byId(id)?.title ?? `Чат ${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSummary() {
|
||||||
|
try {
|
||||||
|
summary = await getStorageSummary(chatId);
|
||||||
|
} catch {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPage(current: number, limit: number): Promise<number> {
|
||||||
|
if (listing === "chats") {
|
||||||
|
const batch = await getStorageChats(group, {
|
||||||
|
limit,
|
||||||
|
offset: heavyChats.length,
|
||||||
|
});
|
||||||
|
if (current === token) {
|
||||||
|
chats.ensureMany(batch.map((chat) => chat.chat_id));
|
||||||
|
heavyChats = [...heavyChats, ...batch];
|
||||||
|
}
|
||||||
|
return batch.length;
|
||||||
|
}
|
||||||
|
const batch = await getStorageFiles(
|
||||||
|
group,
|
||||||
|
{ limit, offset: files.length },
|
||||||
|
chatId
|
||||||
|
);
|
||||||
|
if (current === token) {
|
||||||
|
chats.ensureMany(batch.map((file) => file.chat_id));
|
||||||
|
files = [...files, ...batch];
|
||||||
|
}
|
||||||
|
return batch.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore(limit = PAGE) {
|
||||||
|
if (loading || done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = token;
|
||||||
|
loading = true;
|
||||||
|
failed = false;
|
||||||
|
try {
|
||||||
|
const loaded = await loadPage(current, limit);
|
||||||
|
if (current === token) {
|
||||||
|
done = loaded < limit;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (current === token) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (current === token) {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload(limit = PAGE) {
|
||||||
|
token += 1;
|
||||||
|
files = [];
|
||||||
|
heavyChats = [];
|
||||||
|
done = false;
|
||||||
|
loading = false;
|
||||||
|
selected.clear();
|
||||||
|
return Promise.all([loadSummary(), loadMore(limit)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume() {
|
||||||
|
const from = chatId === null ? storageUi.takeResume() : null;
|
||||||
|
await reload(Math.ceil(Math.max(1, from?.count ?? 0) / PAGE) * PAGE);
|
||||||
|
if (from && scroller) {
|
||||||
|
await tick();
|
||||||
|
scroller.scrollTop = from.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openChat(id: number) {
|
||||||
|
storageUi.openChat(id, {
|
||||||
|
count: heavyChats.length,
|
||||||
|
top: scroller?.scrollTop ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hold(node: HTMLElement) {
|
||||||
|
scroller = node.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
scope?.refresh();
|
||||||
|
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
|
||||||
|
);
|
||||||
|
scope?.refresh();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(next: StorageListing) {
|
||||||
|
if (next === storageUi.listing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storageUi.listing = next;
|
||||||
|
problem = null;
|
||||||
|
hideUndo();
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(chat: StorageChat) {
|
||||||
|
goto(`/app/${chat.chat_id}`);
|
||||||
|
if (isMobile()) {
|
||||||
|
ui.closePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(resume);
|
||||||
|
onDestroy(() => clearTimeout(undoTimer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="storage" {@attach hold}>
|
||||||
|
{#if summary}
|
||||||
|
{#if summary.files > 0 || chatId !== null}
|
||||||
|
<section class="summary">
|
||||||
|
<div class="hero">
|
||||||
|
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
|
||||||
|
<span class="count">
|
||||||
|
{filesLabel(summary.files)}
|
||||||
|
{#if chatId !== null}
|
||||||
|
в этом чате
|
||||||
|
{:else if summary.trash_bytes > 0}
|
||||||
|
· ещё {formatBytes(summary.trash_bytes)} в корзине
|
||||||
|
{:else}
|
||||||
|
на диске
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="stack" class:bare={groups.length === 0}>
|
||||||
|
{#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 chatId === null && 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}
|
||||||
|
|
||||||
|
{#if chatId === null}
|
||||||
|
<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>
|
||||||
|
<div class="tabs">
|
||||||
|
{#each LISTINGS as item (item.value)}
|
||||||
|
<button
|
||||||
|
aria-pressed={listing === item.value}
|
||||||
|
class="tab"
|
||||||
|
onclick={() => show(item.value)}
|
||||||
|
type="button"
|
||||||
|
class:on={listing === item.value}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ScopeFiles
|
||||||
|
label="Все файлы чата"
|
||||||
|
onchange={() => {
|
||||||
|
hideUndo();
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
target={{ chatId }}
|
||||||
|
bind:this={scope}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if listing === "files" && 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" class:chats={listing === "chats"}>
|
||||||
|
{#each heavyChats as chat (chat.chat_id)}
|
||||||
|
<StorageChatRow
|
||||||
|
{chat}
|
||||||
|
hasAvatar={chats.byId(chat.chat_id)?.has_avatar ?? false}
|
||||||
|
onopen={() => openChat(chat.chat_id)}
|
||||||
|
title={chatTitle(chat.chat_id)}
|
||||||
|
weight={heaviestChat > 0 ? chat.bytes / heaviestChat : 0}
|
||||||
|
>
|
||||||
|
{#snippet menu()}
|
||||||
|
<ContextMenuItem icon="data" onselect={() => openChat(chat.chat_id)}>
|
||||||
|
Файлы чата
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem icon="reply" onselect={() => visit(chat)}>
|
||||||
|
Перейти в чат
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
icon="settings"
|
||||||
|
onselect={() =>
|
||||||
|
policyUi.edit({ chatId: chat.chat_id }, chatTitle(chat.chat_id))}
|
||||||
|
>
|
||||||
|
Политика захвата
|
||||||
|
</ContextMenuItem>
|
||||||
|
{/snippet}
|
||||||
|
</StorageChatRow>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#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={chatId === null
|
||||||
|
? chatTitle(file.chat_id)
|
||||||
|
: formatListDate(file.date)}
|
||||||
|
tail={chatId === null ? formatListDate(file.date) : undefined}
|
||||||
|
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 shown === 0}
|
||||||
|
<div class="state"><p>{emptiness}</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;
|
||||||
|
|
||||||
|
&.bare {
|
||||||
|
border-radius: 0.3125rem;
|
||||||
|
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid var(--color-borders);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
height: 2.75rem;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
transition: color 0.15s ease-out;
|
||||||
|
|
||||||
|
&.on {
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:not(.on):hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
&.chats {
|
||||||
|
padding-top: 0.375rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
<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>
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import {
|
||||||
|
createPolicy,
|
||||||
|
deletePolicy,
|
||||||
|
getPolicyScope,
|
||||||
|
updatePolicy,
|
||||||
|
} from "#lib/api/endpoints.js";
|
||||||
|
import type {
|
||||||
|
CaptureToggles,
|
||||||
|
PolicyParent,
|
||||||
|
PolicyRecord,
|
||||||
|
PolicyScopeType,
|
||||||
|
ScopeTarget,
|
||||||
|
} from "#lib/api/types.js";
|
||||||
|
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
|
||||||
|
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 {
|
||||||
|
parentLabel,
|
||||||
|
pickToggles,
|
||||||
|
preset,
|
||||||
|
sameToggles,
|
||||||
|
summary,
|
||||||
|
TOGGLE_KEYS,
|
||||||
|
TOGGLE_LABELS,
|
||||||
|
type ToggleKey,
|
||||||
|
} from "#lib/format/policy.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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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 target}
|
||||||
|
<ScopeFiles {target} />
|
||||||
|
{/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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<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,241 +1,369 @@
|
|||||||
<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 {
|
import type { Folder, PolicyRule, PolicyScopeType } from "#lib/api/types.js";
|
||||||
CaptureToggles,
|
import PolicyCard from "#lib/components/policy/PolicyCard.svelte";
|
||||||
Folder,
|
import Avatar from "#lib/components/ui/Avatar.svelte";
|
||||||
PolicyRecord,
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
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 { accounts } from "#lib/stores/accounts.svelte.js";
|
import {
|
||||||
|
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";
|
|
||||||
|
|
||||||
let policies = $state<PolicyRecord[]>([]);
|
interface Draft {
|
||||||
let folders = $state<Folder[]>([]);
|
scopeId: number;
|
||||||
let loading = $state(true);
|
scopeType: "folder" | "chat";
|
||||||
let pickFolder = $state(false);
|
title: string;
|
||||||
let pickChat = $state(false);
|
}
|
||||||
let chatFilter = $state("");
|
|
||||||
|
|
||||||
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
|
const DEFAULT_ORDER: PolicyScopeType[] = [
|
||||||
{ label: "Личные чаты", scope: "default_dm" },
|
"default_dm",
|
||||||
{ label: "Группы", scope: "default_group" },
|
"default_group",
|
||||||
{ label: "Каналы", scope: "default_channel" },
|
"default_channel",
|
||||||
];
|
];
|
||||||
|
const DEFAULT_ICONS: Partial<Record<PolicyScopeType, string>> = {
|
||||||
const ALL_FALSE: CaptureToggles = {
|
default_channel: "channel",
|
||||||
backfill: false,
|
default_dm: "user",
|
||||||
media: false,
|
default_group: "group",
|
||||||
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 {
|
let rules = $state<PolicyRule[]>([]);
|
||||||
return {
|
let folders = $state<Folder[]>([]);
|
||||||
backfill: t.backfill,
|
let loading = $state(true);
|
||||||
media: t.media,
|
let failed = $state(false);
|
||||||
messages: t.messages,
|
let problem = $state<string | null>(null);
|
||||||
presence: t.presence,
|
let expanded = $state<string | null>(null);
|
||||||
profile_history: t.profile_history,
|
let draft = $state<Draft | null>(null);
|
||||||
reactions: t.reactions,
|
let picking = $state<"folder" | "chat" | null>(null);
|
||||||
self_destruct_media: t.self_destruct_media,
|
let chatFilter = $state("");
|
||||||
stories: t.stories,
|
|
||||||
stt: t.stt,
|
const picker = createChatPicker();
|
||||||
track_edits_deletes: t.track_edits_deletes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaults = $derived(
|
const defaults = $derived(
|
||||||
DEFAULT_SCOPES.map((d) => ({
|
DEFAULT_ORDER.map((scope) =>
|
||||||
label: d.label,
|
rules.find((rule) => rule.scope_type === scope)
|
||||||
record: policies.find((p) => p.scope_type === d.scope),
|
).filter((rule): rule is PolicyRule => rule !== undefined)
|
||||||
})).filter((d): d is { label: string; record: PolicyRecord } =>
|
|
||||||
Boolean(d.record)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const folderPolicies = $derived(
|
const folderRules = $derived(
|
||||||
policies.filter((p) => p.scope_type === "folder")
|
rules
|
||||||
|
.filter((rule) => rule.scope_type === "folder")
|
||||||
|
.toSorted((a, b) => folderOrder(a.scope_id) - folderOrder(b.scope_id))
|
||||||
);
|
);
|
||||||
const chatPolicies = $derived(
|
const chatRules = $derived(
|
||||||
policies.filter((p) => p.scope_type === "chat")
|
rules
|
||||||
|
.filter((rule) => rule.scope_type === "chat")
|
||||||
|
.toSorted((a, b) => title(a).localeCompare(title(b), "ru"))
|
||||||
);
|
);
|
||||||
const availableFolders = $derived(
|
const freeFolders = $derived(
|
||||||
folders.filter(
|
folders.filter(
|
||||||
(f) => !folderPolicies.some((p) => p.scope_id === f.folder_id)
|
(folder) =>
|
||||||
|
!folderRules.some((rule) => rule.scope_id === folder.folder_id)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const picker = createChatPicker();
|
const freeChats = $derived(
|
||||||
const availableChats = $derived(
|
|
||||||
picker.results.filter(
|
picker.results.filter(
|
||||||
(c) => !chatPolicies.some((p) => p.scope_id === c.chat_id)
|
(chat) => !chatRules.some((rule) => rule.scope_id === chat.chat_id)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
function folderOrder(id: number | null): number {
|
||||||
picker.search(chatFilter);
|
return folders.find((folder) => folder.folder_id === id)?.order_index ?? 0;
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
chats.ensureMany(
|
|
||||||
chatPolicies
|
|
||||||
.map((policy) => policy.scope_id)
|
|
||||||
.filter((id): id is number => id !== null)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function folderTitle(id: number | null): string {
|
|
||||||
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chatTitle(id: number | null): string {
|
function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
|
||||||
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
|
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 {
|
||||||
|
if (draft?.scopeType !== "folder") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const slot = folderOrder(draft.scopeId);
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const saved = await updatePolicy(record.id, next);
|
[rules, folders] = await Promise.all([
|
||||||
policies = policies.map((p) => (p.id === record.id ? saved : p));
|
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;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не удалось сохранить политику");
|
failed = true;
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
function toggle(rule: PolicyRule) {
|
||||||
load(accounts.selectedId);
|
const key = keyOf(rule.scope_type, rule.scope_id);
|
||||||
|
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 card(
|
{#snippet lead(
|
||||||
record: PolicyRecord,
|
rule: PolicyRule
|
||||||
title: string,
|
|
||||||
onremove?: () => void
|
|
||||||
)}
|
)}
|
||||||
<div class="card">
|
{#if rule.scope_type === "chat" && rule.scope_id !== null}
|
||||||
<div class="card-head">
|
<Avatar
|
||||||
<span class="card-title">{title}</span>
|
avatar={{ id: rule.scope_id, kind: rule.scope_id > 0 ? "peer" : "chat" }}
|
||||||
{#if record.account_id === null}
|
colorKey={rule.scope_id}
|
||||||
<span class="shared">для всех аккаунтов</span>
|
hasAvatar={chats.byId(rule.scope_id)?.has_avatar ?? false}
|
||||||
{/if}
|
name={title(rule)}
|
||||||
{#if onremove && record.account_id !== null}
|
size={2.25}
|
||||||
<button
|
|
||||||
aria-label="Удалить оверрайд"
|
|
||||||
class="remove"
|
|
||||||
onclick={onremove}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Icon name="close" size="1.125rem" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<CaptureToggleList
|
|
||||||
onchange={(key, value) => toggle(record, key, value)}
|
|
||||||
toggles={record}
|
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="glyph">
|
||||||
|
<Icon name={DEFAULT_ICONS[rule.scope_type] ?? "folder"} size="1.5rem" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#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
|
||||||
|
aria-expanded={open}
|
||||||
|
class="main"
|
||||||
|
onclick={() => toggle(rule)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{@render lead(rule)}
|
||||||
|
<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>
|
||||||
|
{#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}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet pending(
|
||||||
|
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="editor">
|
<div class="policy">
|
||||||
|
{#if problem}
|
||||||
|
<p class="problem">{problem}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">По умолчанию</div>
|
<div class="section-title"><h3>По умолчанию</h3></div>
|
||||||
{#each defaults as item (item.record.id)}
|
{#each defaults as rule (rule.id)}
|
||||||
{@render card(item.record, item.label)}
|
{@render row(rule, true)}
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">Оверрайды папок</div>
|
<div class="section-title">
|
||||||
{#each folderPolicies as record (record.id)}
|
<h3>Папки</h3>
|
||||||
{@render card(record, folderTitle(record.scope_id), () =>
|
<span>Что сохраняют. Из подходящих папок побеждает первая.</span>
|
||||||
removeOverride(record)
|
</div>
|
||||||
)}
|
{#each folderRules as rule (rule.id)}
|
||||||
|
{#if draft && draftBefore(rule)}
|
||||||
|
{@render pending(draft)}
|
||||||
|
{/if}
|
||||||
|
{@render row(rule, false)}
|
||||||
{/each}
|
{/each}
|
||||||
{#if availableFolders.length > 0}
|
{#if draft && draftLast}
|
||||||
|
{@render pending(draft)}
|
||||||
|
{/if}
|
||||||
|
{#if freeFolders.length > 0}
|
||||||
<button
|
<button
|
||||||
|
aria-expanded={picking === "folder"}
|
||||||
class="add"
|
class="add"
|
||||||
onclick={() => {
|
onclick={() => togglePicker("folder")}
|
||||||
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 pickFolder}
|
{#if picking === "folder"}
|
||||||
<div class="picker">
|
<div class="picker">
|
||||||
{#each availableFolders as folder (folder.folder_id)}
|
{#each freeFolders as folder (folder.folder_id)}
|
||||||
<button
|
<button
|
||||||
class="pick-row"
|
class="pick"
|
||||||
onclick={() => addOverride("folder", folder.folder_id)}
|
onclick={() => start("folder", folder.folder_id, folder.title)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{folder.title}
|
{folder.title}
|
||||||
@@ -247,38 +375,50 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="section-title">Оверрайды чатов</div>
|
<div class="section-title">
|
||||||
{#each chatPolicies as record (record.id)}
|
<h3>Чаты</h3>
|
||||||
{@render card(record, chatTitle(record.scope_id), () =>
|
<span>Что меняют против папки или умолчания. Важнее папок.</span>
|
||||||
removeOverride(record)
|
</div>
|
||||||
)}
|
{#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={() => {
|
onclick={() => togglePicker("chat")}
|
||||||
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 pickChat}
|
{#if picking === "chat"}
|
||||||
<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 availableChats as chat (chat.chat_id)}
|
{#each freeChats as chat (chat.chat_id)}
|
||||||
<button
|
<button
|
||||||
class="pick-row"
|
class="pick"
|
||||||
onclick={() => addOverride("chat", chat.chat_id)}
|
onclick={() =>
|
||||||
|
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}
|
||||||
@@ -289,79 +429,211 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.state {
|
.state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
padding: 2rem 1rem;
|
||||||
|
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor {
|
.policy {
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
section {
|
.problem {
|
||||||
padding: 0.5rem 0;
|
margin: 0;
|
||||||
border-bottom: 1px solid var(--color-borders);
|
padding: 0.75rem 1rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
padding: 0.5rem 1rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
|
||||||
|
padding: 0.875rem 1rem 0.375rem;
|
||||||
|
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: var(--font-weight-medium);
|
line-height: 1.35;
|
||||||
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);
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove {
|
.rule.open {
|
||||||
cursor: pointer;
|
border-bottom: 1px solid var(--color-borders);
|
||||||
display: flex;
|
|
||||||
padding: 0.25rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
background-color: transparent;
|
|
||||||
|
|
||||||
&:hover {
|
.line {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.add {
|
.line {
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
padding-right: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
width: 100%;
|
.main {
|
||||||
padding: 0.625rem 1rem;
|
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;
|
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;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 2.75rem;
|
||||||
|
padding: 0 1rem 0 1.5rem;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background-color: transparent;
|
text-align: start;
|
||||||
|
|
||||||
&:hover {
|
background: none;
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,8 +649,10 @@
|
|||||||
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 {
|
||||||
@@ -387,8 +661,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pick-row {
|
.pick {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -396,15 +671,35 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
&:hover {
|
background: none;
|
||||||
background-color: var(--color-chat-hover);
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&: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>
|
||||||
@@ -43,10 +43,15 @@
|
|||||||
onclick={() => ui.openPanel("policy")}
|
onclick={() => ui.openPanel("policy")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="data"
|
icon="cloud-download"
|
||||||
label="Данные и хранилище"
|
label="Бэкфилл и задачи"
|
||||||
onclick={() => ui.openPanel("jobs")}
|
onclick={() => ui.openPanel("jobs")}
|
||||||
/>
|
/>
|
||||||
|
<SettingsItem
|
||||||
|
icon="data"
|
||||||
|
label="Хранилище"
|
||||||
|
onclick={() => ui.openPanel("storage")}
|
||||||
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="play-story"
|
icon="play-story"
|
||||||
label="Сторис"
|
label="Сторис"
|
||||||
|
|||||||
@@ -32,10 +32,14 @@
|
|||||||
|
|
||||||
let groups = $state<Group[]>([]);
|
let groups = $state<Group[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
const previews = $state<Record<number, string | null>>({});
|
const previews = $state<Record<string, 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[]>([]);
|
||||||
@@ -78,7 +82,7 @@
|
|||||||
kind: "peer" as const,
|
kind: "peer" as const,
|
||||||
name: peerName(peer) || `ID ${peerId}`,
|
name: peerName(peer) || `ID ${peerId}`,
|
||||||
peerId,
|
peerId,
|
||||||
stories,
|
stories: peerStories,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const chat = chatById.get(peerId);
|
const chat = chatById.get(peerId);
|
||||||
@@ -87,7 +91,7 @@
|
|||||||
kind: "chat" as const,
|
kind: "chat" as const,
|
||||||
name: chat?.title ?? `Chat ${peerId}`,
|
name: chat?.title ?? `Chat ${peerId}`,
|
||||||
peerId,
|
peerId,
|
||||||
stories,
|
stories: peerStories,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -106,7 +110,7 @@
|
|||||||
groups = [];
|
groups = [];
|
||||||
loading = false;
|
loading = false;
|
||||||
for (const key of Object.keys(previews)) {
|
for (const key of Object.keys(previews)) {
|
||||||
delete previews[Number(key)];
|
delete previews[key];
|
||||||
}
|
}
|
||||||
for (const key of Object.keys(expanded)) {
|
for (const key of Object.keys(expanded)) {
|
||||||
delete expanded[Number(key)];
|
delete expanded[Number(key)];
|
||||||
@@ -122,13 +126,14 @@
|
|||||||
let active = true;
|
let active = true;
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
for (const item of list) {
|
for (const item of list) {
|
||||||
if (!item.downloaded || previews[item.story_id] !== undefined) {
|
const key = previewKey(item);
|
||||||
|
if (!item.downloaded || previews[key] !== undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
previews[item.story_id] = null;
|
previews[key] = null;
|
||||||
loadStoryMedia(item.peer_id, item.story_id).then((url) => {
|
loadStoryMedia(item.peer_id, item.story_id).then((url) => {
|
||||||
if (active) {
|
if (active) {
|
||||||
previews[item.story_id] = url;
|
previews[key] = url;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -210,7 +215,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[item.story_id]}
|
preview={previews[previewKey(item)]}
|
||||||
story={item}
|
story={item}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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")];
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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} дн.`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ui } from "#lib/stores/ui.svelte.js";
|
||||||
|
|
||||||
|
export type StorageListing = "files" | "chats";
|
||||||
|
|
||||||
|
interface Resume {
|
||||||
|
count: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStorageUi() {
|
||||||
|
let listing = $state<StorageListing>("files");
|
||||||
|
let chatId = $state<number | null>(null);
|
||||||
|
let resume: Resume | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get chatId() {
|
||||||
|
return chatId;
|
||||||
|
},
|
||||||
|
get listing() {
|
||||||
|
return listing;
|
||||||
|
},
|
||||||
|
set listing(next: StorageListing) {
|
||||||
|
listing = next;
|
||||||
|
},
|
||||||
|
openChat(id: number, from: Resume) {
|
||||||
|
chatId = id;
|
||||||
|
resume = from;
|
||||||
|
ui.openPanel("storage-chat");
|
||||||
|
},
|
||||||
|
takeResume(): Resume | null {
|
||||||
|
const taken = resume;
|
||||||
|
resume = null;
|
||||||
|
return taken;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storageUi = createStorageUi();
|
||||||
@@ -14,7 +14,10 @@ export type RightPanel =
|
|||||||
| "watches"
|
| "watches"
|
||||||
| "alerts"
|
| "alerts"
|
||||||
| "shares"
|
| "shares"
|
||||||
| "bots";
|
| "bots"
|
||||||
|
| "storage"
|
||||||
|
| "storage-chat"
|
||||||
|
| "storage-trash";
|
||||||
|
|
||||||
export type LeftView = "main" | "settings";
|
export type LeftView = "main" | "settings";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
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,6 +133,20 @@
|
|||||||
|
|
||||||
--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,6 +3,7 @@
|
|||||||
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";
|
||||||
@@ -101,6 +102,8 @@
|
|||||||
{/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