"""«📝 Коммит»: browsing the uploads repo tree with buttons (SPEC C-1…C-4). Changes and push — #26, #9.""" import html import logging from enum import StrEnum from typing import Any from aiogram import F, Router from aiogram.enums import ParseMode from aiogram.filters.callback_data import CallbackData from aiogram.fsm.context import FSMContext from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message from aiogram.utils.keyboard import InlineKeyboardBuilder from app.bot.menu import Nav, NavAction from app.repo.git import Entry, GitError, UploadsRepo from app.texts import Texts log = logging.getLogger(__name__) PAGE_SIZE = 20 INLINE_VIEW_LIMIT = 3500 # chars; Telegram caps a message at 4096 and
markup takes some
class Act(StrEnum):
OPEN = "o"
UP = "u"
PAGE = "p"
VIEW = "v"
TO_DIR = "b"
REPLACE = "r"
DELETE = "d"
NEW_FILE = "f"
NEW_DIR = "n"
class CommitCb(CallbackData, prefix="c"):
# Only an action and a small index: paths never go into callback_data (64-byte limit, ARCHITECTURE §2.1)
a: Act
i: int = 0
def human_size(n: int) -> str:
size = float(n)
for unit in ("Б", "КБ", "МБ"):
if size < 1024 or unit == "МБ":
return f"{size:.0f} {unit}" if unit == "Б" else f"{size:.1f} {unit}"
size /= 1024
raise AssertionError("unreachable")
def parent(path: str) -> str:
return path.rsplit("/", 1)[0] if "/" in path else ""
def render_dir(texts: Texts, path: str, entries: list[Entry], page: int) -> tuple[str, InlineKeyboardMarkup]:
t = texts.commit
pages = max(1, -(-len(entries) // PAGE_SIZE))
page = min(max(page, 0), pages - 1)
lines = [t.title.format(path=path)]
if not entries:
lines.append(t.empty)
elif pages > 1:
lines.append(t.page.format(page=page + 1, pages=pages))
kb = InlineKeyboardBuilder()
start = page * PAGE_SIZE
for index, e in enumerate(entries[start : start + PAGE_SIZE], start=start):
label = (
t.dir_button.format(name=e.name)
if e.kind == "dir"
else t.file_button.format(name=e.name, size=human_size(e.size))
)
kb.button(text=label, callback_data=CommitCb(a=Act.OPEN, i=index))
sizes = [1] * min(PAGE_SIZE, max(0, len(entries) - start))
paging: list[tuple[str, CommitCb]] = []
if page > 0:
paging.append((t.prev, CommitCb(a=Act.PAGE, i=page - 1)))
if page < pages - 1:
paging.append((t.next, CommitCb(a=Act.PAGE, i=page + 1)))
for text, cb in paging:
kb.button(text=text, callback_data=cb)
if paging:
sizes.append(len(paging))
kb.button(text=t.new_file, callback_data=CommitCb(a=Act.NEW_FILE))
kb.button(text=t.new_dir, callback_data=CommitCb(a=Act.NEW_DIR))
sizes.append(2)
if path:
kb.button(text=t.up, callback_data=CommitCb(a=Act.UP))
sizes.append(1)
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.button(text=texts.menu.cancel, callback_data=Nav(action=NavAction.CANCEL))
sizes.append(2)
kb.adjust(*sizes)
return "\n".join(lines), kb.as_markup()
def file_keyboard(texts: Texts) -> InlineKeyboardMarkup:
t = texts.commit
kb = InlineKeyboardBuilder()
kb.button(text=t.view, callback_data=CommitCb(a=Act.VIEW))
kb.button(text=t.replace, callback_data=CommitCb(a=Act.REPLACE))
kb.button(text=t.delete, callback_data=CommitCb(a=Act.DELETE))
kb.button(text=t.to_dir, callback_data=CommitCb(a=Act.TO_DIR))
kb.adjust(1, 2, 1)
return kb.as_markup()
def build_router(texts: Texts, repo: UploadsRepo) -> Router:
router = Router(name="commit")
t = texts.commit
async def show_dir(message: Message, state: FSMContext, path: str, page: int, *, edit: bool) -> None:
entries = await repo.list_dir(path)
await state.update_data(
commit_path=path,
commit_page=page,
commit_entries=[[e.path, e.kind, e.size] for e in entries],
commit_file=None,
)
text, kb = render_dir(texts, path, entries, page)
if edit:
await message.edit_text(text, reply_markup=kb)
else:
await message.answer(text, reply_markup=kb)
@router.message(F.text == texts.menu.commit)
async def open_commit(message: Message, state: FSMContext) -> None:
await state.clear()
try:
await repo.refresh()
await show_dir(message, state, "", 0, edit=False)
except GitError:
log.exception("uploads repo unavailable")
await message.answer(t.unavailable)
@router.callback_query(CommitCb.filter())
async def on_action(query: CallbackQuery, callback_data: CommitCb, state: FSMContext) -> None:
if not isinstance(query.message, Message):
await query.answer()
return
msg = query.message
data: dict[str, Any] = await state.get_data()
path: str = data.get("commit_path", "")
entries: list[list[Any]] = data.get("commit_entries", [])
try:
match callback_data.a:
case Act.OPEN:
if not 0 <= callback_data.i < len(entries):
await query.answer() # stale button from an older screen
return
target, kind, size = entries[callback_data.i]
if kind == "dir":
await repo.refresh()
await show_dir(msg, state, target, 0, edit=True)
else:
await state.update_data(commit_file=target)
await msg.edit_text(await file_card(target, size), reply_markup=file_keyboard(texts))
case Act.UP:
await show_dir(msg, state, parent(path), 0, edit=True)
case Act.PAGE:
await state.update_data(commit_page=callback_data.i)
real = [Entry(p, k, s) for p, k, s in entries]
text, kb = render_dir(texts, path, real, callback_data.i)
await msg.edit_text(text, reply_markup=kb)
case Act.TO_DIR:
await show_dir(msg, state, path, data.get("commit_page", 0), edit=True)
case Act.VIEW:
file_path = data.get("commit_file")
if file_path:
await send_file(msg, file_path)
case Act.REPLACE | Act.DELETE | Act.NEW_FILE | Act.NEW_DIR:
await query.answer(t.soon, show_alert=True)
return
except GitError:
log.exception("uploads repo unavailable")
await query.answer(t.unavailable, show_alert=True)
return
await query.answer()
async def file_card(path: str, size: int) -> str:
card = t.file_card.format(path=path, size=human_size(size))
info = await repo.last_commit(path)
tail = (
t.last_commit.format(
sha=info.sha[:7], author=info.author, date=info.date.strftime("%d.%m.%Y %H:%M"), subject=info.subject
)
if info
else t.no_commits
)
return f"{card}\n{tail}"
async def send_file(msg: Message, path: str) -> None:
content = await repo.read(path)
name = path.rsplit("/", 1)[-1]
try:
text = content.decode("utf-8")
except UnicodeDecodeError:
text = None
if text is not None and "\0" not in text and len(text) <= INLINE_VIEW_LIMIT:
await msg.answer(f"/{html.escape(path)}\n{html.escape(text)}", parse_mode=ParseMode.HTML)
else:
await msg.answer_document(BufferedInputFile(content, name), caption=t.sent_as_file)
return router