Compare commits
18
Commits
945adcf879
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e21e99afae | ||
|
|
597c6e82dd | ||
|
|
9191ef9ad4 | ||
|
|
7697ba3e3c | ||
|
|
e3559da2a7 | ||
|
|
dcd6786ca5 | ||
|
|
32d58193f3 | ||
|
|
59d0e2c8ad | ||
|
|
850d39ad6f | ||
|
|
b8a38092eb | ||
|
|
2836dcb3a9 | ||
|
|
01cf01b629 | ||
|
|
7f124059be | ||
|
|
5eacabd101 | ||
|
|
0362777a51 | ||
|
|
dfc726976b | ||
|
|
cb01d7dff8 | ||
|
|
f646f29ff9 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from utils.read import storage as storage_read
|
|||||||
from utils.read.models import (
|
from utils.read.models import (
|
||||||
DEFAULT_LIMIT,
|
DEFAULT_LIMIT,
|
||||||
Page,
|
Page,
|
||||||
|
StorageChatView,
|
||||||
StorageFileView,
|
StorageFileView,
|
||||||
|
StorageScopeView,
|
||||||
StorageSummaryView,
|
StorageSummaryView,
|
||||||
TrashItemView,
|
TrashItemView,
|
||||||
TrashOutcome,
|
TrashOutcome,
|
||||||
@@ -30,7 +32,13 @@ class TrashRequest(BaseModel):
|
|||||||
|
|
||||||
class RestoreRequest(BaseModel):
|
class RestoreRequest(BaseModel):
|
||||||
account_id: int
|
account_id: int
|
||||||
keys: list[str]
|
keys: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScopeRequest(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
chat_id: int | None = None
|
||||||
|
folder_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class PurgeRequest(BaseModel):
|
class PurgeRequest(BaseModel):
|
||||||
@@ -40,9 +48,11 @@ class PurgeRequest(BaseModel):
|
|||||||
|
|
||||||
@router.get("/summary")
|
@router.get("/summary")
|
||||||
async def summary(
|
async def summary(
|
||||||
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: Annotated[int, Query()],
|
||||||
|
chat_id: Annotated[int | None, Query()] = None,
|
||||||
) -> StorageSummaryView:
|
) -> StorageSummaryView:
|
||||||
return await storage_read.get_summary(pool, account_id)
|
return await storage_read.get_summary(pool, account_id, chat_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files")
|
@router.get("/files")
|
||||||
@@ -50,18 +60,67 @@ async def files(
|
|||||||
pool: FromDishka[asyncpg.Pool],
|
pool: FromDishka[asyncpg.Pool],
|
||||||
account_id: Annotated[int, Query()],
|
account_id: Annotated[int, Query()],
|
||||||
group: Annotated[str | None, Query()] = None,
|
group: Annotated[str | None, Query()] = None,
|
||||||
|
chat_id: Annotated[int | None, Query()] = None,
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
offset: Annotated[int, Query()] = 0,
|
offset: Annotated[int, Query()] = 0,
|
||||||
) -> list[StorageFileView]:
|
) -> list[StorageFileView]:
|
||||||
page = Page(limit=limit, offset=offset)
|
page = Page(limit=limit, offset=offset)
|
||||||
return await storage_read.list_files(pool, account_id, group, page)
|
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")
|
@router.get("/trash")
|
||||||
async def trash_items(
|
async def trash_items(
|
||||||
pool: FromDishka[asyncpg.Pool], account_id: Annotated[int, Query()]
|
pool: FromDishka[asyncpg.Pool],
|
||||||
|
account_id: Annotated[int, Query()],
|
||||||
|
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||||
|
offset: Annotated[int, Query()] = 0,
|
||||||
) -> list[TrashItemView]:
|
) -> list[TrashItemView]:
|
||||||
return await storage_read.list_trash(pool, account_id)
|
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")
|
@router.post("/trash")
|
||||||
|
|||||||
@@ -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,8 +34,7 @@ _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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
try:
|
||||||
chat = await client.get_chat(owner_id)
|
chat = await client.get_chat(owner_id)
|
||||||
|
except UNKNOWN_OWNER:
|
||||||
|
return None
|
||||||
photo = chat.photo if chat else 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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -253,6 +253,13 @@ class StorageSummaryView(BaseModel):
|
|||||||
groups: list[StorageGroupView]
|
groups: list[StorageGroupView]
|
||||||
|
|
||||||
|
|
||||||
|
class StorageChatView(BaseModel):
|
||||||
|
chat_id: int
|
||||||
|
files: int
|
||||||
|
bytes: int
|
||||||
|
groups: list[StorageGroupView]
|
||||||
|
|
||||||
|
|
||||||
class StorageFileView(BaseModel):
|
class StorageFileView(BaseModel):
|
||||||
media_id: int
|
media_id: int
|
||||||
chat_id: int
|
chat_id: int
|
||||||
@@ -287,6 +294,14 @@ class TrashOutcome(BaseModel):
|
|||||||
blocked: list[int] = []
|
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
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from itertools import groupby
|
||||||
|
from operator import itemgetter
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.files import media_file_name
|
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 (
|
from utils.read.models import (
|
||||||
Page,
|
Page,
|
||||||
|
StorageChatView,
|
||||||
StorageFileView,
|
StorageFileView,
|
||||||
StorageGroupView,
|
StorageGroupView,
|
||||||
|
StorageScopeView,
|
||||||
StorageSummaryView,
|
StorageSummaryView,
|
||||||
TrashItemView,
|
TrashItemView,
|
||||||
)
|
)
|
||||||
@@ -31,6 +37,7 @@ WITH blobs AS (
|
|||||||
SELECT storage_key, max(file_size) AS size, min(kind) AS kind
|
SELECT storage_key, max(file_size) AS size, min(kind) AS kind
|
||||||
FROM media
|
FROM media
|
||||||
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
|
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
|
GROUP BY storage_key
|
||||||
)
|
)
|
||||||
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
|
SELECT kind, count(*) AS files, coalesce(sum(size), 0)::bigint AS bytes,
|
||||||
@@ -46,24 +53,49 @@ FROM media_trash WHERE account_id = $1
|
|||||||
|
|
||||||
_FILES = """
|
_FILES = """
|
||||||
WITH top AS MATERIALIZED (
|
WITH top AS MATERIALIZED (
|
||||||
SELECT storage_key, max(file_size) AS file_size, count(*) AS refs
|
SELECT storage_key, max(file_size) AS file_size
|
||||||
FROM media
|
FROM media
|
||||||
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
|
WHERE account_id = $1 AND downloaded AND storage_key IS NOT NULL
|
||||||
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
|
AND ($2::text[] IS NULL OR (kind = ANY($2)) = $3)
|
||||||
|
AND ($7::bigint IS NULL OR chat_id = $7)
|
||||||
GROUP BY storage_key
|
GROUP BY storage_key
|
||||||
ORDER BY 2 DESC NULLS LAST, 1
|
ORDER BY 2 DESC NULLS LAST, 1
|
||||||
LIMIT $4 OFFSET $5
|
LIMIT $4 OFFSET $5
|
||||||
)
|
)
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT t.storage_key, t.file_size, t.refs, m.id, m.chat_id, m.message_id,
|
SELECT t.storage_key, t.file_size, m.id, m.chat_id, m.message_id,
|
||||||
m.kind, m.mime, m.ttl_seconds,
|
m.kind, m.mime, m.ttl_seconds,
|
||||||
row_number() OVER (PARTITION BY t.storage_key ORDER BY m.id) AS n
|
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
|
FROM top t
|
||||||
JOIN media m ON m.storage_key = t.storage_key
|
JOIN media m ON m.storage_key = t.storage_key
|
||||||
AND m.account_id = $1 AND m.downloaded
|
AND m.account_id = $1 AND m.downloaded
|
||||||
) ranked
|
) ranked
|
||||||
WHERE n <= $6
|
WHERE n <= $6
|
||||||
ORDER BY file_size DESC NULLS LAST, storage_key, id
|
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 = """
|
_TRASH = """
|
||||||
@@ -71,6 +103,46 @@ SELECT storage_key, chat_id, message_id, kind, mime, file_name, file_size,
|
|||||||
trashed_at
|
trashed_at
|
||||||
FROM media_trash WHERE account_id = $1
|
FROM media_trash WHERE account_id = $1
|
||||||
ORDER BY file_size DESC NULLS LAST, storage_key
|
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 = """
|
_TRASH_REFS = """
|
||||||
@@ -100,9 +172,7 @@ def _group_filter(group: str | None) -> tuple[list[str] | None, bool]:
|
|||||||
return list(GROUP_KINDS.get(group, ())), True
|
return list(GROUP_KINDS.get(group, ())), True
|
||||||
|
|
||||||
|
|
||||||
async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView:
|
def _groups(rows: list[asyncpg.Record]) -> list[StorageGroupView]:
|
||||||
rows = await pool.fetch(_SUMMARY, account_id, HEAVY_BYTES)
|
|
||||||
trash = await pool.fetchrow(_TRASH_TOTAL, account_id)
|
|
||||||
groups = {
|
groups = {
|
||||||
name: StorageGroupView(group=name, files=0, bytes=0)
|
name: StorageGroupView(group=name, files=0, bytes=0)
|
||||||
for name in (*GROUP_KINDS, OTHER_GROUP)
|
for name in (*GROUP_KINDS, OTHER_GROUP)
|
||||||
@@ -111,6 +181,14 @@ async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView
|
|||||||
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
|
group = groups[_KIND_GROUP.get(row["kind"], OTHER_GROUP)]
|
||||||
group.files += row["files"]
|
group.files += row["files"]
|
||||||
group.bytes += row["bytes"]
|
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(
|
return StorageSummaryView(
|
||||||
files=sum(row["files"] for row in rows),
|
files=sum(row["files"] for row in rows),
|
||||||
bytes=sum(row["bytes"] for row in rows),
|
bytes=sum(row["bytes"] for row in rows),
|
||||||
@@ -119,10 +197,31 @@ async def get_summary(pool: asyncpg.Pool, account_id: int) -> StorageSummaryView
|
|||||||
heavy_threshold=HEAVY_BYTES,
|
heavy_threshold=HEAVY_BYTES,
|
||||||
trash_files=trash["files"],
|
trash_files=trash["files"],
|
||||||
trash_bytes=trash["bytes"],
|
trash_bytes=trash["bytes"],
|
||||||
groups=list(groups.values()),
|
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(
|
async def _messages(
|
||||||
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
|
pool: asyncpg.Pool, account_id: int, refs: list[asyncpg.Record]
|
||||||
) -> list[asyncpg.Record | None]:
|
) -> list[asyncpg.Record | None]:
|
||||||
@@ -146,18 +245,31 @@ def _alive(ref: asyncpg.Record, message: asyncpg.Record | None) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
async def list_files(
|
async def list_files(
|
||||||
pool: asyncpg.Pool, account_id: int, group: str | None, page: Page
|
pool: asyncpg.Pool,
|
||||||
|
account_id: int,
|
||||||
|
group: str | None,
|
||||||
|
page: Page,
|
||||||
|
chat_id: int | None = None,
|
||||||
) -> list[StorageFileView]:
|
) -> list[StorageFileView]:
|
||||||
kinds, include = _group_filter(group)
|
kinds, include = _group_filter(group)
|
||||||
refs = await pool.fetch(
|
refs = await pool.fetch(
|
||||||
_FILES, account_id, kinds, include, page.capped_limit, page.offset, REFS_CHECKED
|
_FILES,
|
||||||
|
account_id,
|
||||||
|
kinds,
|
||||||
|
include,
|
||||||
|
page.capped_limit,
|
||||||
|
page.offset,
|
||||||
|
REFS_CHECKED,
|
||||||
|
chat_id,
|
||||||
)
|
)
|
||||||
messages = await _messages(pool, account_id, refs)
|
messages = await _messages(pool, account_id, refs)
|
||||||
files: dict[str, StorageFileView] = {}
|
files: dict[str, StorageFileView] = {}
|
||||||
for ref, message in zip(refs, messages, strict=True):
|
for ref, message in zip(refs, messages, strict=True):
|
||||||
alive = _alive(ref, message)
|
alive = _alive(ref, message)
|
||||||
known = files.get(ref["storage_key"])
|
known = files.get(ref["storage_key"])
|
||||||
if known is not None and (known.refetchable or not alive):
|
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
|
continue
|
||||||
files[ref["storage_key"]] = StorageFileView(
|
files[ref["storage_key"]] = StorageFileView(
|
||||||
media_id=ref["id"],
|
media_id=ref["id"],
|
||||||
@@ -179,20 +291,26 @@ async def list_files(
|
|||||||
return list(files.values())
|
return list(files.values())
|
||||||
|
|
||||||
|
|
||||||
async def list_trash(pool: asyncpg.Pool, account_id: int) -> list[TrashItemView]:
|
async def list_trash(
|
||||||
rows = await pool.fetch(_TRASH, account_id)
|
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(
|
refs = await pool.fetch(
|
||||||
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
|
_TRASH_REFS, account_id, [row["storage_key"] for row in rows], REFS_CHECKED
|
||||||
)
|
)
|
||||||
messages = await _messages(pool, account_id, refs)
|
messages = await _messages(pool, account_id, refs)
|
||||||
alive: dict[str, bool] = defaultdict(bool)
|
alive: dict[str, bool] = defaultdict(bool)
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
|
names: dict[str, str] = {}
|
||||||
for ref, message in zip(refs, messages, strict=True):
|
for ref, message in zip(refs, messages, strict=True):
|
||||||
alive[ref["storage_key"]] |= _alive(ref, message)
|
key = ref["storage_key"]
|
||||||
counts[ref["storage_key"]] = ref["refs"]
|
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 [
|
return [
|
||||||
TrashItemView(
|
TrashItemView(
|
||||||
**dict(row),
|
**{**dict(row), "file_name": _trash_name(row, names)},
|
||||||
purge_at=row["trashed_at"] + RETENTION,
|
purge_at=row["trashed_at"] + RETENTION,
|
||||||
refs=counts.get(row["storage_key"], 0),
|
refs=counts.get(row["storage_key"], 0),
|
||||||
refetchable=alive.get(row["storage_key"], True),
|
refetchable=alive.get(row["storage_key"], True),
|
||||||
@@ -201,6 +319,40 @@ async def list_trash(pool: asyncpg.Pool, account_id: int) -> list[TrashItemView]
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
async def get_trash_item(pool: asyncpg.Pool, storage_key: str) -> asyncpg.Record | None:
|
||||||
return await pool.fetchrow(
|
return await pool.fetchrow(
|
||||||
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
|
"SELECT kind, mime, file_name FROM media_trash WHERE storage_key = $1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
|
from utils.files import media_file_name
|
||||||
from utils.read.media import get_media
|
from utils.read.media import get_media
|
||||||
from utils.read.models import TrashOutcome
|
from utils.read.models import TrashOutcome
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -12,6 +13,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
RETENTION = timedelta(days=7)
|
RETENTION = timedelta(days=7)
|
||||||
PURGE_INTERVAL_SECONDS = 3600
|
PURGE_INTERVAL_SECONDS = 3600
|
||||||
|
BATCH = 200
|
||||||
|
|
||||||
_SHARED_ELSEWHERE = """
|
_SHARED_ELSEWHERE = """
|
||||||
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
|
SELECT EXISTS (SELECT 1 FROM stories WHERE storage_key = $1)
|
||||||
@@ -47,6 +49,12 @@ _FORGET_KEYS = """
|
|||||||
UPDATE media SET evicted_key = NULL WHERE evicted_key = ANY($1::text[])
|
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(
|
async def move_to_trash(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
@@ -86,12 +94,65 @@ async def move_to_trash(
|
|||||||
return outcome
|
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(
|
async def restore(
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
storage: ContentAddressedStorage,
|
storage: ContentAddressedStorage,
|
||||||
account_id: int,
|
account_id: int,
|
||||||
keys: list[str],
|
keys: list[str] | None,
|
||||||
) -> TrashOutcome:
|
) -> 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()
|
outcome = TrashOutcome()
|
||||||
for key in keys:
|
for key in keys:
|
||||||
async with pool.acquire() as conn, conn.transaction():
|
async with pool.acquire() as conn, conn.transaction():
|
||||||
|
|||||||
Generated
+29
-18
@@ -21,16 +21,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alembic"
|
name = "alembic"
|
||||||
version = "1.20.0"
|
version = "1.20.1"
|
||||||
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/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/93/1c/00909e0fbebad2121d916fdcab044cae988146680e9ee4387ba25fef8747/alembic-1.20.1.tar.gz", hash = "sha256:83490def1f723413db5096591f7609ac52544805be93849170088ed04f5a2e0b", size = 2103576, upload-time = "2026-10-22T03:24:35.93Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/11/3c/8740f5b4e1bd57333e4f84265b9b6363da4f7cf70f5622dd1e2fde7a0bd5/alembic-1.20.1-py3-none-any.whl", hash = "sha256:9970712cfb81ea26c8658d378aaa5b8f6ff9146e5bb111d70497c164016cf598", size = 270355, upload-time = "2026-10-22T03:24:37.667Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -226,6 +226,15 @@ 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.10.19"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cffi"
|
name = "cffi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -411,7 +420,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.143.0"
|
version = "0.159.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -421,26 +430,26 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/40/a212bffb9809afb9a94b250f11e0c29fd8b2589ec92e4bedd1cc7adf9938/fastapi-0.159.1.tar.gz", hash = "sha256:b45b1ec57f3aa4d2f80f09bfbcf068b46764a2ee892e56753f35bb3d9d845ce2", size = 522004, upload-time = "2026-10-21T20:59:29.054Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/8d/35/b03b5df04a1a8ee400c9df4b1795af84370b2bb6f1cc4be57c0b79688d00/fastapi-0.159.1-py3-none-any.whl", hash = "sha256:49b94c29afec2fca1390e9c39be202c59755d5cda8e4f1148a133761e4313e7e", size = 149604, upload-time = "2026-10-21T20:59:27.58Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp"
|
name = "fastmcp"
|
||||||
version = "4.1.0"
|
version = "4.1.3"
|
||||||
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/c3/c4/fe4e24af2cb03f4701f45634873d87557c2b5d283d287412345345ad0ede/fastmcp-4.1.0.tar.gz", hash = "sha256:7a8bf4e58cc6c2f3a8552b5a7a17ba1e4682b6fd4cafb7f461920a66dbd2499f", size = 42686511, upload-time = "2026-10-08T22:57:05.652Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ea/f4/79169f9a2419e1f541b3b39b1bda1c57569b4c7c1ac4b606750c81471ad1/fastmcp-4.1.3.tar.gz", hash = "sha256:a1f79e9db929a1603c75331df603f74530fc236aa711e0ee2e185821c6d9888d", size = 42755717, upload-time = "2026-10-20T22:32:28.005Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/97/7f/27c501d98b5681ca4f6c21a7d741b3e732e962c5e4c3b4db6dd1ffdf4fd8/fastmcp-4.1.3-py3-none-any.whl", hash = "sha256:b983d8acc44ab1143e0c86fd6da843f25043ee0aeb64ab983b36c527bd1d33e9", size = 8153, upload-time = "2026-10-20T22:32:22.881Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp-slim"
|
name = "fastmcp-slim"
|
||||||
version = "4.1.0"
|
version = "4.1.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mcp-types" },
|
{ name = "mcp-types" },
|
||||||
@@ -451,9 +460,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/81/86/01705c74106297c021e5f8a22ed051adb43061cc779cf543dc05a4d47aba/fastmcp_slim-4.1.3.tar.gz", hash = "sha256:d95277db0979e4cbfa84528cf9016ae079494b41ed8e88cc909fee8233db6035", size = 749467, upload-time = "2026-10-20T22:30:58.047Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/4e/ff/a7feaeb130a2003700789ce32e933b4c2ae00fc7c264642e57ab12e4684f/fastmcp_slim-4.1.3-py3-none-any.whl", hash = "sha256:81af8699331c33d438caf85b6947f36df6e2f04118e6f318ca00374fd9afea9a", size = 931046, upload-time = "2026-10-20T22:30:56.182Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -470,6 +479,7 @@ client = [
|
|||||||
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 = "beartype", version = "0.23.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
|
{ name = "certifi" },
|
||||||
{ name = "cyclopts" },
|
{ name = "cyclopts" },
|
||||||
{ name = "exceptiongroup" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "griffelib" },
|
{ name = "griffelib" },
|
||||||
@@ -912,14 +922,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openapi-pydantic"
|
name = "openapi-pydantic"
|
||||||
version = "0.5.1"
|
version = "0.6.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/2b/32/0c9bd3e4e847cd6117b64dbef4cf810faa9cdbd6689323b811569cc8a1b8/openapi_pydantic-0.6.0.tar.gz", hash = "sha256:11f3ac6ad41521fc156381ec587246b0c0ecea523bd9565ed74d3e7fac9d91cd", size = 1667049, upload-time = "2026-10-02T12:20:00.508Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/57/9d3eb823a03c33e9d4c810e6a3276e3a6dc820a7fdc0961db2c04e613817/openapi_pydantic-0.6.0-py3-none-any.whl", hash = "sha256:8bd6d548a250da37db96f461bcd52b3468e8834ad7d6db14b00a6b2ebbd163a3", size = 96931, upload-time = "2026-10-02T12:19:58.917Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1080,16 +1090,17 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-settings"
|
name = "pydantic-settings"
|
||||||
version = "2.15.0"
|
version = "2.16.1"
|
||||||
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-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/05/ca/7817b4bf0c4b15c6a6ab8cdf4cb96c268335a8ca2f525de93b6ebe4c7d28/pydantic_settings-2.16.1.tar.gz", hash = "sha256:aff602a1886338c2df3d1e958e2193a8dc33cec75b91f202e83fd98484b4b0da", size = 303436, upload-time = "2026-10-20T11:37:19.024Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/3c/74/fdf926afe145d8629c4b2ff57f4ea1d64ed7abc4d84d97463b2517a2547e/pydantic_settings-2.16.1-py3-none-any.whl", hash = "sha256:30f5313340da4e8f1b440d1910b92a5bc3ccbb6b4ba70ec2b8395bc921f418f5", size = 80771, upload-time = "2026-10-20T11:37:17.298Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -26,13 +26,18 @@ import type {
|
|||||||
PolicyChatKind,
|
PolicyChatKind,
|
||||||
PolicyCreate,
|
PolicyCreate,
|
||||||
PolicyRecord,
|
PolicyRecord,
|
||||||
|
PolicyRule,
|
||||||
|
PolicyScope,
|
||||||
PresenceHourly,
|
PresenceHourly,
|
||||||
PresenceSample,
|
PresenceSample,
|
||||||
ReactionView,
|
ReactionView,
|
||||||
ResponseStats,
|
ResponseStats,
|
||||||
|
ScopeTarget,
|
||||||
SearchHit,
|
SearchHit,
|
||||||
|
StorageChat,
|
||||||
StorageFile,
|
StorageFile,
|
||||||
StorageGroupName,
|
StorageGroupName,
|
||||||
|
StorageScope,
|
||||||
StorageSummary,
|
StorageSummary,
|
||||||
StoryView,
|
StoryView,
|
||||||
TrashItem,
|
TrashItem,
|
||||||
@@ -148,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;
|
||||||
@@ -587,22 +609,55 @@ export function removeWatchedBot(botId: number): Promise<void> {
|
|||||||
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageSummary(): Promise<StorageSummary> {
|
export function getStorageSummary(
|
||||||
return request<StorageSummary>("/storage/summary", { account: true });
|
chatId: number | null = null
|
||||||
|
): Promise<StorageSummary> {
|
||||||
|
return request<StorageSummary>("/storage/summary", {
|
||||||
|
account: true,
|
||||||
|
query: { chat_id: chatId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStorageFiles(
|
export function getStorageFiles(
|
||||||
group: StorageGroupName | null,
|
group: StorageGroupName | null,
|
||||||
page: Page = {}
|
page: Page = {},
|
||||||
|
chatId: number | null = null
|
||||||
): Promise<StorageFile[]> {
|
): Promise<StorageFile[]> {
|
||||||
return request<StorageFile[]>("/storage/files", {
|
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,
|
account: true,
|
||||||
query: { group, ...page },
|
query: { group, ...page },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTrash(): Promise<TrashItem[]> {
|
export function getTrash(page: Page = {}): Promise<TrashItem[]> {
|
||||||
return request<TrashItem[]>("/storage/trash", { account: true });
|
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> {
|
export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
|
||||||
@@ -612,7 +667,7 @@ export function moveToTrash(mediaIds: number[]): Promise<TrashOutcome> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreFromTrash(keys: string[]): Promise<TrashOutcome> {
|
export function restoreFromTrash(keys: string[] | null): Promise<TrashOutcome> {
|
||||||
return request<TrashOutcome>("/storage/trash/restore", {
|
return request<TrashOutcome>("/storage/trash/restore", {
|
||||||
body: { account_id: accounts.selectedId, keys },
|
body: { account_id: accounts.selectedId, keys },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -580,6 +598,13 @@ export interface StorageSummary {
|
|||||||
trash_files: number;
|
trash_files: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageChat {
|
||||||
|
bytes: number;
|
||||||
|
chat_id: number;
|
||||||
|
files: number;
|
||||||
|
groups: StorageGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageFile {
|
export interface StorageFile {
|
||||||
chat_id: number;
|
chat_id: number;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
@@ -613,3 +638,13 @@ export interface TrashOutcome {
|
|||||||
files: number;
|
files: number;
|
||||||
keys: string[];
|
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)}
|
||||||
|
{#if tab.id === null}
|
||||||
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
|
<button class="tab" onclick={() => folders.select(tab.id)} type="button">
|
||||||
{tab.title}
|
{tab.title}
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<ContextMenu>
|
||||||
|
{#snippet children({
|
||||||
|
props,
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
class="tab"
|
||||||
|
onclick={() => folders.select(tab.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet menu()}
|
||||||
|
<ContextMenuItem icon="settings" onselect={() => editPolicy(tab)}>
|
||||||
|
Политика захвата
|
||||||
|
</ContextMenuItem>
|
||||||
|
{/snippet}
|
||||||
|
</ContextMenu>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
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 { 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> = {
|
||||||
@@ -35,6 +38,7 @@
|
|||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
shares: "Файлы по ссылке",
|
shares: "Файлы по ссылке",
|
||||||
storage: "Хранилище",
|
storage: "Хранилище",
|
||||||
|
"storage-chat": "Файлы чата",
|
||||||
"storage-trash": "Корзина",
|
"storage-trash": "Корзина",
|
||||||
stories: "Сторис",
|
stories: "Сторис",
|
||||||
"stories-all": "Все сторис",
|
"stories-all": "Все сторис",
|
||||||
@@ -43,10 +47,21 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
const parents: Partial<Record<RightPanel, RightPanel>> = {
|
||||||
|
"storage-chat": "storage",
|
||||||
"storage-trash": "storage",
|
"storage-trash": "storage",
|
||||||
};
|
};
|
||||||
|
|
||||||
const parent = $derived(ui.rightPanel ? parents[ui.rightPanel] : undefined);
|
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">
|
||||||
@@ -71,14 +86,16 @@
|
|||||||
<Icon name="close" />
|
<Icon name="close" />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<h2>{ui.rightPanel ? titles[ui.rightPanel] : ""}</h2>
|
<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"}
|
||||||
|
{#key `${accounts.selectedId}:${policyUi.revision}`}
|
||||||
<PolicyEditor />
|
<PolicyEditor />
|
||||||
|
{/key}
|
||||||
{:else if ui.rightPanel === "jobs"}
|
{:else if ui.rightPanel === "jobs"}
|
||||||
<JobsPanel />
|
<JobsPanel />
|
||||||
{:else if ui.rightPanel === "presence"}
|
{:else if ui.rightPanel === "presence"}
|
||||||
@@ -109,6 +126,10 @@
|
|||||||
{#key accounts.selectedId}
|
{#key accounts.selectedId}
|
||||||
<StoragePanel />
|
<StoragePanel />
|
||||||
{/key}
|
{/key}
|
||||||
|
{:else if ui.rightPanel === "storage-chat"}
|
||||||
|
{#key `${accounts.selectedId}:${storageUi.chatId}`}
|
||||||
|
<StoragePanel chatId={storageUi.chatId} />
|
||||||
|
{/key}
|
||||||
{:else if ui.rightPanel === "storage-trash"}
|
{:else if ui.rightPanel === "storage-trash"}
|
||||||
{#key accounts.selectedId}
|
{#key accounts.selectedId}
|
||||||
<TrashPanel />
|
<TrashPanel />
|
||||||
@@ -128,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,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>
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
import { mediaUrl } from "#lib/api/client.js";
|
import { mediaUrl } from "#lib/api/client.js";
|
||||||
import { downloadMedia } from "#lib/api/download.js";
|
import { downloadMedia } from "#lib/api/download.js";
|
||||||
import {
|
import {
|
||||||
|
getStorageChats,
|
||||||
getStorageFiles,
|
getStorageFiles,
|
||||||
getStorageSummary,
|
getStorageSummary,
|
||||||
moveToTrash,
|
moveToTrash,
|
||||||
@@ -11,12 +12,15 @@
|
|||||||
} from "#lib/api/endpoints.js";
|
} from "#lib/api/endpoints.js";
|
||||||
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
|
import { forgetMedia, type ViewerItem } from "#lib/api/media.js";
|
||||||
import type {
|
import type {
|
||||||
|
StorageChat,
|
||||||
StorageFile,
|
StorageFile,
|
||||||
StorageGroupName,
|
StorageGroupName,
|
||||||
StorageSummary,
|
StorageSummary,
|
||||||
TrashOutcome,
|
TrashOutcome,
|
||||||
} from "#lib/api/types.js";
|
} from "#lib/api/types.js";
|
||||||
|
import ScopeFiles from "#lib/components/disk/ScopeFiles.svelte";
|
||||||
import SelectionBar from "#lib/components/disk/SelectionBar.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 StorageRow from "#lib/components/disk/StorageRow.svelte";
|
||||||
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
import MediaViewer from "#lib/components/MediaViewer.svelte";
|
||||||
import Button from "#lib/components/ui/Button.svelte";
|
import Button from "#lib/components/ui/Button.svelte";
|
||||||
@@ -31,16 +35,33 @@
|
|||||||
messagesLabel,
|
messagesLabel,
|
||||||
} from "#lib/format/storage.js";
|
} from "#lib/format/storage.js";
|
||||||
import { chats } from "#lib/stores/chats.svelte.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 { ui } from "#lib/stores/ui.svelte.js";
|
||||||
import { isMobile } from "#lib/viewport.js";
|
import { isMobile } from "#lib/viewport.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
chatId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chatId = null }: Props = $props();
|
||||||
|
|
||||||
const PAGE = 30;
|
const PAGE = 30;
|
||||||
const UNDO_MS = 12_000;
|
const UNDO_MS = 12_000;
|
||||||
const UNDO_GUARD_MS = 600;
|
const UNDO_GUARD_MS = 600;
|
||||||
|
const LISTINGS: { label: string; value: StorageListing }[] = [
|
||||||
|
{ label: "Файлы", value: "files" },
|
||||||
|
{ label: "Чаты", value: "chats" },
|
||||||
|
];
|
||||||
|
|
||||||
let summary = $state<StorageSummary | null>(null);
|
let summary = $state<StorageSummary | null>(null);
|
||||||
let files = $state<StorageFile[]>([]);
|
let files = $state<StorageFile[]>([]);
|
||||||
|
let heavyChats = $state<StorageChat[]>([]);
|
||||||
|
let scope = $state<ReturnType<typeof ScopeFiles>>();
|
||||||
let group = $state<StorageGroupName | null>(null);
|
let group = $state<StorageGroupName | null>(null);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
@@ -54,10 +75,26 @@
|
|||||||
let viewerIndex = $state(0);
|
let viewerIndex = $state(0);
|
||||||
const selected = new SvelteSet<number>();
|
const selected = new SvelteSet<number>();
|
||||||
let token = 0;
|
let token = 0;
|
||||||
|
let scroller: HTMLElement | null = null;
|
||||||
let undoTimer: ReturnType<typeof setTimeout> | undefined;
|
let undoTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let undoShownAt = 0;
|
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 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 chosen = $derived(files.filter((file) => selected.has(file.media_id)));
|
||||||
const chosenBytes = $derived(
|
const chosenBytes = $derived(
|
||||||
chosen.reduce((sum, file) => sum + file.file_size, 0)
|
chosen.reduce((sum, file) => sum + file.file_size, 0)
|
||||||
@@ -82,19 +119,43 @@
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
function chatTitle(chatId: number): string {
|
function chatTitle(id: number): string {
|
||||||
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
return chats.byId(id)?.title ?? `Чат ${id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSummary() {
|
async function loadSummary() {
|
||||||
try {
|
try {
|
||||||
summary = await getStorageSummary();
|
summary = await getStorageSummary(chatId);
|
||||||
} catch {
|
} catch {
|
||||||
failed = true;
|
failed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
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) {
|
if (loading || done) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -102,16 +163,10 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
failed = false;
|
failed = false;
|
||||||
try {
|
try {
|
||||||
const batch = await getStorageFiles(group, {
|
const loaded = await loadPage(current, limit);
|
||||||
limit: PAGE,
|
if (current === token) {
|
||||||
offset: files.length,
|
done = loaded < limit;
|
||||||
});
|
|
||||||
if (current !== token) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
chats.ensureMany(batch.map((file) => file.chat_id));
|
|
||||||
files = [...files, ...batch];
|
|
||||||
done = batch.length < PAGE;
|
|
||||||
} catch {
|
} catch {
|
||||||
if (current === token) {
|
if (current === token) {
|
||||||
failed = true;
|
failed = true;
|
||||||
@@ -123,14 +178,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reload() {
|
function reload(limit = PAGE) {
|
||||||
token += 1;
|
token += 1;
|
||||||
files = [];
|
files = [];
|
||||||
|
heavyChats = [];
|
||||||
done = false;
|
done = false;
|
||||||
loading = false;
|
loading = false;
|
||||||
selected.clear();
|
selected.clear();
|
||||||
loadSummary();
|
return Promise.all([loadSummary(), loadMore(limit)]);
|
||||||
loadMore();
|
}
|
||||||
|
|
||||||
|
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) {
|
function toggle(mediaId: number) {
|
||||||
@@ -171,6 +246,7 @@
|
|||||||
}
|
}
|
||||||
files = files.filter((file) => !moved.has(file.media_id));
|
files = files.filter((file) => !moved.has(file.media_id));
|
||||||
selected.clear();
|
selected.clear();
|
||||||
|
scope?.refresh();
|
||||||
showUndo(outcome, trashed);
|
showUndo(outcome, trashed);
|
||||||
if (outcome.blocked.length > 0) {
|
if (outcome.blocked.length > 0) {
|
||||||
problem = "Файл занят в сторис или аватаре и остался на диске";
|
problem = "Файл занят в сторис или аватаре и остался на диске";
|
||||||
@@ -194,6 +270,7 @@
|
|||||||
files = [...files, ...restored.files].sort(
|
files = [...files, ...restored.files].sort(
|
||||||
(a, b) => b.file_size - a.file_size
|
(a, b) => b.file_size - a.file_size
|
||||||
);
|
);
|
||||||
|
scope?.refresh();
|
||||||
await loadSummary();
|
await loadSummary();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось вернуть файлы";
|
problem = "Не удалось вернуть файлы";
|
||||||
@@ -227,24 +304,45 @@
|
|||||||
reload();
|
reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(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));
|
onDestroy(() => clearTimeout(undoTimer));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="storage">
|
<div class="storage" {@attach hold}>
|
||||||
{#if summary}
|
{#if summary}
|
||||||
{#if summary.files > 0}
|
{#if summary.files > 0 || chatId !== null}
|
||||||
<section class="summary">
|
<section class="summary">
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<span class="total">{formatBytes(summary.bytes)}</span>
|
<span class="total">{formatBytes(summary.bytes) || "0 B"}</span>
|
||||||
<span class="count">
|
<span class="count">
|
||||||
{filesLabel(summary.files)}
|
{filesLabel(summary.files)}
|
||||||
{summary.trash_bytes > 0
|
{#if chatId !== null}
|
||||||
? `· ещё ${formatBytes(summary.trash_bytes)} в корзине`
|
в этом чате
|
||||||
: "на диске"}
|
{:else if summary.trash_bytes > 0}
|
||||||
|
· ещё {formatBytes(summary.trash_bytes)} в корзине
|
||||||
|
{:else}
|
||||||
|
на диске
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack">
|
<div class="stack" class:bare={groups.length === 0}>
|
||||||
{#each groups as item (item.group)}
|
{#each groups as item (item.group)}
|
||||||
<span
|
<span
|
||||||
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
|
title="{GROUP_LABELS[item.group]}: {formatBytes(item.bytes)}"
|
||||||
@@ -252,7 +350,7 @@
|
|||||||
style:flex-grow={item.bytes}
|
style:flex-grow={item.bytes}
|
||||||
></span>
|
></span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if summary.trash_bytes > 0}
|
{#if chatId === null && summary.trash_bytes > 0}
|
||||||
<span
|
<span
|
||||||
class="trashed"
|
class="trashed"
|
||||||
title="Корзина: {formatBytes(summary.trash_bytes)}"
|
title="Корзина: {formatBytes(summary.trash_bytes)}"
|
||||||
@@ -281,6 +379,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if chatId === null}
|
||||||
<button
|
<button
|
||||||
class="trash-link"
|
class="trash-link"
|
||||||
onclick={() => ui.openPanel("storage-trash")}
|
onclick={() => ui.openPanel("storage-trash")}
|
||||||
@@ -299,9 +398,33 @@
|
|||||||
</span>
|
</span>
|
||||||
<Icon name="next" size="1.125rem" />
|
<Icon name="next" size="1.125rem" />
|
||||||
</button>
|
</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}
|
||||||
|
|
||||||
{#if files.length > 0}
|
{#if listing === "files" && files.length > 0}
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h3>Самые тяжёлые</h3>
|
<h3>Самые тяжёлые</h3>
|
||||||
{#if summary && group === null && summary.heavy_files > 0}
|
{#if summary && group === null && summary.heavy_files > 0}
|
||||||
@@ -314,7 +437,33 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="list">
|
<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)}
|
{#each files as file, index (file.media_id)}
|
||||||
<StorageRow
|
<StorageRow
|
||||||
fileName={file.file_name}
|
fileName={file.file_name}
|
||||||
@@ -327,8 +476,10 @@
|
|||||||
selected={selected.has(file.media_id)}
|
selected={selected.has(file.media_id)}
|
||||||
size={file.file_size}
|
size={file.file_size}
|
||||||
src={mediaUrl(file.media_id)}
|
src={mediaUrl(file.media_id)}
|
||||||
sub={chatTitle(file.chat_id)}
|
sub={chatId === null
|
||||||
tail={formatListDate(file.date)}
|
? chatTitle(file.chat_id)
|
||||||
|
: formatListDate(file.date)}
|
||||||
|
tail={chatId === null ? formatListDate(file.date) : undefined}
|
||||||
weight={heaviest > 0 ? file.file_size / heaviest : 0}
|
weight={heaviest > 0 ? file.file_size / heaviest : 0}
|
||||||
>
|
>
|
||||||
{#snippet menu()}
|
{#snippet menu()}
|
||||||
@@ -356,18 +507,14 @@
|
|||||||
{:else if failed}
|
{:else if failed}
|
||||||
<div class="state">
|
<div class="state">
|
||||||
<p>Не удалось загрузить хранилище.</p>
|
<p>Не удалось загрузить хранилище.</p>
|
||||||
<Button onclick={reload} smaller variant="text">Повторить</Button>
|
<Button onclick={() => reload()} smaller variant="text">
|
||||||
</div>
|
Повторить
|
||||||
{:else if files.length === 0}
|
</Button>
|
||||||
<div class="state">
|
|
||||||
<p>
|
|
||||||
{group === null
|
|
||||||
? "На диске нет скачанных файлов."
|
|
||||||
: "В этой группе нет файлов."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else if shown === 0}
|
||||||
|
<div class="state"><p>{emptiness}</p></div>
|
||||||
{:else if !done}
|
{:else if !done}
|
||||||
<button class="more" onclick={loadMore} type="button">
|
<button class="more" onclick={() => loadMore()} type="button">
|
||||||
Показать ещё
|
Показать ещё
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -470,6 +617,11 @@
|
|||||||
height: 0.625rem;
|
height: 0.625rem;
|
||||||
margin-top: 0.875rem;
|
margin-top: 0.875rem;
|
||||||
|
|
||||||
|
&.bare {
|
||||||
|
border-radius: 0.3125rem;
|
||||||
|
background-color: rgba(var(--color-text-secondary-rgb), 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
min-width: 0.1875rem;
|
min-width: 0.1875rem;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
@@ -615,6 +767,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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 {
|
.section-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -639,6 +832,10 @@
|
|||||||
.list {
|
.list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|
||||||
|
&.chats {
|
||||||
|
padding-top: 0.375rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { SvelteSet } from "svelte/reactivity";
|
import { SvelteSet } from "svelte/reactivity";
|
||||||
import { trashedUrl } from "#lib/api/client.js";
|
import { trashedUrl } from "#lib/api/client.js";
|
||||||
import {
|
import {
|
||||||
|
getStorageSummary,
|
||||||
getTrash,
|
getTrash,
|
||||||
purgeTrash,
|
purgeTrash,
|
||||||
restoreFromTrash,
|
restoreFromTrash,
|
||||||
@@ -29,7 +30,12 @@
|
|||||||
import { isMobile } from "#lib/viewport.js";
|
import { isMobile } from "#lib/viewport.js";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
const PAGE = 30;
|
||||||
|
|
||||||
let items = $state<TrashItem[]>([]);
|
let items = $state<TrashItem[]>([]);
|
||||||
|
let total = $state({ bytes: 0, files: 0 });
|
||||||
|
let done = $state(false);
|
||||||
|
let everything = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
@@ -42,7 +48,7 @@
|
|||||||
const heaviest = $derived(
|
const heaviest = $derived(
|
||||||
Math.max(0, ...items.map((item) => item.file_size ?? 0))
|
Math.max(0, ...items.map((item) => item.file_size ?? 0))
|
||||||
);
|
);
|
||||||
const totalBytes = $derived(bytesOf(items));
|
const dialogOpen = $derived(doomed !== null || everything);
|
||||||
const chosen = $derived(
|
const chosen = $derived(
|
||||||
items.filter((item) => selected.has(item.storage_key))
|
items.filter((item) => selected.has(item.storage_key))
|
||||||
);
|
);
|
||||||
@@ -74,12 +80,19 @@
|
|||||||
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
return chats.byId(chatId)?.title ?? `Чат ${chatId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function loadTotal() {
|
||||||
|
const summary = await getStorageSummary();
|
||||||
|
total = { bytes: summary.trash_bytes, files: summary.trash_files };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
loading = true;
|
loading = true;
|
||||||
failed = false;
|
failed = false;
|
||||||
try {
|
try {
|
||||||
items = await getTrash();
|
const batch = await getTrash({ limit: PAGE, offset: items.length });
|
||||||
chats.ensureMany(items.map((item) => item.chat_id));
|
chats.ensureMany(batch.map((item) => item.chat_id));
|
||||||
|
items = [...items, ...batch];
|
||||||
|
done = batch.length < PAGE;
|
||||||
} catch {
|
} catch {
|
||||||
failed = true;
|
failed = true;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -87,6 +100,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
items = [];
|
||||||
|
done = false;
|
||||||
|
selected.clear();
|
||||||
|
await Promise.all([loadMore(), loadTotal().catch(() => undefined)]);
|
||||||
|
}
|
||||||
|
|
||||||
function toggle(key: string) {
|
function toggle(key: string) {
|
||||||
problem = null;
|
problem = null;
|
||||||
if (!selected.delete(key)) {
|
if (!selected.delete(key)) {
|
||||||
@@ -98,6 +118,7 @@
|
|||||||
const gone = new Set(keys);
|
const gone = new Set(keys);
|
||||||
items = items.filter((item) => !gone.has(item.storage_key));
|
items = items.filter((item) => !gone.has(item.storage_key));
|
||||||
selected.clear();
|
selected.clear();
|
||||||
|
loadTotal().catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restore(list: TrashItem[]) {
|
async function restore(list: TrashItem[]) {
|
||||||
@@ -117,17 +138,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function purge() {
|
async function restoreAll() {
|
||||||
if (busy || doomed === null) {
|
if (busy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
busy = true;
|
busy = true;
|
||||||
problem = null;
|
problem = null;
|
||||||
const keys = doomed.map((item) => item.storage_key);
|
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 {
|
try {
|
||||||
await purgeTrash(keys);
|
await purgeTrash(keys);
|
||||||
drop(keys);
|
|
||||||
doomed = null;
|
doomed = null;
|
||||||
|
everything = false;
|
||||||
|
await load();
|
||||||
} catch {
|
} catch {
|
||||||
problem = "Не удалось удалить файлы";
|
problem = "Не удалось удалить файлы";
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,17 +195,22 @@
|
|||||||
вернуть.
|
вернуть.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if items.length > 0}
|
{#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
|
<button
|
||||||
class="clear"
|
class="act danger"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
doomed = items;
|
everything = true;
|
||||||
}}
|
}}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="delete" size="1.375rem" />
|
<Icon name="delete" size="1.375rem" />
|
||||||
<span class="label">Очистить корзину</span>
|
<span class="label">Очистить корзину</span>
|
||||||
<span class="value">{formatBytes(totalBytes)}</span>
|
<span class="value">{formatBytes(total.bytes)}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -219,12 +262,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if items.length === 0}
|
{:else if items.length === 0}
|
||||||
<div class="state"><p>Корзина пуста.</p></div>
|
<div class="state"><p>Корзина пуста.</p></div>
|
||||||
|
{:else if !done}
|
||||||
|
<button class="more" onclick={loadMore} type="button">
|
||||||
|
Показать ещё
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if chosen.length > 0}
|
{#if chosen.length > 0}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
failure={doomed === null ? problem : null}
|
failure={dialogOpen ? null : problem}
|
||||||
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
|
note={chosenGone > 0 ? `${chosenGone} не скачать заново` : undefined}
|
||||||
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
|
title="{filesLabel(chosen.length)} · {formatBytes(bytesOf(chosen))}"
|
||||||
warn
|
warn
|
||||||
@@ -250,7 +297,7 @@
|
|||||||
<span>Вернуть</span>
|
<span>Вернуть</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SelectionBar>
|
</SelectionBar>
|
||||||
{:else if problem && doomed === null}
|
{:else if problem && !dialogOpen}
|
||||||
<SelectionBar failure={problem} title="Не получилось">
|
<SelectionBar failure={problem} title="Не получилось">
|
||||||
<Button
|
<Button
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -269,26 +316,40 @@
|
|||||||
onOpenChange={(next) => {
|
onOpenChange={(next) => {
|
||||||
if (!next) {
|
if (!next) {
|
||||||
doomed = null;
|
doomed = null;
|
||||||
|
everything = false;
|
||||||
problem = null;
|
problem = null;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open={doomed !== null}
|
open={doomed !== null || everything}
|
||||||
>
|
>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="dialog-overlay" />
|
<Dialog.Overlay class="dialog-overlay" />
|
||||||
<Dialog.Content class="dialog-content">
|
<Dialog.Content class="dialog-content">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<Dialog.Title class="dialog-title">
|
<Dialog.Title class="dialog-title">
|
||||||
{doomed?.length === items.length
|
{everything ? "Очистить корзину?" : "Удалить насовсем?"}
|
||||||
? "Очистить корзину?"
|
|
||||||
: "Удалить насовсем?"}
|
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
<Dialog.Close aria-label="Закрыть" class="dialog-close">
|
||||||
<Icon name="close" size="1.25rem" />
|
<Icon name="close" size="1.25rem" />
|
||||||
</Dialog.Close>
|
</Dialog.Close>
|
||||||
</header>
|
</header>
|
||||||
{#if doomed}
|
|
||||||
<div class="dialog-body">
|
<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">
|
<p class="confirm">
|
||||||
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
|
{filesLabel(doomed.length)}, {formatBytes(bytesOf(doomed))},
|
||||||
{plural(
|
{plural(
|
||||||
@@ -316,11 +377,11 @@
|
|||||||
можно скачать из Telegram заново.
|
можно скачать из Telegram заново.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
{#if problem}
|
{#if problem}
|
||||||
<p class="confirm failed">{problem}.</p>
|
<p class="confirm failed">{problem}.</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<Dialog.Close>
|
<Dialog.Close>
|
||||||
{#snippet child({
|
{#snippet child({
|
||||||
@@ -366,7 +427,7 @@
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.clear {
|
.act {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -382,11 +443,20 @@
|
|||||||
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-error);
|
color: var(--color-primary);
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
|
||||||
background: none;
|
background: none;
|
||||||
|
|
||||||
|
&.danger {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -398,15 +468,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
outline: 2px solid var(--color-error);
|
outline: 2px solid currentcolor;
|
||||||
outline-offset: -2px;
|
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) {
|
@media (hover: hover) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
scopeId: number;
|
||||||
|
scopeType: "folder" | "chat";
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ORDER: PolicyScopeType[] = [
|
||||||
|
"default_dm",
|
||||||
|
"default_group",
|
||||||
|
"default_channel",
|
||||||
|
];
|
||||||
|
const DEFAULT_ICONS: Partial<Record<PolicyScopeType, string>> = {
|
||||||
|
default_channel: "channel",
|
||||||
|
default_dm: "user",
|
||||||
|
default_group: "group",
|
||||||
|
};
|
||||||
|
|
||||||
|
let rules = $state<PolicyRule[]>([]);
|
||||||
let folders = $state<Folder[]>([]);
|
let folders = $state<Folder[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let pickFolder = $state(false);
|
let failed = $state(false);
|
||||||
let pickChat = $state(false);
|
let problem = $state<string | null>(null);
|
||||||
|
let expanded = $state<string | null>(null);
|
||||||
|
let draft = $state<Draft | null>(null);
|
||||||
|
let picking = $state<"folder" | "chat" | null>(null);
|
||||||
let chatFilter = $state("");
|
let chatFilter = $state("");
|
||||||
|
|
||||||
const DEFAULT_SCOPES: { label: string; scope: PolicyScopeType }[] = [
|
const picker = createChatPicker();
|
||||||
{ label: "Личные чаты", scope: "default_dm" },
|
|
||||||
{ label: "Группы", scope: "default_group" },
|
|
||||||
{ label: "Каналы", scope: "default_channel" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ALL_FALSE: CaptureToggles = {
|
|
||||||
backfill: false,
|
|
||||||
media: false,
|
|
||||||
messages: false,
|
|
||||||
presence: false,
|
|
||||||
profile_history: false,
|
|
||||||
reactions: false,
|
|
||||||
self_destruct_media: false,
|
|
||||||
stories: false,
|
|
||||||
stt: false,
|
|
||||||
track_edits_deletes: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
function pickToggles(t: CaptureToggles): CaptureToggles {
|
|
||||||
return {
|
|
||||||
backfill: t.backfill,
|
|
||||||
media: t.media,
|
|
||||||
messages: t.messages,
|
|
||||||
presence: t.presence,
|
|
||||||
profile_history: t.profile_history,
|
|
||||||
reactions: t.reactions,
|
|
||||||
self_destruct_media: t.self_destruct_media,
|
|
||||||
stories: t.stories,
|
|
||||||
stt: t.stt,
|
|
||||||
track_edits_deletes: t.track_edits_deletes,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaults = $derived(
|
const defaults = $derived(
|
||||||
DEFAULT_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(() => {
|
function keyOf(scopeType: PolicyScopeType, scopeId: number | null): string {
|
||||||
chats.ensureMany(
|
return `${scopeType}:${scopeId}`;
|
||||||
chatPolicies
|
}
|
||||||
.map((policy) => policy.scope_id)
|
|
||||||
.filter((id): id is number => id !== null)
|
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))
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
function folderTitle(id: number | null): string {
|
|
||||||
return folders.find((f) => f.folder_id === id)?.title ?? `Папка ${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chatTitle(id: number | null): string {
|
|
||||||
return chats.byId(id ?? 0)?.title ?? `Чат ${id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reload() {
|
async function reload() {
|
||||||
[policies, folders] = await Promise.all([listPolicies(), listFolders()]);
|
try {
|
||||||
}
|
[rules, folders] = await Promise.all([
|
||||||
|
getPolicyOverview(),
|
||||||
async function toggle(
|
listFolders(),
|
||||||
record: PolicyRecord,
|
]);
|
||||||
key: keyof CaptureToggles,
|
failed = false;
|
||||||
value: boolean
|
if (
|
||||||
|
draft &&
|
||||||
|
rules.some(
|
||||||
|
(rule) =>
|
||||||
|
rule.scope_type === draft?.scopeType &&
|
||||||
|
rule.scope_id === draft?.scopeId
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
const next = { ...pickToggles(record), [key]: value };
|
expanded = keyOf(draft.scopeType, draft.scopeId);
|
||||||
policies = policies.map((p) =>
|
draft = null;
|
||||||
p.id === record.id ? { ...p, [key]: value } : p
|
}
|
||||||
);
|
|
||||||
try {
|
|
||||||
const saved = await updatePolicy(record.id, next);
|
|
||||||
policies = policies.map((p) => (p.id === record.id ? saved : p));
|
|
||||||
} catch {
|
} catch {
|
||||||
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}
|
||||||
|
name={title(rule)}
|
||||||
|
size={2.25}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<span class="glyph">
|
||||||
|
<Icon name={DEFAULT_ICONS[rule.scope_type] ?? "folder"} size="1.5rem" />
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if onremove && record.account_id !== null}
|
{/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
|
<button
|
||||||
aria-label="Удалить оверрайд"
|
aria-expanded={open}
|
||||||
class="remove"
|
class="main"
|
||||||
onclick={onremove}
|
onclick={() => toggle(rule)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Icon name="close" size="1.125rem" />
|
{@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>
|
</button>
|
||||||
|
{#if extra}
|
||||||
|
<Button onclick={() => drop(rule)} smaller variant="text">
|
||||||
|
Убрать
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
aria-hidden="true"
|
||||||
|
class="chevron"
|
||||||
|
onclick={() => toggle(rule)}
|
||||||
|
tabindex="-1"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Icon name={open ? "up" : "down"} size="1.125rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if open}
|
||||||
|
<div class="editor" {@attach reveal}>
|
||||||
|
<PolicyCard
|
||||||
|
onchange={reload}
|
||||||
|
parent={rule.parent}
|
||||||
|
{rule}
|
||||||
|
scopeId={rule.scope_id}
|
||||||
|
scopeType={rule.scope_type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<CaptureToggleList
|
{/snippet}
|
||||||
onchange={(key, value) => toggle(record, key, value)}
|
|
||||||
toggles={record}
|
{#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,81 +429,213 @@
|
|||||||
<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;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: inherit;
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 0.25rem 0 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-head {
|
.rule.open {
|
||||||
display: flex;
|
border-bottom: 1px solid var(--color-borders);
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.25rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
.line {
|
||||||
flex: 1;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shared {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove {
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
padding: 0.25rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
background-color: transparent;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding-right: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 3.25rem;
|
||||||
|
padding: 0.4375rem 0.5rem 0.4375rem 1rem;
|
||||||
|
border: 0;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-align: start;
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
:global(.icon) {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:not(.static):hover {
|
||||||
|
background-color: var(--color-chat-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
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 {
|
.add {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.625rem 1rem;
|
height: 2.75rem;
|
||||||
|
padding: 0 1rem 0 1.5rem;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
||||||
|
font: inherit;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
background-color: transparent;
|
text-align: start;
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -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;
|
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-chat-hover);
|
background-color: var(--color-chat-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
min-height: 2.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nothing {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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,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();
|
||||||
@@ -16,6 +16,7 @@ export type RightPanel =
|
|||||||
| "shares"
|
| "shares"
|
||||||
| "bots"
|
| "bots"
|
||||||
| "storage"
|
| "storage"
|
||||||
|
| "storage-chat"
|
||||||
| "storage-trash";
|
| "storage-trash";
|
||||||
|
|
||||||
export type LeftView = "main" | "settings";
|
export type LeftView = "main" | "settings";
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ html.theme-dark {
|
|||||||
--color-storage-warning: #fb8c00;
|
--color-storage-warning: #fb8c00;
|
||||||
--color-storage-trash: #6c6c6c;
|
--color-storage-trash: #6c6c6c;
|
||||||
--storage-tile-mix: 90%;
|
--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;
|
||||||
|
|||||||
@@ -142,6 +142,11 @@
|
|||||||
--color-storage-trash: #939799;
|
--color-storage-trash: #939799;
|
||||||
--storage-tile-mix: 100%;
|
--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