m / app.py
Eluza133's picture
Update app.py
6ec0068 verified
raw
history blame
147 kB
import flask
from flask import Flask, render_template_string, request, redirect, url_for, session, flash, send_file, jsonify, Response, stream_with_context
from flask_caching import Cache
import json
import os
import logging
import threading
import time
from datetime import datetime, timedelta
from huggingface_hub import HfApi, hf_hub_download, utils as hf_utils
from werkzeug.utils import secure_filename
import requests
from io import BytesIO
import uuid
from functools import wraps
from urllib.parse import quote
import zipfile
import tempfile
import pytz
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma")
DATA_FILE = 'cloudeng_data_tma.json'
REPO_ID = "Eluza133/Z1e1u"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "6750208873:AAE2hvPlJ99dBdhGa_Brre0IIpUdOvXxHt4")
ADMIN_TELEGRAM_ID = os.getenv("ADMIN_TELEGRAM_ID", "YOUR_ADMIN_TELEGRAM_USER_ID_HERE")
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "zeusadminpass")
UPLOAD_FOLDER = 'uploads_tma'
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
cache = Cache(app, config={'CACHE_TYPE': 'simple'})
logging.basicConfig(level=logging.INFO)
BASE_STYLE = '''
:root {
--primary: #ff4d6d; --secondary: #00ddeb; --accent: #8b5cf6;
--background-dark: #121212; --card-bg-dark: #1e1e1e;
--text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
--glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
--delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3);
--note-color: #6a5acd; --share-color: #4caf50; --todolist-color: #20c997; --shoppinglist-color: #fd7e14;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
* { margin: 0; padding: 0; box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: var(--background-dark); color: var(--text-dark); line-height: 1.6; -webkit-tap-highlight-color: transparent; }
.container { margin: 0 auto; max-width: 1200px; padding: 75px 15px 15px 15px; }
.app-header { position: fixed; top: 0; left: 0; right: 0; background: var(--glass-bg); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid rgba(255,255,255,0.1); z-index: 1000; padding: 10px 20px; display: flex; justify-content: space-between; align-items: center; }
.user-info { font-weight: 600; }
.view-toggle { display: flex; align-items: center; gap: 5px; }
.view-toggle button { background: none; border: none; color: var(--text-muted); font-size: 1.2em; padding: 5px; cursor: pointer; transition: var(--transition); }
.view-toggle button:hover, .view-toggle button.active { color: var(--primary); }
h2, h3, h4, h5 { color: var(--text-dark); }
h2 { font-size: 1.3em; margin-bottom: 15px; margin-top: 15px; }
.breadcrumbs { font-size: 1em; margin-bottom: 20px; white-space: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.breadcrumbs a { color: var(--accent); text-decoration: none; }
.breadcrumbs span { margin: 0 5px; color: var(--text-muted); }
input, select, textarea { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #333; border-radius: 12px; background: #2a2a2a; color: var(--text-dark); font-size: 1em; }
.btn { padding: 12px 24px; background: var(--primary); color: white; border: none; border-radius: 12px; cursor: pointer; font-size: 1em; font-weight: 600; transition: var(--transition); text-decoration: none; display: inline-block; text-align: center; }
.btn:hover { filter: brightness(1.2); }
.btn:active { transform: scale(0.98); }
.download-btn { background: var(--secondary); }
.delete-btn { background: var(--delete-color); }
.folder-btn { background: var(--folder-color); }
.share-btn { background: var(--share-color); }
.flash { text-align: center; margin-bottom: 15px; padding: 12px; border-radius: 10px; background: rgba(0, 221, 235, 0.1); color: var(--secondary); }
.flash.error { background: rgba(255, 68, 68, 0.1); color: var(--delete-color); }
.file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; }
.item { background: var(--card-bg-dark); border-radius: 16px; text-align: center; transition: var(--transition); position: relative; border: 2px solid transparent; user-select: none; padding: 10px; display: flex; flex-direction: column; cursor: pointer; }
.item:hover { transform: translateY(-5px); box-shadow: var(--shadow); }
.item:active { transform: scale(0.97); }
.item.selected { border-color: var(--accent); background-color: var(--selection-color); }
.item-preview-wrapper { position: relative; width: 100%; padding-top: 75%; border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #2a2a2a; }
.item-preview { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
.item.folder .item-preview { object-fit: contain; font-size: 3.5em; color: var(--folder-color); }
.item.note .item-preview { object-fit: contain; font-size: 3.5em; color: var(--note-color); }
.item.todolist .item-preview { object-fit: contain; font-size: 3.5em; color: var(--todolist-color); }
.item.shoppinglist .item-preview { object-fit: contain; font-size: 3.5em; color: var(--shoppinglist-color); }
.item-name { font-size: 0.9em; font-weight: 500; word-break: break-all; margin: 5px 0; flex-grow: 1; }
.item-info { font-size: 0.75em; color: var(--text-muted); }
.file-grid.list-view { display: flex; flex-direction: column; gap: 8px; }
.file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; }
.file-grid.list-view .item:hover { transform: translateY(0); }
.file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; }
.file-grid.list-view .item.folder .item-preview, .file-grid.list-view .item.note .item-preview, .file-grid.list-view .item.todolist .item-preview, .file-grid.list-view .item.shoppinglist .item-preview { font-size: 1.8em; }
.file-grid.list-view .item-name-info { flex-grow: 1; }
.modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.85); z-index: 2000; justify-content: center; align-items: center; }
.modal-content { display: flex; flex-direction: column; max-width: 95%; max-height: 95%; background: var(--card-bg-dark); padding: 10px; border-radius: 15px; overflow: hidden; position: relative; }
.modal-main-content { flex-grow: 1; overflow-y: auto; }
.modal-main-content img, .modal-main-content video, .modal-main-content iframe, .modal-main-content pre { max-width: 100%; max-height: 85vh; display: block; margin: auto; border-radius: 10px; }
.modal-main-content iframe { width: 80vw; height: 85vh; border: none; }
.modal-main-content pre { background: #121212; padding: 15px; border-radius: 8px; white-space: pre-wrap; word-wrap: break-word; max-height: 85vh; color: var(--text-dark); }
.modal-actions { padding: 10px; text-align: center; border-top: 1px solid #333; }
.modal-close-btn { position: absolute; top: 15px; right: 25px; font-size: 30px; color: #aaa; cursor: pointer; background: rgba(0,0,0,0.5); border-radius: 50%; width: 30px; height: 30px; line-height: 30px; text-align: center; z-index: 2001;}
#progress-container { width: 100%; background: #333; border-radius: 10px; margin: 15px 0; display: none; height: 10px; }
#progress-bar { width: 0%; height: 100%; background: var(--primary); border-radius: 10px; transition: width 0.3s ease; }
#selection-bar { position: fixed; bottom: -120px; left: 10px; right: 10px; background: var(--glass-bg); backdrop-filter: blur(10px); padding: 10px; border-radius: 15px; box-shadow: var(--shadow); z-index: 1000; display: flex; justify-content: space-around; align-items: center; transition: bottom 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); }
#selection-bar.visible { bottom: 10px; }
#selection-bar .btn { margin: 0 5px; padding: 10px 15px; font-size: 0.9em; flex-grow: 1; }
#move-modal .modal-content { padding: 20px; max-width: 400px; }
.fab-container { position: fixed; bottom: 20px; right: 20px; z-index: 1050; }
.fab { width: 56px; height: 56px; background: var(--accent); border-radius: 50%; border: none; box-shadow: var(--shadow); color: white; font-size: 24px; display: flex; justify-content: center; align-items: center; cursor: pointer; transition: transform 0.3s; }
.fab:active { transform: scale(0.9); }
.loading-spinner { border: 4px solid #f3f3f3; border-top: 4px solid var(--primary); border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: auto; }
#fab-modal .modal-content { padding: 20px; max-width: 500px; background: var(--card-bg-dark); text-align: center; }
.fab-options { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 15px; margin-bottom: 20px; }
.fab-option { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 12px; padding: 15px; cursor: pointer; transition: var(--transition); text-decoration:none; color: var(--text-dark); }
.fab-option:hover { background: #333; transform: translateY(-3px); }
.fab-option i { font-size: 2em; margin-bottom: 8px; }
#fab-option-upload i { color: var(--secondary); }
#fab-option-note i { color: var(--note-color); }
#fab-option-folder i { color: var(--folder-color); }
#fab-option-todolist i { color: var(--todolist-color); }
#fab-option-shoppinglist i { color: var(--shoppinglist-color); }
#create-folder-form { display: none; margin-top: 15px; }
.shared-link-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom: 1px solid #333; }
.shared-link-item:last-child { border-bottom: none; }
.shared-link-info { text-align: left; }
.shared-link-info strong { word-break: break-all; }
.shared-link-info small { color: var(--text-muted); display: block; }
.shared-link-actions button { background: none; border: none; color: var(--text-muted); font-size: 1.1em; cursor: pointer; padding: 5px; }
'''
PUBLIC_FOLDER_PAGE_HTML = '''
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Общая папка: {{ folder.name }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>''' + BASE_STYLE + '''
body { padding-bottom: 30px; }
.public-header { padding: 15px; text-align: center; border-bottom: 1px solid #333; margin-bottom: 20px; }
.item { cursor: default; }
.item .download-icon { position: absolute; top: 10px; right: 10px; font-size: 1.2em; color: var(--text-muted); cursor: pointer; transition: var(--transition); }
.item .download-icon:hover { color: var(--secondary); }
.list-view .item .download-icon { position: static; margin-left: auto; padding: 5px 10px; }
</style></head><body>
<div class="public-header">
<h1>Общая папка</h1>
<h2>{{ folder.name }}</h2>
<p style="color: var(--text-muted);">Автор: {{ user.first_name or user.telegram_username }}</p>
</div>
<div class="container" style="padding-top: 15px;">
<div class="file-grid list-view">
{% for item in items %}
<div class="item {{ item.type }}">
<div class="item-preview-wrapper">
{% if item.type == 'folder' %}
<a href="{{ url_for('shared_folder_view', link_id=link.id, subfolder_id=item.id) }}" style="text-decoration: none; color: inherit;">
<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
</a>
{% elif item.type == 'note' %}
<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
{% elif item.file_type == 'image' %}<div class="item-preview" style="background-image: url({{ hf_file_url_jinja(item.path) }}); background-size: cover; background-position: center;"></div>
{% else %}<div class="item-preview" style="font-size: 1.8em; display: flex; align-items: center; justify-content: center;"><i class="fa-solid fa-file"></i></div>{% endif %}
</div>
<div class="item-name-info">
<p class="item-name">
{% if item.type == 'folder' %}
<a href="{{ url_for('shared_folder_view', link_id=link.id, subfolder_id=item.id) }}" style="text-decoration: none; color: inherit;">{{ item.name }}</a>
{% else %}
{{ item.title if item.type == 'note' else item.original_filename }}
{% endif %}
</p>
<p class="item-info">{% if item.type == 'file' %}{{ item.upload_date }}{% elif item.type == 'note' %}{{ item.modified_date }}{% endif %}</p>
</div>
{% if item.type != 'folder' %}
<a href="{{ url_for('public_download_via_link', link_id=link.id, item_id=item.id) }}" class="download-icon" title="Скачать">
<i class="fa-solid fa-download"></i>
</a>
{% endif %}
</div>
{% endfor %}
{% if not items %}<p>Эта папка пуста.</p>{% endif %}
</div>
</div></body></html>
'''
PUBLIC_SHOPPING_LIST_PAGE_HTML = '''
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Список покупок: {{ list_data.title }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>''' + BASE_STYLE + '''
body { padding-bottom: 30px; }
.public-header { padding: 15px; text-align: center; border-bottom: 1px solid #333; margin-bottom: 20px; }
.shopping-list { list-style: none; padding: 0; }
.shopping-item { display: flex; align-items: center; gap: 15px; background: var(--card-bg-dark); padding: 15px; border-radius: 12px; margin-bottom: 10px; transition: all 0.3s; }
.shopping-item.purchased { opacity: 0.6; }
.shopping-item.purchased .item-name { text-decoration: line-through; }
.shopping-item input[type="checkbox"] { width: 22px; height: 22px; flex-shrink: 0; accent-color: var(--accent); }
.item-details { flex-grow: 1; }
.item-name { font-weight: 500; }
.item-quantity { color: var(--text-muted); font-size: 0.9em; }
</style></head><body>
<div class="public-header">
<h1>Список покупок</h1>
<h2>{{ list_data.title }}</h2>
<p style="color: var(--text-muted);">Автор: {{ user.first_name or user.telegram_username }}</p>
</div>
<div class="container" style="padding-top: 15px;">
<ul class="shopping-list" id="shopping-list">
<div class="loading-spinner"></div>
</ul>
</div>
<script>
const listId = '{{ link.id }}';
const listContainer = document.getElementById('shopping-list');
function renderList(items) {
listContainer.innerHTML = '';
if (!items || items.length === 0) {
listContainer.innerHTML = '<p>Список пуст.</p>';
return;
}
items.sort((a, b) => a.purchased - b.purchased);
items.forEach(item => {
const li = document.createElement('li');
li.className = 'shopping-item';
if (item.purchased) {
li.classList.add('purchased');
}
li.innerHTML = `
<input type="checkbox" id="item-${item.id}" data-id="${item.id}" ${item.purchased ? 'checked' : ''}>
<div class="item-details">
<label for="item-${item.id}" class="item-name">${item.name}</label>
<div class="item-quantity">Количество: ${item.quantity}</div>
</div>
`;
listContainer.appendChild(li);
});
}
async function fetchList() {
try {
const response = await fetch(`{{ url_for('get_shared_list_data', link_id=link.id) }}`);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
if (data.status === 'success') {
renderList(data.items);
} else {
listContainer.innerHTML = `<p>Ошибка загрузки списка: ${data.message}</p>`;
}
} catch (error) {
console.error('Fetch error:', error);
listContainer.innerHTML = '<p>Не удалось обновить список. Проверьте соединение.</p>';
}
}
async function updateItemStatus(itemId, isPurchased) {
try {
await fetch(`{{ url_for('update_shared_item_status', link_id=link.id) }}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: itemId, purchased: isPurchased })
});
fetchList();
} catch (error) {
console.error('Update error:', error);
alert('Ошибка обновления статуса товара.');
}
}
listContainer.addEventListener('change', (event) => {
if (event.target.type === 'checkbox') {
const itemId = event.target.dataset.id;
const isPurchased = event.target.checked;
updateItemStatus(itemId, isPurchased);
}
});
document.addEventListener('DOMContentLoaded', () => {
fetchList();
setInterval(fetchList, 5000);
});
</script>
</body></html>
'''
def find_node_by_id(filesystem, node_id):
if not filesystem: return None, None
if filesystem.get('id') == node_id:
return filesystem, None
queue = [(filesystem, None)]
while queue:
current_node, parent = queue.pop(0)
if current_node.get('type') == 'folder' and 'children' in current_node:
for i, child in enumerate(current_node['children']):
if child.get('id') == node_id:
return child, current_node
if child.get('type') == 'folder':
queue.append((child, current_node))
return None, None
def add_node(filesystem, parent_id, node_data):
parent_node, _ = find_node_by_id(filesystem, parent_id)
if parent_node and parent_node.get('type') == 'folder':
if 'children' not in parent_node:
parent_node['children'] = []
parent_node['children'].append(node_data)
return True
return False
def remove_node(filesystem, node_id):
node_to_remove, parent_node = find_node_by_id(filesystem, node_id)
if node_to_remove and parent_node and 'children' in parent_node:
parent_node['children'] = [child for child in parent_node['children'] if child.get('id') != node_id]
return True, node_to_remove
return False, None
def get_node_path_string(filesystem, node_id):
path_list = []
current_id = node_id
while current_id:
node, parent = find_node_by_id(filesystem, current_id)
if not node: break
if node.get('id') != 'root':
path_list.append(node.get('name', node.get('original_filename', '')))
if not parent: break
current_id = parent.get('id') if parent else None
return " / ".join(reversed(path_list)) or "Root"
def get_all_folders(filesystem, exclude_ids=None):
if exclude_ids is None:
exclude_ids = set()
folders = []
def traverse(node, path_prefix):
if node.get('type') == 'folder':
if node.get('id') not in exclude_ids:
folder_name = f"{path_prefix}{node.get('name', 'Unnamed')}" if path_prefix else (node.get('name') if node.get('id') != 'root' else 'Главная (Root)')
folders.append({'id': node.get('id'), 'name': folder_name})
new_prefix = f"{path_prefix}{node.get('name', '')}/" if node.get('id') != 'root' else ""
for child in node.get('children', []):
traverse(child, new_prefix)
traverse(filesystem, "")
return sorted(folders, key=lambda x: x['name'].lower())
def count_items_recursive(node):
if not node or not isinstance(node, dict):
return 0
count = 0
if node.get('type') in ['file', 'note', 'todolist', 'shoppinglist']:
count += 1
if node.get('type') == 'folder' and 'children' in node:
for child in node.get('children', []):
count += count_items_recursive(child)
return count
def initialize_user_filesystem_tma(user_data, tma_user_id_str):
if 'filesystem' not in user_data:
user_data['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
if 'files' in user_data and isinstance(user_data['files'], list):
for old_file in user_data['files']:
file_id = old_file.get('id', uuid.uuid4().hex)
original_filename = old_file.get('filename', 'unknown_file')
name_part, ext_part = os.path.splitext(original_filename)
unique_suffix = uuid.uuid4().hex[:8]
unique_filename = f"{name_part}_{unique_suffix}{ext_part}"
hf_path = f"cloud_files/{tma_user_id_str}/root/{unique_filename}"
file_node = {
'type': 'file', 'id': file_id, 'original_filename': original_filename,
'unique_filename': unique_filename, 'path': hf_path,
'file_type': get_file_type(original_filename),
'upload_date': old_file.get('upload_date', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
}
add_node(user_data['filesystem'], 'root', file_node)
del user_data['files']
@cache.memoize(timeout=300)
def load_data():
try:
download_db_from_hf()
with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file)
if not isinstance(data, dict): data = {'users': {}, 'shared_links': {}}
data.setdefault('users', {})
data.setdefault('shared_links', {})
for tma_user_id_str, user_data_item in data['users'].items():
initialize_user_filesystem_tma(user_data_item, tma_user_id_str)
user_data_item.setdefault('reminders', [])
return data
except Exception as e:
logging.error(f"Error loading data: {e}")
return {'users': {}, 'shared_links': {}}
def save_data(data):
try:
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
upload_db_to_hf()
cache.clear()
except Exception as e:
logging.error(f"Error saving data: {e}")
raise
def upload_db_to_hf():
if not HF_TOKEN_WRITE: return
try:
api = HfApi()
api.upload_file(path_or_fileobj=DATA_FILE, path_in_repo=DATA_FILE, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
except Exception as e:
logging.error(f"Error uploading database: {e}")
def download_db_from_hf():
if not HF_TOKEN_READ:
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}}, f)
return
try:
hf_hub_download(repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False)
except (hf_utils.RepositoryNotFoundError, hf_utils.EntryNotFoundError):
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}}, f)
except Exception as e:
logging.error(f"Error downloading database: {e}")
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}}, f)
def periodic_backup():
while True:
upload_db_to_hf()
time.sleep(1800)
def send_telegram_message(chat_id, text):
if not TELEGRAM_BOT_TOKEN:
logging.warning("TELEGRAM_BOT_TOKEN is not set. Cannot send message.")
return
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
try:
response = requests.post(url, json=payload)
response.raise_for_status()
logging.info(f"Sent message to {chat_id}")
except requests.exceptions.RequestException as e:
logging.error(f"Failed to send Telegram message to {chat_id}: {e}")
def check_reminders():
while True:
try:
data = load_data()
now_utc = datetime.now(pytz.utc)
made_changes = False
for user_id, user_data in data.get('users', {}).items():
if 'reminders' in user_data:
for reminder in user_data['reminders']:
if not reminder.get('notified', False):
due_time_str = reminder.get('due_datetime_utc')
if due_time_str:
due_time_utc = datetime.fromisoformat(due_time_str.replace('Z', '+00:00')).replace(tzinfo=pytz.utc)
if now_utc >= due_time_utc:
telegram_id = user_data.get('telegram_id')
if telegram_id:
message_text = f"🔔 <b>Напоминание:</b>\n\n{reminder['text']}"
send_telegram_message(telegram_id, message_text)
reminder['notified'] = True
made_changes = True
if made_changes:
save_data(data)
except Exception as e:
logging.error(f"Error in check_reminders thread: {e}")
time.sleep(60)
def get_file_type(filename):
filename_lower = filename.lower()
if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video'
elif filename_lower.endswith(('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg')): return 'image'
elif filename_lower.endswith('.pdf'): return 'pdf'
elif filename_lower.endswith('.txt'): return 'text'
return 'other'
def is_admin_tma():
if not ADMIN_TELEGRAM_ID or ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE":
return False
return 'telegram_user_id' in session and str(session['telegram_user_id']) == str(ADMIN_TELEGRAM_ID)
def admin_browser_login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('admin_browser_logged_in'):
return redirect(url_for('admin_login', next=request.url))
return f(*args, **kwargs)
return decorated_function
TMA_ENTRY_HTML = '''
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zeus Cloud TMA</title><script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background-color: #121212; color: #e0e0e0; } .loading { font-size: 1.5em; }</style>
</head><body><div class="loading">Загрузка приложения...</div>
<script>
window.Telegram.WebApp.ready();
const initData = window.Telegram.WebApp.initData;
const initDataUnsafe = window.Telegram.WebApp.initDataUnsafe;
if (initDataUnsafe && initDataUnsafe.user) {
fetch("{{ url_for('auth_via_telegram') }}", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user: initDataUnsafe.user, initData: initData })
}).then(response => response.json()).then(data => {
if (data.status === 'success') { window.location.href = data.redirect_url; }
else { document.body.innerHTML = `<div class="loading">Ошибка авторизации: ${data.message}</div>`; Telegram.WebApp.showAlert(data.message || "Ошибка авторизации"); }
}).catch(error => { document.body.innerHTML = `<div class="loading">Ошибка сети: ${error}</div>`; Telegram.WebApp.showAlert("Ошибка сети при авторизации."); });
} else {
document.body.innerHTML = '<div class="loading">Не удалось получить данные. Попробуйте перезапустить приложение.</div>';
Telegram.WebApp.showAlert("Не удалось получить данные пользователя Telegram.");
}
</script></body></html>'''
@app.route('/tma')
def tma_entry_page():
return render_template_string(TMA_ENTRY_HTML)
@app.route('/')
def root_redirect():
return redirect(url_for('tma_entry_page'))
@app.route('/auth_via_telegram', methods=['POST'])
def auth_via_telegram():
try:
payload = request.json
tg_user_data = payload.get('user')
if not tg_user_data or not tg_user_data.get('id'):
return jsonify({'status': 'error', 'message': 'Отсутствуют данные пользователя Telegram.'}), 400
tma_user_id_str = str(tg_user_data['id'])
data = load_data()
user_info = {
'telegram_id': tg_user_data['id'],
'telegram_username': tg_user_data.get('username'),
'first_name': tg_user_data.get('first_name'),
'last_name': tg_user_data.get('last_name'),
'photo_url': tg_user_data.get('photo_url')
}
if tma_user_id_str not in data['users']:
user_info['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
user_info['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
user_info['reminders'] = []
data['users'][tma_user_id_str] = user_info
initialize_user_filesystem_tma(data['users'][tma_user_id_str], tma_user_id_str)
else:
data['users'][tma_user_id_str].update(user_info)
try: save_data(data)
except Exception as e:
logging.error(f"Save data error for TMA user {tma_user_id_str}: {e}")
return jsonify({'status': 'error', 'message': 'Ошибка сохранения данных пользователя.'}), 500
session['telegram_user_id'] = tma_user_id_str
display_name = tg_user_data.get('first_name') or tg_user_data.get('username') or f"User {tma_user_id_str}"
session['telegram_display_name'] = display_name
return jsonify({'status': 'success', 'redirect_url': url_for('tma_dashboard')})
except Exception as e:
logging.error(f"Error in auth_via_telegram: {e}")
return jsonify({'status': 'error', 'message': 'Внутренняя ошибка сервера при авторизации.'}), 500
TMA_DASHBOARD_HTML_TEMPLATE = '''
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Zeus Cloud</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>''' + BASE_STYLE + '''
.list-editor-items { max-height: 40vh; overflow-y: auto; margin-bottom: 10px; }
.list-editor-item { display: flex; align-items: center; gap: 10px; padding: 5px; border-radius: 8px; }
.list-editor-item:hover { background: #333; }
.list-editor-item input[type="text"] { margin: 0; flex-grow: 1; }
.list-editor-item input[type="number"] { margin: 0; width: 60px; text-align: center; }
.list-editor-item .quantity-controls button { padding: 5px 10px; font-size: 1em; background: #444; }
.list-editor-item .delete-item-btn { color: var(--delete-color); background: none; border: none; font-size: 1.2em; cursor: pointer; }
.list-editor-add-item-form { display: flex; gap: 10px; margin-top: 10px; }
</style></head><body>
<div class="app-header">
<div class="user-info">{{ display_name }}</div>
<div class="view-toggle">
<a href="{{ url_for('tma_archive') }}" style="text-decoration: none;"><button title="Архив"><i class="fa-solid fa-archive"></i></button></a>
<button id="reminders-btn" title="Напоминания"><i class="fa-solid fa-bell"></i></button>
<button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button>
<button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button>
</div>
</div>
<div class="container" id="main-container">
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %}
{% endif %}{% endwith %}
<div class="breadcrumbs">
{% for crumb in breadcrumbs %}
{% if crumb.is_link %}<a href="{{ url_for('tma_dashboard', folder_id=crumb.id) }}">{{ crumb.name if crumb.id != 'root' else 'Главная' }}</a>
{% else %}<span>{{ crumb.name if crumb.id != 'root' else 'Главная' }}</span>{% endif %}
{% if not loop.last %}<span>/</span>{% endif %}
{% endfor %}
</div>
<h2>{{ current_folder.name if current_folder_id != 'root' else 'Главная' }}</h2>
<div id="progress-container"><div id="progress-bar"></div></div>
<div class="file-grid" id="file-container">
{% for item in items %}
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}"
{% if item.type == 'folder' %}
onclick="window.Telegram.WebApp.HapticFeedback.impactOccurred('light'); window.location.href='{{ url_for('tma_dashboard', folder_id=item.id) }}'"
{% elif item.type == 'note' %}
onclick="openNoteModal('{{ item.id }}')"
{% elif item.type == 'todolist' %}
onclick="openListModal('todolist', '{{ item.id }}')"
{% elif item.type == 'shoppinglist' %}
onclick="openListModal('shoppinglist', '{{ item.id }}')"
{% else %}
onclick="openModal('{{ hf_file_url_jinja(item.path) if item.file_type not in ['text', 'pdf'] else (url_for('get_text_content_tma', file_id=item.id) if item.file_type == 'text' else hf_file_url_jinja(item.path, True)) }}', '{{ item.file_type }}', '{{ item.id }}')"
{% endif %}>
<div class="item-preview-wrapper">
{% if item.type == 'folder' %}<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
{% elif item.type == 'note' %}<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
{% elif item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div>
{% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div>
{% elif item.type == 'file' %}
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
{% elif item.file_type == 'video' %}<video class="item-preview" preload="metadata" muted loading="lazy"><source src="{{ hf_file_url_jinja(item.path, True) }}#t=0.5"></video>
{% elif item.file_type == 'pdf' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--accent);"><i class="fa-solid fa-file-pdf"></i></div>
{% elif item.file_type == 'text' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--secondary);"><i class="fa-solid fa-file-lines"></i></div>
{% else %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--text-muted);"><i class="fa-solid fa-file-circle-question"></i></div>
{% endif %}
{% endif %}
</div>
<div class="item-name-info">
<p class="item-name">{{ (item.title if item.type in ['note', 'todolist', 'shoppinglist'] else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p>
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
{% elif item.type in ['note', 'todolist', 'shoppinglist'] %}<p class="item-info">{{ item.modified_date }}</p>{% endif %}
</div>
</div>
{% endfor %}
{% if not items %} <p>Эта папка пуста.</p> {% endif %}
</div>
</div>
<div class="modal" id="mediaModal" onclick="closeModal(event)">
<div class="modal-content">
<span onclick="closeModalManual()" class="modal-close-btn">&times;</span>
<div class="modal-main-content" id="modalContent"></div>
<div class="modal-actions">
<a id="modal-download-btn" class="btn download-btn" style="display: none; width: 80%;">
<i class="fa-solid fa-download"></i> Скачать
</a>
</div>
</div>
</div>
<div id="selection-bar">
<span id="selection-count"></span>
<button id="selection-archive-btn" class="btn" style="background: var(--todolist-color); display:none;" onclick="archiveSelected()"><i class="fa-solid fa-archive"></i></button>
<button id="selection-share-btn" class="btn share-btn" onclick="openShareModal()" style="display:none;"><i class="fa-solid fa-share-alt"></i></button>
<button id="selection-download-btn" class="btn download-btn" onclick="downloadSingleSelected()" style="display:none;"><i class="fa-solid fa-download"></i></button>
<button class="btn" style="background: var(--accent);" onclick="showMoveModal()"><i class="fa-solid fa-arrow-right-to-bracket"></i></button>
<button class="btn delete-btn" onclick="deleteSelected()"><i class="fa-solid fa-trash-can"></i></button>
<button class="btn" style="background: #555;" onclick="toggleSelectionMode(false)">Отмена</button>
</div>
<div class="modal" id="move-modal"><div class="modal-content">
<h4>Переместить в:</h4>
<select id="folder-destination-select">
{% for folder in all_folders_for_move %}<option value="{{ folder.id }}">{{ folder.name }}</option>{% endfor %}
</select>
<button class="btn" style="background: var(--accent); width: 100%; margin-top: 10px;" onclick="moveSelected()">Переместить</button>
<button class="btn" style="background: #555; width: 100%;" onclick="closeMoveModal()">Отмена</button>
</div></div>
<div class="modal" id="fab-modal"><div class="modal-content">
<h4>Добавить в "{{ current_folder.name if current_folder_id != 'root' else 'Главная' }}"</h4>
<div class="fab-options">
<label for="file-input" class="fab-option" id="fab-option-upload">
<i class="fa-solid fa-upload"></i><span>Файлы</span>
</label>
<div class="fab-option" id="fab-option-note" onclick="openNoteModal()">
<i class="fa-solid fa-note-sticky"></i><span>Заметку</span>
</div>
<div class="fab-option" id="fab-option-folder">
<i class="fa-solid fa-folder-plus"></i><span>Папку</span>
</div>
<div class="fab-option" id="fab-option-todolist" onclick="openListModal('todolist')">
<i class="fa-solid fa-list-check"></i><span>Список дел</span>
</div>
<div class="fab-option" id="fab-option-shoppinglist" onclick="openListModal('shoppinglist')">
<i class="fa-solid fa-cart-shopping"></i><span>Покупки</span>
</div>
</div>
<form id="upload-form" method="POST" enctype="multipart/form-data" action="{{ url_for('tma_dashboard') }}" style="display:none;">
<input type="hidden" name="current_folder_id" value="{{ current_folder_id }}">
<input type="file" name="files" id="file-input" multiple required onchange="document.getElementById('upload-btn-modal').click()">
<button type="submit" id="upload-btn-modal"></button>
</form>
<form method="POST" action="{{ url_for('create_folder_tma') }}" id="create-folder-form">
<input type="hidden" name="parent_folder_id" value="{{ current_folder_id }}">
<input type="text" name="folder_name" placeholder="Имя новой папки" required>
<button type="submit" class="btn folder-btn" style="width:100%">Создать</button>
</form>
<button class="btn" style="background: #555; width: 100%; margin-top: 10px;" onclick="closeFabModal()">Закрыть</button>
</div></div>
<div class="modal" id="note-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
<h4 id="note-modal-title">Новая заметка</h4>
<input type="hidden" id="note-id-input">
<input type="text" id="note-title-input" placeholder="Заголовок заметки" style="font-size: 1.1em; margin-bottom: 10px;">
<textarea id="note-content-input" placeholder="Текст заметки..." style="width: 100%; height: 40vh; background: #2a2a2a; color: var(--text-dark); border: 1px solid #333; border-radius: 12px; padding: 10px; font-size: 1em; resize: vertical;"></textarea>
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveNote()">Сохранить</button>
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeNoteModal()">Отмена</button>
</div>
</div></div>
<div class="modal" id="list-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
<h4 id="list-modal-title">Новый список</h4>
<input type="hidden" id="list-id-input">
<input type="hidden" id="list-type-input">
<input type="text" id="list-title-input" placeholder="Название списка" style="font-size: 1.1em; margin-bottom: 10px;">
<div id="list-items-container" class="list-editor-items"></div>
<div id="list-add-item-form-container"></div>
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveList()">Сохранить</button>
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeListModal()">Отмена</button>
</div>
</div></div>
<div class="modal" id="reminders-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
<h4>Напоминания</h4>
<div id="reminders-list" style="max-height: 40vh; overflow-y: auto; margin-bottom: 15px; font-size: 0.9em;"></div>
<h5 style="margin-top: 15px;">Новое напоминание</h5>
<input type="text" id="reminder-text-input" placeholder="Текст напоминания" required>
<input type="datetime-local" id="reminder-datetime-input" required>
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="createReminder()">Создать</button>
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeRemindersModal()">Закрыть</button>
</div>
</div></div>
<div class="modal" id="share-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
<h4 id="share-modal-title">Поделиться</h4>
<div id="existing-links-list" style="max-height: 30vh; overflow-y: auto; margin-bottom: 15px;"></div>
<h5 style="margin-top: 15px;">Создать новую ссылку</h5>
<input type="text" id="share-link-name" placeholder="Название ссылки (необязательно)">
<select id="share-link-duration">
<option value="1">1 час</option><option value="24">24 часа</option><option value="168">7 дней</option><option value="0">Всегда</option>
</select>
<div style="display: flex; gap: 10px; margin-top: 15px;">
<button class="btn share-btn" style="flex-grow: 1;" onclick="createShareLink()">Создать</button>
<button class="btn" style="background: #555; flex-grow: 1;" onclick="closeShareModal()">Закрыть</button>
</div>
</div></div>
<div class="fab-container"><button id="fab" class="fab"><i class="fa-solid fa-plus"></i></button></div>
<script>
window.Telegram.WebApp.ready();
window.Telegram.WebApp.expand();
const haptic = window.Telegram.WebApp.HapticFeedback;
async function openModal(srcOrUrl, type, itemId) {
if (!srcOrUrl) return;
haptic.impactOccurred('light');
const modal = document.getElementById('mediaModal');
const modalContent = document.getElementById('modalContent');
const downloadBtn = document.getElementById('modal-download-btn');
modalContent.innerHTML = '<div class="loading-spinner"></div>';
modal.style.display = 'flex';
if (type !== 'folder' && itemId) {
downloadBtn.onclick = () => { initiateDownload(itemId); };
downloadBtn.style.display = 'inline-block';
} else {
downloadBtn.style.display = 'none';
}
try {
if (type === 'image') modalContent.innerHTML = `<img src="${srcOrUrl}">`;
else if (type === 'video') modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
else if (type === 'pdf') modalContent.innerHTML = `<iframe src="${srcOrUrl}"></iframe>`;
else if (type === 'text') {
const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Ошибка: ${response.statusText}`);
const text = await response.text();
modalContent.innerHTML = `<pre>${text.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</pre>`;
} else initiateDownload(itemId);
} catch (error) { modalContent.innerHTML = `<p>Ошибка предпросмотра: ${error.message}</p>`; }
}
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
function closeModalManual() {
const modal = document.getElementById('mediaModal');
modal.style.display = 'none';
const video = modal.querySelector('video'); if (video) video.pause();
document.getElementById('modalContent').innerHTML = '';
document.getElementById('modal-download-btn').style.display = 'none';
}
async function initiateDownload(fileId) {
haptic.impactOccurred('medium');
const downloadBtn = document.getElementById('modal-download-btn');
const originalHTML = downloadBtn.innerHTML;
downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>';
downloadBtn.onclick = null;
try {
const response = await fetch(`{{ url_for('download_tma', file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
const data = await response.json();
if (data.status === 'success' && data.url) {
tmaDownloadFile(data.url);
closeModalManual();
} else { Telegram.WebApp.showAlert(data.message || 'Не удалось создать ссылку для скачивания.'); }
} catch (error) { Telegram.WebApp.showAlert('Сетевая ошибка при создании ссылки для скачивания.'); }
finally { if (downloadBtn) { downloadBtn.innerHTML = originalHTML; downloadBtn.onclick = () => { initiateDownload(fileId); }; } }
}
function tmaDownloadFile(downloadUrl) {
if (window.Telegram && window.Telegram.WebApp && Telegram.WebApp.openLink) { Telegram.WebApp.openLink(downloadUrl, {try_instant_view: false}); }
else { window.open(downloadUrl, '_blank'); }
}
document.getElementById('upload-form')?.addEventListener('submit', function(e) {
e.preventDefault(); const files = document.getElementById('file-input').files;
if (files.length === 0) { Telegram.WebApp.showAlert('Выберите файлы.'); return; }
if (files.length > 20) { Telegram.WebApp.showAlert('Максимум 20 файлов за раз!'); return; }
closeFabModal();
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
progressContainer.style.display = 'block'; progressBar.style.width = '0%';
const formData = new FormData(this); const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', e => { if (e.lengthComputable) progressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%'; });
xhr.addEventListener('load', () => { haptic.notificationOccurred('success'); window.location.reload(); });
xhr.addEventListener('error', () => { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Ошибка загрузки.'); });
xhr.open('POST', this.action, true); xhr.send(formData);
});
let selectionMode = false; const selectedItems = new Set(); let longPressTimer;
const mainContainer = document.getElementById('main-container');
const selectionBar = document.getElementById('selection-bar');
const selectionCount = document.getElementById('selection-count');
const selectionDownloadBtn = document.getElementById('selection-download-btn');
const selectionShareBtn = document.getElementById('selection-share-btn');
const selectionArchiveBtn = document.getElementById('selection-archive-btn');
const allItems = document.querySelectorAll('.item');
function toggleSelectionMode(enable) {
selectionMode = enable;
selectionBar.classList.toggle('visible', enable);
if(enable) haptic.impactOccurred('heavy');
if (!enable) { selectedItems.clear(); allItems.forEach(item => item.classList.remove('selected')); }
}
function updateSelectionUI() {
selectionCount.textContent = `Выбрано: ${selectedItems.size}`;
if (selectedItems.size !== 1) {
selectionDownloadBtn.style.display = 'none';
selectionShareBtn.style.display = 'none';
selectionArchiveBtn.style.display = 'none';
return;
}
const firstSelectedId = selectedItems.values().next().value;
const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`);
if (!itemElement) return;
const itemType = itemElement.dataset.type;
selectionDownloadBtn.style.display = (itemType === 'file') ? 'inline-block' : 'none';
selectionShareBtn.style.display = (itemType === 'folder' || itemType === 'shoppinglist') ? 'inline-block' : 'none';
selectionArchiveBtn.style.display = (itemType === 'todolist' || itemType === 'shoppinglist') ? 'inline-block' : 'none';
}
allItems.forEach(item => {
item.addEventListener('pointerdown', e => {
if (e.pointerType === 'mouse' && e.button !== 0) return;
longPressTimer = setTimeout(() => {
if (!selectionMode) toggleSelectionMode(true);
item.classList.toggle('selected');
if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id);
updateSelectionUI();
}, 500);
});
item.addEventListener('pointerup', () => clearTimeout(longPressTimer));
item.addEventListener('pointerleave', () => clearTimeout(longPressTimer));
item.addEventListener('click', e => {
if (selectionMode) {
haptic.impactOccurred('light'); e.preventDefault(); e.stopPropagation();
item.classList.toggle('selected');
if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id);
updateSelectionUI();
if (selectedItems.size === 0) toggleSelectionMode(false);
}
}, true);
});
async function performBatchAction(url, body) {
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const result = await response.json();
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); }
} catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); }
finally { toggleSelectionMode(false); }
}
function downloadSingleSelected() {
if (selectedItems.size !== 1) return;
const fileId = selectedItems.values().next().value;
initiateDownload(fileId);
toggleSelectionMode(false);
}
function showMoveModal() { if (selectedItems.size > 0) { haptic.impactOccurred('light'); document.getElementById('move-modal').style.display = 'flex'; }}
function closeMoveModal() { document.getElementById('move-modal').style.display = 'none'; }
function moveSelected() {
const destinationId = document.getElementById('folder-destination-select').value;
performBatchAction('{{ url_for("batch_move_tma") }}', { item_ids: Array.from(selectedItems), destination_id: destinationId });
closeMoveModal();
}
function deleteSelected() {
if (selectedItems.size === 0) return;
haptic.impactOccurred('medium');
Telegram.WebApp.showConfirm(`Удалить ${selectedItems.size} элемент(ов)?`, ok => {
if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); }
});
}
function archiveSelected() {
if (selectedItems.size !== 1) return;
const listId = selectedItems.values().next().value;
haptic.impactOccurred('medium');
performBatchAction(`{{ url_for("archive_list_tma", list_id='__ID__') }}`.replace('__ID__', listId), {});
}
async function openNoteModal(noteId = null) {
haptic.impactOccurred('light');
closeFabModal();
const modal = document.getElementById('note-modal');
const titleEl = document.getElementById('note-modal-title');
const idInput = document.getElementById('note-id-input');
const titleInput = document.getElementById('note-title-input');
const contentInput = document.getElementById('note-content-input');
if (noteId) {
titleEl.textContent = 'Редактировать заметку';
const response = await fetch(`{{ url_for('get_note_tma', note_id='__ID__') }}`.replace('__ID__', noteId));
const data = await response.json();
if (data.status === 'success') {
idInput.value = data.note.id;
titleInput.value = data.note.title;
contentInput.value = data.note.content;
} else { Telegram.WebApp.showAlert('Ошибка загрузки заметки.'); return; }
} else {
titleEl.textContent = 'Новая заметка';
idInput.value = ''; titleInput.value = ''; contentInput.value = '';
}
modal.style.display = 'flex';
}
function closeNoteModal() { document.getElementById('note-modal').style.display = 'none'; }
async function saveNote() {
const id = document.getElementById('note-id-input').value;
const title = document.getElementById('note-title-input').value;
const content = document.getElementById('note-content-input').value;
if (!title.trim()) { Telegram.WebApp.showAlert('Заголовок не может быть пустым.'); return; }
const payload = {
note_id: id, title: title, content: content, parent_folder_id: '{{ current_folder_id }}'
};
const response = await fetch('{{ url_for("create_or_update_note_tma") }}', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const result = await response.json();
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); }
}
function closeRemindersModal() { document.getElementById('reminders-modal').style.display = 'none'; }
async function openRemindersModal() {
haptic.impactOccurred('light');
const modal = document.getElementById('reminders-modal');
const listEl = document.getElementById('reminders-list');
listEl.innerHTML = '<div class="loading-spinner"></div>';
modal.style.display = 'flex';
try {
const response = await fetch('{{ url_for("get_reminders_tma") }}');
const data = await response.json();
if (data.status === 'success') {
listEl.innerHTML = '';
if (data.reminders.length === 0) listEl.innerHTML = '<p>Напоминаний нет.</p>';
data.reminders.forEach(r => {
const dt = new Date(r.due_datetime_local);
const el = document.createElement('div');
el.className = 'reminder-item';
el.innerHTML = `
<div>
<span>${r.text}</span><br>
<small style="color:var(--text-muted)">${dt.toLocaleString()}</small>
</div>
<button onclick="deleteReminder('${r.id}')">&times;</button>
`;
listEl.appendChild(el);
});
} else { listEl.innerHTML = '<p>Ошибка загрузки.</p>'; }
} catch (e) { listEl.innerHTML = '<p>Сетевая ошибка.</p>'; }
}
async function createReminder() {
const text = document.getElementById('reminder-text-input').value;
const datetimeLocal = document.getElementById('reminder-datetime-input').value;
if (!text.trim() || !datetimeLocal) { Telegram.WebApp.showAlert('Заполните все поля.'); return; }
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const payload = { text, datetime_local: datetimeLocal, user_timezone: userTimezone };
const response = await fetch('{{ url_for("create_reminder_tma") }}', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const result = await response.json();
if (result.status === 'success') {
haptic.notificationOccurred('success');
document.getElementById('reminder-text-input').value = '';
document.getElementById('reminder-datetime-input').value = '';
openRemindersModal();
} else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка.'); }
}
async function deleteReminder(reminderId) {
Telegram.WebApp.showConfirm('Удалить это напоминание?', async (ok) => {
if (ok) {
haptic.impactOccurred('heavy');
const response = await fetch(`{{ url_for('delete_reminder_tma', reminder_id='__ID__') }}`.replace('__ID__', reminderId), { method: 'POST' });
const result = await response.json();
if (result.status === 'success') { openRemindersModal(); }
else { Telegram.WebApp.showAlert('Ошибка удаления.'); }
}
});
}
function closeShareModal() { document.getElementById('share-modal').style.display = 'none'; }
async function openShareModal() {
if (selectedItems.size !== 1) return;
haptic.impactOccurred('light');
const itemId = selectedItems.values().next().value;
const itemElement = document.querySelector(`.item[data-id='${itemId}']`);
const itemType = itemElement.dataset.type;
const listEl = document.getElementById('existing-links-list');
document.getElementById('share-modal-title').textContent = itemType === 'folder' ? 'Поделиться папкой' : 'Поделиться списком';
listEl.innerHTML = '<div class="loading-spinner"></div>';
document.getElementById('share-modal').style.display = 'flex';
const response = await fetch(`{{ url_for('get_public_links', item_id='ITEM_ID') }}`.replace('ITEM_ID', itemId));
const data = await response.json();
listEl.innerHTML = '';
if (data.status === 'success' && data.links.length > 0) {
data.links.forEach(link => {
const el = document.createElement('div');
el.className = 'shared-link-item';
const expiration = link.expires_at ? new Date(link.expires_at).toLocaleString() : 'Никогда';
el.innerHTML = `
<div class="shared-link-info">
<strong>${link.name || 'Безымянная ссылка'}</strong>
<small>Истекает: ${expiration}</small>
</div>
<div class="shared-link-actions">
<button onclick="copyToClipboard('${link.url}')" title="Копировать"><i class="fa-solid fa-copy"></i></button>
<button onclick="deleteShareLink('${link.id}')" title="Удалить"><i class="fa-solid fa-trash"></i></button>
</div>`;
listEl.appendChild(el);
});
} else {
listEl.innerHTML = '<p>Публичных ссылок для этого элемента нет.</p>';
}
}
async function createShareLink() {
const itemId = selectedItems.values().next().value;
const itemType = document.querySelector(`.item[data-id='${itemId}']`).dataset.type;
const name = document.getElementById('share-link-name').value;
const duration_hours = document.getElementById('share-link-duration').value;
const response = await fetch('{{ url_for("create_public_link") }}', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ item_id: itemId, item_type: itemType, name: name, duration_hours: parseInt(duration_hours) })
});
const result = await response.json();
if (result.status === 'success') {
haptic.notificationOccurred('success');
openShareModal();
copyToClipboard(result.url);
Telegram.WebApp.showAlert('Ссылка создана и скопирована!');
} else {
haptic.notificationOccurred('error');
Telegram.WebApp.showAlert(result.message || 'Ошибка создания ссылки.');
}
}
async function deleteShareLink(linkId) {
Telegram.WebApp.showConfirm('Удалить эту публичную ссылку?', async (ok) => {
if(ok) {
haptic.impactOccurred('heavy');
const response = await fetch('{{ url_for("delete_public_link") }}', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ link_id: linkId })
});
const result = await response.json();
if (result.status === 'success') { openShareModal(); }
else { Telegram.WebApp.showAlert('Ошибка удаления.'); }
}
});
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
haptic.notificationOccurred('success');
Telegram.WebApp.showAlert('Скопировано!');
}, () => {
haptic.notificationOccurred('error');
});
}
function setupListModal(type, itemsContainer, addFormContainer) {
itemsContainer.innerHTML = '';
if (type === 'todolist') {
addFormContainer.innerHTML = `
<form onsubmit="addTodoItem(event)" class="list-editor-add-item-form">
<input type="text" id="add-task-input" placeholder="Новая задача" required>
<button type="submit" class="btn" style="padding: 10px 15px;">+</button>
</form>`;
} else if (type === 'shoppinglist') {
addFormContainer.innerHTML = `
<form onsubmit="addShoppingItem(event)" class="list-editor-add-item-form">
<input type="text" id="add-shopping-name-input" placeholder="Название товара" required style="flex-grow:1;">
<input type="number" id="add-shopping-qty-input" value="1" min="1" style="width: 70px;">
<button type="submit" class="btn" style="padding: 10px 15px;">+</button>
</form>`;
}
}
function renderTodoItem(item) {
return `<div class="list-editor-item" data-id="${item.id}">
<input type="checkbox" ${item.completed ? 'checked' : ''}>
<input type="text" value="${item.text.replace(/"/g, '&quot;')}">
<button class="delete-item-btn" onclick="this.parentElement.remove()">&times;</button>
</div>`;
}
function renderShoppingItem(item) {
return `<div class="list-editor-item" data-id="${item.id}">
<input type="text" value="${item.name.replace(/"/g, '&quot;')}" class="shopping-item-name">
<div class="quantity-controls" style="display:flex; align-items:center;">
<button type="button" onclick="this.nextElementSibling.stepDown()">-</button>
<input type="number" value="${item.quantity}" min="1" class="shopping-item-qty">
<button type="button" onclick="this.previousElementSibling.stepUp()">+</button>
</div>
<button class="delete-item-btn" onclick="this.parentElement.remove()">&times;</button>
</div>`;
}
function addTodoItem(event) {
event.preventDefault();
const input = document.getElementById('add-task-input');
if (!input.value.trim()) return;
const newItem = { id: 'new_' + Date.now(), text: input.value.trim(), completed: false };
document.getElementById('list-items-container').insertAdjacentHTML('beforeend', renderTodoItem(newItem));
input.value = '';
}
function addShoppingItem(event) {
event.preventDefault();
const nameInput = document.getElementById('add-shopping-name-input');
const qtyInput = document.getElementById('add-shopping-qty-input');
if (!nameInput.value.trim()) return;
const newItem = { id: 'new_' + Date.now(), name: nameInput.value.trim(), quantity: parseInt(qtyInput.value) || 1, purchased: false };
document.getElementById('list-items-container').insertAdjacentHTML('beforeend', renderShoppingItem(newItem));
nameInput.value = '';
qtyInput.value = '1';
}
async function openListModal(type, listId = null) {
haptic.impactOccurred('light');
closeFabModal();
const modal = document.getElementById('list-modal');
const titleEl = document.getElementById('list-modal-title');
const idInput = document.getElementById('list-id-input');
const typeInput = document.getElementById('list-type-input');
const titleInput = document.getElementById('list-title-input');
const itemsContainer = document.getElementById('list-items-container');
const addFormContainer = document.getElementById('list-add-item-form-container');
typeInput.value = type;
setupListModal(type, itemsContainer, addFormContainer);
if (listId) {
titleEl.textContent = type === 'todolist' ? 'Редактировать список дел' : 'Редактировать список покупок';
const response = await fetch(`{{ url_for('get_list_tma', list_id='__ID__') }}`.replace('__ID__', listId));
const data = await response.json();
if (data.status === 'success') {
idInput.value = data.list.id;
titleInput.value = data.list.title;
itemsContainer.innerHTML = '';
if (type === 'todolist') data.list.tasks.forEach(item => itemsContainer.insertAdjacentHTML('beforeend', renderTodoItem(item)));
if (type === 'shoppinglist') data.list.items.forEach(item => itemsContainer.insertAdjacentHTML('beforeend', renderShoppingItem(item)));
} else { Telegram.WebApp.showAlert('Ошибка загрузки списка.'); return; }
} else {
titleEl.textContent = type === 'todolist' ? 'Новый список дел' : 'Новый список покупок';
idInput.value = ''; titleInput.value = ''; itemsContainer.innerHTML = '';
}
modal.style.display = 'flex';
}
function closeListModal() { document.getElementById('list-modal').style.display = 'none'; }
async function saveList() {
const id = document.getElementById('list-id-input').value;
const type = document.getElementById('list-type-input').value;
const title = document.getElementById('list-title-input').value;
if (!title.trim()) { Telegram.WebApp.showAlert('Название не может быть пустым.'); return; }
let payload = { list_id: id, type: type, title: title, parent_folder_id: '{{ current_folder_id }}' };
if (type === 'todolist') {
payload.tasks = Array.from(document.querySelectorAll('#list-items-container .list-editor-item')).map(el => ({
id: el.dataset.id.startsWith('new_') ? null : el.dataset.id,
text: el.querySelector('input[type="text"]').value,
completed: el.querySelector('input[type="checkbox"]').checked
}));
} else if (type === 'shoppinglist') {
payload.items = Array.from(document.querySelectorAll('#list-items-container .list-editor-item')).map(el => ({
id: el.dataset.id.startsWith('new_') ? null : el.dataset.id,
name: el.querySelector('.shopping-item-name').value,
quantity: parseInt(el.querySelector('.shopping-item-qty').value) || 1
}));
}
const response = await fetch('{{ url_for("create_or_update_list_tma") }}', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
const result = await response.json();
if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); }
}
document.getElementById('reminders-btn').addEventListener('click', openRemindersModal);
const gridViewBtn = document.getElementById('grid-view-btn');
const listViewBtn = document.getElementById('list-view-btn');
const fileContainer = document.getElementById('file-container');
function setView(view) {
haptic.impactOccurred('light');
fileContainer.classList.toggle('list-view', view === 'list');
listViewBtn.classList.toggle('active', view === 'list');
gridViewBtn.classList.toggle('active', view !== 'list');
localStorage.setItem('viewMode', view);
}
gridViewBtn.addEventListener('click', () => setView('grid'));
listViewBtn.addEventListener('click', () => setView('list'));
const fab = document.getElementById('fab');
const fabModal = document.getElementById('fab-modal');
fab.addEventListener('click', () => { haptic.impactOccurred('medium'); fabModal.style.display = 'flex'; });
function closeFabModal() {
fabModal.style.display = 'none';
document.getElementById('create-folder-form').style.display = 'none';
}
fabModal.addEventListener('click', e => { if (e.target.id === 'fab-modal') closeFabModal(); });
document.getElementById('fab-option-folder').addEventListener('click', () => {
document.getElementById('create-folder-form').style.display = 'block';
});
document.addEventListener('DOMContentLoaded', () => {
setView(localStorage.getItem('viewMode') || 'grid');
const currentFolderId = '{{ current_folder_id }}';
const parentFolderId = '{{ parent_folder_id }}';
if (currentFolderId !== 'root') {
let backButton = window.Telegram.WebApp.BackButton;
backButton.show();
backButton.onClick(() => { haptic.impactOccurred('light'); window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`; });
} else { window.Telegram.WebApp.BackButton.hide(); }
});
</script></body></html>
'''
TMA_ARCHIVE_PAGE_HTML = '''
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Архив - Zeus Cloud</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>''' + BASE_STYLE + '''</style></head><body>
<div class="app-header">
<div class="user-info">{{ display_name }}</div>
<div class="view-toggle">
<a href="{{ url_for('tma_dashboard') }}" style="text-decoration: none;"><button title="Домой"><i class="fa-solid fa-home"></i></button></a>
</div>
</div>
<div class="container" id="main-container">
<h2>Архив</h2>
<div class="file-grid list-view" id="file-container">
{% for item in items %}
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
<div class="item-preview-wrapper">
{% if item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div>
{% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div>
{% endif %}
</div>
<div class="item-name-info" style="flex-grow: 1;">
<p class="item-name">{{ item.title }}</p>
<p class="item-info">Заархивировано: {{ item.modified_date }}</p>
<p class="item-info">Путь: {{ item.path_string }}</p>
</div>
<button class="btn" onclick="unarchiveItem('{{ item.id }}')" title="Разархивировать" style="background:var(--secondary); padding: 8px 12px; margin-right: 5px;"><i class="fa-solid fa-box-open"></i></button>
<button class="btn delete-btn" onclick="deleteItem('{{ item.id }}')" title="Удалить" style="padding: 8px 12px;"><i class="fa-solid fa-trash"></i></button>
</div>
{% endfor %}
{% if not items %}<p>Архив пуст.</p>{% endif %}
</div>
</div>
<script>
window.Telegram.WebApp.ready();
window.Telegram.WebApp.expand();
const haptic = window.Telegram.WebApp.HapticFeedback;
async function performAction(url, confirmMessage) {
Telegram.WebApp.showConfirm(confirmMessage, async (ok) => {
if (ok) {
haptic.impactOccurred('heavy');
try {
const response = await fetch(url, { method: 'POST' });
const result = await response.json();
if (result.status === 'success') {
haptic.notificationOccurred('success');
window.location.reload();
} else {
haptic.notificationOccurred('error');
Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.');
}
} catch (error) {
haptic.notificationOccurred('error');
Telegram.WebApp.showAlert('Сетевая ошибка.');
}
}
});
}
function unarchiveItem(itemId) {
const url = `{{ url_for("archive_list_tma", list_id='__ID__') }}`.replace('__ID__', itemId) + '?unarchive=true';
performAction(url, 'Разархивировать этот список?');
}
function deleteItem(itemId) {
const url = `{{ url_for("batch_delete_tma") }}`;
Telegram.WebApp.showConfirm('Вы уверены, что хотите удалить этот список навсегда?', async (ok) => {
if (ok) {
haptic.impactOccurred('heavy');
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_ids: [itemId] })
});
const result = await response.json();
if (result.status === 'success') {
haptic.notificationOccurred('success');
window.location.reload();
} else {
haptic.notificationOccurred('error');
Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.');
}
} catch (error) {
haptic.notificationOccurred('error');
Telegram.WebApp.showAlert('Сетевая ошибка.');
}
}
});
}
document.addEventListener('DOMContentLoaded', () => {
let backButton = window.Telegram.WebApp.BackButton;
backButton.show();
backButton.onClick(() => {
haptic.impactOccurred('light');
window.location.href = `{{ url_for('tma_dashboard') }}`;
});
});
</script>
</body></html>
'''
@app.route('/tma_dashboard', methods=['GET', 'POST'])
def tma_dashboard():
if 'telegram_user_id' not in session:
flash('Пожалуйста, авторизуйтесь через Telegram.', 'error')
return redirect(url_for('tma_entry_page'))
tma_user_id = session['telegram_user_id']
display_name = session.get('telegram_display_name', 'Пользователь')
data = load_data()
if tma_user_id not in data['users']:
session.clear()
flash('Пользователь не найден. Пожалуйста, перезапустите приложение.', 'error')
return redirect(url_for('tma_entry_page'))
user_data = data['users'][tma_user_id]
initialize_user_filesystem_tma(user_data, tma_user_id)
current_folder_id = request.args.get('folder_id', 'root')
current_folder, parent_folder = find_node_by_id(user_data['filesystem'], current_folder_id)
if not current_folder or current_folder.get('type') != 'folder':
flash('Папка не найдена!', 'error')
current_folder_id = 'root'
current_folder, parent_folder = find_node_by_id(user_data['filesystem'], 'root')
parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root'
items_in_folder = [item for item in current_folder.get('children', []) if not item.get('archived')]
items_in_folder = sorted(items_in_folder, key=lambda x: (x['type'] not in ['folder', 'note'], x.get('name', x.get('original_filename', x.get('title', ''))).lower()))
if request.method == 'POST':
if not HF_TOKEN_WRITE:
flash('Загрузка невозможна: токен для записи не настроен.', 'error')
return redirect(url_for('tma_dashboard', folder_id=current_folder_id))
files = request.files.getlist('files')
if not files or all(not f.filename for f in files):
flash('Файлы для загрузки не выбраны.', 'error')
return redirect(url_for('tma_dashboard', folder_id=current_folder_id))
target_folder_id = request.form.get('current_folder_id', 'root')
uploaded_count = 0
errors_list = []
api = HfApi()
for file_obj in files:
if file_obj and file_obj.filename:
original_filename = secure_filename(file_obj.filename)
name_part, ext_part = os.path.splitext(original_filename)
unique_suffix = uuid.uuid4().hex[:8]
unique_filename = f"{name_part}_{unique_suffix}{ext_part}"
file_id = uuid.uuid4().hex
hf_path = f"cloud_files/{tma_user_id}/{target_folder_id}/{unique_filename}"
temp_path = os.path.join(UPLOAD_FOLDER, f"{file_id}_{unique_filename}")
try:
file_obj.save(temp_path)
api.upload_file(path_or_fileobj=temp_path, path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
file_info = {'type': 'file', 'id': file_id, 'original_filename': original_filename, 'unique_filename': unique_filename, 'path': hf_path, 'file_type': get_file_type(original_filename), 'upload_date': datetime.now().strftime('%Y-%m-%d')}
if add_node(user_data['filesystem'], target_folder_id, file_info):
uploaded_count += 1
except Exception as e:
errors_list.append(f"Ошибка загрузки {original_filename}: {e}")
finally:
if os.path.exists(temp_path): os.remove(temp_path)
if uploaded_count > 0:
try: save_data(data); flash(f'{uploaded_count} файл(ов) успешно загружено!')
except Exception: flash('Файлы загружены, но ошибка сохранения метаданных.', 'error')
if errors_list:
for error_msg in errors_list: flash(error_msg, 'error')
return redirect(url_for('tma_dashboard', folder_id=target_folder_id))
breadcrumbs = []
temp_id = current_folder_id
while temp_id:
node, parent_node_bc = find_node_by_id(user_data['filesystem'], temp_id)
if not node: break
is_link = (node['id'] != current_folder_id)
breadcrumbs.append({'id': node['id'], 'name': node.get('name', 'Root'), 'is_link': is_link})
if not parent_node_bc: break
temp_id = parent_node_bc.get('id')
breadcrumbs.reverse()
all_folders_for_move = get_all_folders(user_data['filesystem'])
return render_template_string(TMA_DASHBOARD_HTML_TEMPLATE, display_name=display_name, items=items_in_folder, current_folder_id=current_folder_id, current_folder=current_folder, parent_folder_id=parent_folder_id, breadcrumbs=breadcrumbs, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}", is_tma_user_admin_flag=is_admin_tma(), all_folders_for_move=all_folders_for_move)
@app.route('/tma_archive')
def tma_archive():
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
tma_user_id = session['telegram_user_id']
display_name = session.get('telegram_display_name', 'Пользователь')
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return redirect(url_for('tma_entry_page'))
archived_items = []
def find_archived(node):
if node.get('type') == 'folder':
for child in node.get('children', []):
if child.get('archived'):
child['path_string'] = get_node_path_string(user_data['filesystem'], node.get('id'))
archived_items.append(child)
if child.get('type') == 'folder':
find_archived(child)
find_archived(user_data.get('filesystem'))
sorted_items = sorted(archived_items, key=lambda x: x.get('modified_date', ''), reverse=True)
return render_template_string(TMA_ARCHIVE_PAGE_HTML, display_name=display_name, items=sorted_items)
@app.route('/create_folder_tma', methods=['POST'])
def create_folder_tma():
if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return redirect(url_for('tma_entry_page'))
parent_folder_id = request.form.get('parent_folder_id', 'root')
folder_name = request.form.get('folder_name', '').strip()
if not folder_name:
flash('Имя папки не может быть пустым!', 'error')
else:
folder_id = uuid.uuid4().hex
folder_data = {'type': 'folder', 'id': folder_id, 'name': folder_name, 'children': []}
if add_node(user_data['filesystem'], parent_folder_id, folder_data):
try: save_data(data); flash(f'Папка "{folder_name}" создана.')
except Exception: flash('Ошибка сохранения данных.', 'error')
else: flash('Не удалось найти родительскую папку.', 'error')
return redirect(url_for('tma_dashboard', folder_id=parent_folder_id))
def get_item_node_for_user(item_id):
if not (session.get('telegram_user_id') or session.get('admin_browser_logged_in')):
return None
data = load_data()
if session.get('admin_browser_logged_in'):
for uid, udata in data.get('users', {}).items():
node, _ = find_node_by_id(udata.get('filesystem', {}), item_id)
if node: return node
else:
user_data = data['users'].get(session['telegram_user_id'])
if user_data:
node, _ = find_node_by_id(user_data.get('filesystem', {}), item_id)
if node: return node
return None
def get_file_node_for_admin(tma_user_id_str, file_id):
if not session.get('admin_browser_logged_in'):
return None
data = load_data()
user_data = data['users'].get(tma_user_id_str)
if user_data:
node, _ = find_node_by_id(user_data.get('filesystem', {}), file_id)
if node and node.get('type') == 'file':
return node
return None
@app.route('/download_tma/<file_id>')
def download_tma(file_id):
file_node = get_item_node_for_user(file_id)
if not file_node or file_node.get('type') != 'file':
return jsonify({'status': 'error', 'message': 'Файл не найден или доступ запрещен'}), 404
token = uuid.uuid4().hex
cache.set(f"download_token_{token}", file_node, timeout=300)
public_url = url_for('public_download', token=token, _external=True)
return jsonify({'status': 'success', 'url': public_url})
@app.route('/public_download/<token>')
def public_download(token):
file_node = cache.get(f"download_token_{token}")
if not file_node:
return Response("Ссылка для скачивания недействительна или истекла.", status=404)
hf_path = file_node.get('path')
original_filename = file_node.get('original_filename', 'downloaded_file')
if not hf_path:
return Response("Ошибка: Путь к файлу не найден.", status=500)
try:
hf_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(hf_path)}"
headers = {}
if HF_TOKEN_READ:
headers["Authorization"] = f"Bearer {HF_TOKEN_READ}"
req = requests.get(hf_url, headers=headers, stream=True, allow_redirects=True)
req.raise_for_status()
encoded_filename = quote(original_filename)
response_headers = {
'Content-Type': req.headers.get('Content-Type', 'application/octet-stream'),
'Content-Disposition': f"attachment; filename*=UTF-8''{encoded_filename}",
'Content-Length': req.headers.get('Content-Length')
}
response_headers = {k: v for k, v in response_headers.items() if v is not None}
return Response(stream_with_context(req.iter_content(chunk_size=8192)), headers=response_headers)
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
logging.error(f"File not found on Hugging Face during streaming: {hf_path}")
return Response("Файл не найден на удаленном хранилище.", status=404)
else:
logging.error(f"HTTP error downloading from HF via streaming: {e}")
return Response(f'Ошибка HTTP при скачивании файла: {e}', status=502)
except Exception as e:
logging.error(f"Error streaming with token {token} from HF: {e}")
return Response(f'Ошибка скачивания файла: {e}', status=502)
@app.route('/batch_download_tma')
def batch_download_tma():
if 'telegram_user_id' not in session: return Response("Unauthorized", 401)
file_ids_str = request.args.get('file_ids')
if not file_ids_str: return Response("No file IDs provided", 400)
file_ids = file_ids_str.split(',')
temp_zip_file = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
try:
with zipfile.ZipFile(temp_zip_file.name, 'w', zipfile.ZIP_DEFLATED) as zf:
for file_id in file_ids:
file_node = get_item_node_for_user(file_id)
if file_node and file_node.get('path'):
hf_path = file_node['path']
original_filename = file_node.get('original_filename', file_id)
try:
local_file_path = hf_hub_download(
repo_id=REPO_ID,
filename=hf_path,
repo_type="dataset",
token=HF_TOKEN_READ,
cache_dir=os.path.join(UPLOAD_FOLDER, 'hf_download_cache')
)
zf.write(local_file_path, arcname=original_filename)
except Exception as e:
logging.error(f"Failed to download and add {original_filename} to zip: {e}")
return send_file(temp_zip_file.name, as_attachment=True, download_name='archive.zip', mimetype='application/zip')
finally:
if os.path.exists(temp_zip_file.name):
os.unlink(temp_zip_file.name)
@app.route('/batch_delete_tma', methods=['POST'])
def batch_delete_tma():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404
item_ids = request.json.get('item_ids', [])
if not item_ids: return jsonify({'status': 'error', 'message': 'Не выбраны элементы.'}), 400
api = HfApi()
success_count = 0; errors = []
for item_id in item_ids:
node, _ = find_node_by_id(user_data['filesystem'], item_id)
if not node:
errors.append(f"Элемент {item_id} не найден.")
continue
item_type = node.get('type')
item_name = node.get('name') or node.get('title') or node.get('original_filename')
if item_type == 'folder':
if node.get('children'): errors.append(f'Папка "{item_name}" не пуста.'); continue
if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
else: errors.append(f'Ошибка удаления папки "{item_name}".')
elif item_type in ['note', 'todolist', 'shoppinglist']:
if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
else: errors.append(f'Ошибка удаления "{item_name}".')
elif item_type == 'file':
try:
if node.get('path') and HF_TOKEN_WRITE: api.delete_file(path_in_repo=node['path'], repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
except hf_utils.EntryNotFoundError: pass
except Exception as e: errors.append(f'Ошибка удаления "{item_name}" с сервера: {e}'); continue
if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
else: errors.append(f'Ошибка удаления "{item_name}" из базы.')
if success_count > 0:
try: save_data(data)
except Exception as e: return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'})
if errors: return jsonify({'status': 'error', 'message': f'Удалено {success_count}. Ошибки: ' + "; ".join(errors)})
return jsonify({'status': 'success', 'message': f'Удалено {success_count} элемент(ов).'})
@app.route('/batch_move_tma', methods=['POST'])
def batch_move_tma():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404
item_ids = request.json.get('item_ids', [])
destination_id = request.json.get('destination_id')
if not item_ids or not destination_id: return jsonify({'status': 'error', 'message': 'Не указаны файлы или папка.'}), 400
destination_node, _ = find_node_by_id(user_data['filesystem'], destination_id)
if not destination_node or destination_node.get('type') != 'folder': return jsonify({'status': 'error', 'message': 'Папка назначения не найдена.'}), 404
descendant_ids = set()
for item_id in item_ids:
node, _ = find_node_by_id(user_data['filesystem'], item_id)
if node and node.get('type') == 'folder':
queue = [node]
while queue:
curr = queue.pop(0)
descendant_ids.add(curr.get('id'))
if 'children' in curr: queue.extend(curr['children'])
if destination_id in descendant_ids: return jsonify({'status': 'error', 'message': 'Нельзя переместить папку в саму себя.'})
moved_count = 0; errors = []
for item_id in item_ids:
if item_id == destination_id: continue
removed, node_to_move = remove_node(user_data['filesystem'], item_id)
if removed and node_to_move:
if add_node(user_data['filesystem'], destination_id, node_to_move): moved_count += 1
else: errors.append(f'Ошибка добавления {item_id} в новую папку.')
else: errors.append(f'Не удалось извлечь {item_id}.')
if moved_count > 0:
try: save_data(data)
except Exception as e: return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'})
if errors: return jsonify({'status': 'error', 'message': f'Перемещено {moved_count}. Ошибки: ' + "; ".join(errors)})
return jsonify({'status': 'success', 'message': f'Перемещено {moved_count} элемент(ов).'})
@app.route('/get_text_content_tma/<file_id>')
def get_text_content_tma(file_id):
file_node = get_item_node_for_user(file_id)
if not file_node or file_node.get('file_type') != 'text': return Response("Текстовый файл не найден", 404)
hf_path = file_node.get('path')
if not hf_path: return Response("Ошибка: путь к файлу отсутствует", 500)
file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(hf_path)}?download=true"
try:
req_headers = {};
if HF_TOKEN_READ: req_headers["authorization"] = f"Bearer {HF_TOKEN_READ}"
response = requests.get(file_url, headers=req_headers)
response.raise_for_status()
if len(response.content) > 1 * 1024 * 1024: return Response("Файл слишком большой для предпросмотра.", 413)
try: text_content = response.content.decode('utf-8')
except UnicodeDecodeError: text_content = response.content.decode('latin-1', errors='ignore')
return Response(text_content, mimetype='text/plain')
except Exception as e: return Response(f"Ошибка загрузки: {e}", 502)
@app.route('/get_note_tma/<note_id>')
def get_note_tma(note_id):
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
note_node = get_item_node_for_user(note_id)
if not note_node or note_node.get('type') != 'note':
return jsonify({'status': 'error', 'message': 'Note not found'}), 404
return jsonify({'status': 'success', 'note': note_node})
@app.route('/create_or_update_note_tma', methods=['POST'])
def create_or_update_note_tma():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
payload = request.json
note_id = payload.get('note_id')
title = payload.get('title', '').strip()
content = payload.get('content', '')
parent_folder_id = payload.get('parent_folder_id', 'root')
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
if not title: return jsonify({'status': 'error', 'message': 'Title cannot be empty.'}), 400
if note_id:
node, _ = find_node_by_id(user_data['filesystem'], note_id)
if not node or node.get('type') != 'note':
return jsonify({'status': 'error', 'message': 'Note not found'}), 404
node['title'] = title
node['content'] = content
node['modified_date'] = now_str
else:
new_note_id = uuid.uuid4().hex
note_data = {
'type': 'note', 'id': new_note_id, 'title': title, 'content': content,
'created_date': now_str, 'modified_date': now_str
}
if not add_node(user_data['filesystem'], parent_folder_id, note_data):
return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404
try:
save_data(data)
return jsonify({'status': 'success', 'message': 'Note saved.'})
except Exception as e:
return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500
@app.route('/get_list_tma/<list_id>')
def get_list_tma(list_id):
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
list_node = get_item_node_for_user(list_id)
if not list_node or list_node.get('type') not in ['todolist', 'shoppinglist']:
return jsonify({'status': 'error', 'message': 'List not found'}), 404
return jsonify({'status': 'success', 'list': list_node})
@app.route('/create_or_update_list_tma', methods=['POST'])
def create_or_update_list_tma():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
payload = request.json
list_id = payload.get('list_id')
list_type = payload.get('type')
title = payload.get('title', '').strip()
parent_folder_id = payload.get('parent_folder_id', 'root')
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
if not title or list_type not in ['todolist', 'shoppinglist']:
return jsonify({'status': 'error', 'message': 'Invalid data provided.'}), 400
if list_id:
node, _ = find_node_by_id(user_data['filesystem'], list_id)
if not node or node.get('type') != list_type:
return jsonify({'status': 'error', 'message': 'List not found'}), 404
node['title'] = title
node['modified_date'] = now_str
if list_type == 'todolist':
node['tasks'] = payload.get('tasks', [])
for task in node['tasks']:
if not task.get('id'): task['id'] = uuid.uuid4().hex
elif list_type == 'shoppinglist':
existing_items = {item['id']: item for item in node.get('items', [])}
new_items_data = payload.get('items', [])
updated_items = []
for item_data in new_items_data:
item_id = item_data.get('id')
if item_id and item_id in existing_items:
existing_items[item_id].update(item_data)
updated_items.append(existing_items[item_id])
else:
item_data['id'] = uuid.uuid4().hex
item_data['purchased'] = False
updated_items.append(item_data)
node['items'] = updated_items
else:
new_list_id = uuid.uuid4().hex
list_data = {
'type': list_type, 'id': new_list_id, 'title': title, 'archived': False,
'created_date': now_str, 'modified_date': now_str
}
if list_type == 'todolist':
list_data['tasks'] = payload.get('tasks', [])
for task in list_data['tasks']: task['id'] = uuid.uuid4().hex
elif list_type == 'shoppinglist':
list_data['items'] = payload.get('items', [])
for item in list_data['items']:
item['id'] = uuid.uuid4().hex
item['purchased'] = False
if not add_node(user_data['filesystem'], parent_folder_id, list_data):
return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404
try: save_data(data); return jsonify({'status': 'success', 'message': 'List saved.'})
except Exception as e: return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500
@app.route('/archive_list_tma/<list_id>', methods=['POST'])
def archive_list_tma(list_id):
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
list_node = get_item_node_for_user(list_id)
if not list_node or list_node.get('type') not in ['todolist', 'shoppinglist']:
return jsonify({'status': 'error', 'message': 'List not found'}), 404
unarchive = request.args.get('unarchive', 'false').lower() == 'true'
list_node['archived'] = not unarchive
list_node['modified_date'] = datetime.now().strftime('%Y-%m-%d %H:%M')
try: save_data(load_data()); return jsonify({'status': 'success'})
except Exception as e: return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
@app.route('/get_reminders_tma')
def get_reminders_tma():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
user_data = load_data()['users'].get(session['telegram_user_id'])
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', ''))
return jsonify({'status': 'success', 'reminders': reminders})
@app.route('/create_reminder_tma', methods=['POST'])
def create_reminder_tma():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
payload = request.json
text = payload.get('text', '').strip()
dt_local_str = payload.get('datetime_local')
user_tz_str = payload.get('user_timezone', 'UTC')
if not text or not dt_local_str:
return jsonify({'status': 'error', 'message': 'Missing required fields'}), 400
try:
user_tz = ZoneInfo(user_tz_str)
except ZoneInfoNotFoundError:
user_tz = pytz.timezone('UTC')
dt_local = datetime.fromisoformat(dt_local_str)
dt_aware = dt_local.astimezone(user_tz)
dt_utc = dt_aware.astimezone(pytz.utc)
new_reminder = {
'id': uuid.uuid4().hex, 'text': text,
'due_datetime_utc': dt_utc.isoformat().replace('+00:00', 'Z'),
'due_datetime_local': dt_local.isoformat(),
'user_timezone': user_tz_str, 'notified': False
}
user_data.setdefault('reminders', []).append(new_reminder)
try:
save_data(data)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
@app.route('/delete_reminder_tma/<reminder_id>', methods=['POST'])
def delete_reminder_tma(reminder_id):
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
reminders = user_data.get('reminders', [])
user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id]
try:
save_data(data)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
@app.route('/tma_logout')
def tma_logout():
session.clear()
flash('Вы вышли из сессии приложения.')
return redirect(url_for('tma_entry_page'))
@app.route('/create_public_link', methods=['POST'])
def create_public_link():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404
payload = request.json
item_id = payload.get('item_id')
item_type = payload.get('item_type')
name = payload.get('name')
duration_hours = payload.get('duration_hours', 0)
item_node, _ = find_node_by_id(user_data['filesystem'], item_id)
if not item_node or item_node.get('type') != item_type or item_type not in ['folder', 'shoppinglist']:
return jsonify({'status': 'error', 'message': 'Элемент не найден или не может быть опубликован.'}), 404
now = datetime.now(pytz.utc)
expires_at_iso = None
if duration_hours > 0:
expires_at = now + timedelta(hours=duration_hours)
expires_at_iso = expires_at.isoformat()
link_id = uuid.uuid4().hex
link_data = {
'id': link_id, 'user_id': tma_user_id, 'item_id': item_id, 'item_type': item_type,
'name': name, 'created_at': now.isoformat(), 'expires_at': expires_at_iso
}
data['shared_links'][link_id] = link_data
item_node.setdefault('public_links', []).append(link_id)
try:
save_data(data)
if item_type == 'folder':
public_url = url_for('shared_folder_view', link_id=link_id, _external=True)
else: # shoppinglist
public_url = url_for('shared_list_view', link_id=link_id, _external=True)
return jsonify({'status': 'success', 'url': public_url})
except Exception as e:
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
@app.route('/delete_public_link', methods=['POST'])
def delete_public_link():
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
link_id = request.json.get('link_id')
link_data = data['shared_links'].get(link_id)
if not link_data or link_data.get('user_id') != tma_user_id:
return jsonify({'status': 'error', 'message': 'Ссылка не найдена или нет доступа.'}), 404
item_id = link_data.get('item_id')
user_data = data['users'].get(tma_user_id)
if user_data:
item, _ = find_node_by_id(user_data['filesystem'], item_id)
if item and 'public_links' in item:
item['public_links'] = [l for l in item['public_links'] if l != link_id]
del data['shared_links'][link_id]
try:
save_data(data)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
@app.route('/get_public_links/<item_id>')
def get_public_links(item_id):
if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
tma_user_id = session['telegram_user_id']
data = load_data()
user_data = data['users'].get(tma_user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404
item, _ = find_node_by_id(user_data['filesystem'], item_id)
if not item: return jsonify({'status': 'error', 'message': 'Элемент не найден.'}), 404
link_ids = item.get('public_links', [])
links_details = []
for link_id in link_ids:
link_data = data['shared_links'].get(link_id)
if link_data:
if link_data.get('item_type') == 'folder':
link_data['url'] = url_for('shared_folder_view', link_id=link_id, _external=True)
else:
link_data['url'] = url_for('shared_list_view', link_id=link_id, _external=True)
links_details.append(link_data)
return jsonify({'status': 'success', 'links': links_details})
@app.route('/shared/<link_id>')
@app.route('/shared/<link_id>/<subfolder_id>')
def shared_folder_view(link_id, subfolder_id=None):
data = load_data()
link_data = data['shared_links'].get(link_id)
if not link_data or link_data.get('item_type') != 'folder': return "Ссылка недействительна.", 404
if link_data.get('expires_at'):
expires_at = datetime.fromisoformat(link_data['expires_at'])
if datetime.now(pytz.utc) > expires_at:
return "Срок действия ссылки истек.", 410
user_id = link_data['user_id']
user_data = data['users'].get(user_id)
if not user_data: return "Владелец не найден.", 404
folder_id_to_show = subfolder_id if subfolder_id else link_data['item_id']
folder_node, _ = find_node_by_id(user_data['filesystem'], folder_id_to_show)
if not folder_node or folder_node.get('type') != 'folder':
return "Папка не найдена.", 404
items_in_folder = sorted(folder_node.get('children', []), key=lambda x: (x['type'] != 'folder', x.get('name', x.get('original_filename', x.get('title', ''))).lower()))
return render_template_string(PUBLIC_FOLDER_PAGE_HTML, folder=folder_node, items=items_in_folder, user=user_data, link=link_data, hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}")
@app.route('/shared_list/<link_id>')
def shared_list_view(link_id):
data = load_data()
link_data = data['shared_links'].get(link_id)
if not link_data or link_data.get('item_type') != 'shoppinglist': return "Ссылка недействительна.", 404
if link_data.get('expires_at'):
expires_at = datetime.fromisoformat(link_data['expires_at'])
if datetime.now(pytz.utc) > expires_at: return "Срок действия ссылки истек.", 410
user_id = link_data['user_id']
user_data = data['users'].get(user_id)
if not user_data: return "Владелец не найден.", 404
list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
if not list_node or list_node.get('type') != 'shoppinglist': return "Список не найден.", 404
return render_template_string(PUBLIC_SHOPPING_LIST_PAGE_HTML, list_data=list_node, user=user_data, link=link_data)
@app.route('/get_shared_list_data/<link_id>')
def get_shared_list_data(link_id):
data = load_data()
link_data = data['shared_links'].get(link_id)
if not link_data or link_data.get('item_type') != 'shoppinglist': return jsonify({'status':'error', 'message':'Ссылка недействительна'}), 404
user_id = link_data['user_id']
user_data = data['users'].get(user_id)
if not user_data: return jsonify({'status':'error', 'message':'Владелец не найден'}), 404
list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
if not list_node: return jsonify({'status':'error', 'message':'Список не найден'}), 404
return jsonify({'status': 'success', 'items': list_node.get('items', [])})
@app.route('/update_shared_item_status/<link_id>', methods=['POST'])
def update_shared_item_status(link_id):
data = load_data()
link_data = data['shared_links'].get(link_id)
if not link_data or link_data.get('item_type') != 'shoppinglist': return jsonify({'status': 'error', 'message': 'Ссылка недействительна'}), 404
user_id = link_data['user_id']
user_data = data['users'].get(user_id)
if not user_data: return jsonify({'status': 'error', 'message': 'Владелец не найден'}), 404
list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
if not list_node: return jsonify({'status': 'error', 'message': 'Список не найден'}), 404
payload = request.json
item_id_to_update = payload.get('item_id')
purchased_status = payload.get('purchased')
item_found = False
for item in list_node.get('items', []):
if item.get('id') == item_id_to_update:
item['purchased'] = purchased_status
item_found = True
break
if item_found:
try:
save_data(data)
return jsonify({'status': 'success'})
except Exception as e:
return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
else:
return jsonify({'status': 'error', 'message': 'Элемент не найден в списке'}), 404
@app.route('/public_download/<link_id>/<item_id>')
def public_download_via_link(link_id, item_id):
data = load_data()
link_data = data['shared_links'].get(link_id)
if not link_data: return Response("Ссылка недействительна.", status=404)
if link_data.get('expires_at'):
expires_at = datetime.fromisoformat(link_data['expires_at'])
if datetime.now(pytz.utc) > expires_at:
return Response("Срок действия ссылки истек.", status=410)
user_id = link_data['user_id']
user_data = data['users'].get(user_id)
if not user_data: return Response("Владелец не найден.", status=404)
item_node, _ = find_node_by_id(user_data['filesystem'], item_id)
if not item_node: return Response("Элемент не найден.", status=404)
token = uuid.uuid4().hex
cache.set(f"download_token_{token}", item_node, timeout=300)
return redirect(url_for('public_download', token=token))
ADMIN_LOGIN_HTML = '''
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>''' + BASE_STYLE + '''
body { display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.login-card { background: var(--card-bg-dark); padding: 40px; border-radius: 16px; box-shadow: var(--shadow); width: 100%; max-width: 400px; }
h1 { text-align: center; margin-bottom: 25px; color: var(--primary); }
</style></head><body>
<div class="login-card">
<h1>Admin Login</h1>
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %}
{% endif %}{% endwith %}
<form method="post">
<input type="hidden" name="next" value="{{ request.args.get('next', '') }}">
<label for="username">Username</label><input type="text" name="username" required>
<label for="password">Password</label><input type="password" name="password" required>
<button type="submit" class="btn" style="width:100%;">Login</button>
</form>
</div></body></html>
'''
ADMIN_PANEL_HTML = '''
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin Panel</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>''' + BASE_STYLE + '''
.user-list { list-style: none; padding: 0; }
.user-item { background: var(--card-bg-dark); border-radius: 12px; margin-bottom: 10px; padding: 15px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;}
.user-item:hover { background: #2a2a2a; }
.user-details { display: flex; align-items: center; gap: 15px; flex-grow: 1; }
.user-avatar { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; background-color: #333; }
.user-info span { display: block; }
.user-info .id { font-size: 0.8em; color: var(--text-muted); }
.user-actions { display: flex; gap: 10px; flex-shrink: 0; }
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;}
.search-form { display: flex; gap: 10px; flex-grow: 1; max-width: 400px;}
.search-form input { margin: 0; }
.search-form .btn { padding: 14px 20px; }
</style></head><body>
<div class="container">
<div class="header-actions">
<h2>Admin Panel ({{ users|length }})</h2>
<a href="{{ url_for('admin_logout') }}" class="btn delete-btn">Logout</a>
</div>
<div class="header-actions">
<form method="get" action="{{ url_for('admin_panel') }}" class="search-form">
<input type="search" name="q" placeholder="Search by name or username..." value="{{ search_query or '' }}">
<button type="submit" class="btn">Search</button>
</form>
</div>
<ul class="user-list">
{% for user_id, user in users %}
<li class="user-item">
<div class="user-details">
{% if user.photo_url %}<img src="{{ user.photo_url }}" alt="Avatar" class="user-avatar">
{% else %}<div class="user-avatar" style="display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.5em; color: var(--primary);">{{ user.get('first_name', 'U')[0] }}</div>{% endif %}
<div class="user-info">
<strong>{{ user.get('first_name', 'N/A') }} {{ user.get('last_name', '') }}</strong> (@{{ user.get('telegram_username', 'N/A') }})
<span class="id">ID: {{ user_id }}</span>
<span class="id">Created: {{ user.get('created_at', 'N/A') }}</span>
<span class="id">Items: <strong>{{ user.get('item_count', 0) }}</strong></span>
<span class="id">Reminders: <strong>{{ user.get('reminders', [])|length }}</strong></span>
</div>
</div>
<div class="user-actions">
<a href="{{ url_for('admin_user_files', tma_user_id_str=user_id) }}" class="btn">View Items</a>
<a href="{{ url_for('admin_user_reminders', tma_user_id_str=user_id) }}" class="btn folder-btn">Reminders</a>
</div>
</li>
{% else %}
<li>No users found.</li>
{% endfor %}
</ul>
</div></body></html>
'''
ADMIN_USER_FILES_HTML = '''
<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - User Files</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>''' + BASE_STYLE + '''
.admin-header { padding-bottom: 15px; border-bottom: 1px solid #333; margin-bottom: 20px; }
.admin-header h1 { font-size: 1.5em; }
.admin-header .user-details { color: var(--text-muted); }
.item-actions-admin { position: absolute; top: 5px; right: 5px; display: flex; gap: 5px; }
.item-actions-admin form button { background: var(--delete-color); border: none; color: white; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s; }
.item:hover .item-actions-admin form button { opacity: 1; }
.list-view .item .item-actions-admin { display: flex; position: static; }
.list-view .item .item-actions-admin form button { opacity: 1; }
</style></head><body>
<div class="app-header">
<div class="user-info"><a href="{{ url_for('admin_panel') }}" style="color: var(--primary); text-decoration: none;">Admin Panel</a></div>
<div class="view-toggle">
<button id="grid-view-btn" title="Grid View"><i class="fa fa-th-large"></i></button>
<button id="list-view-btn" title="List View"><i class="fa fa-bars"></i></button>
</div>
</div>
<div class="container">
<div class="admin-header">
<h1>Items for {{ user.get('first_name', 'N/A') }}</h1>
<div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %}
{% endif %}{% endwith %}
<div class="breadcrumbs">
{% for crumb in breadcrumbs %}
{% if crumb.is_link %}<a href="{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=crumb.id) }}">{{ crumb.name if crumb.id != 'root' else 'Root' }}</a>
{% else %}<span>{{ crumb.name if crumb.id != 'root' else 'Root' }}</span>{% endif %}
{% if not loop.last %}<span>/</span>{% endif %}
{% endfor %}
</div>
<h2>{{ current_folder.name if current_folder_id != 'root' else 'Root Folder' }}</h2>
<div class="file-grid" id="file-container">
{% for item in items %}
<div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
<div class="item-preview-wrapper"
{% if item.type == 'folder' %} onclick="window.location.href='{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=item.id) }}'"
{% elif item.type in ['note', 'todolist', 'shoppinglist'] %} onclick="openModal(null, '{{item.type}}', '{{ item.id }}')"
{% else %} onclick="openModal('{{ hf_file_url_jinja(item.path) if item.file_type not in ['text', 'pdf'] else (url_for('admin_get_text_content', tma_user_id_str=user_id, file_id=item.id) if item.file_type == 'text' else hf_file_url_jinja(item.path, True)) }}', '{{ item.file_type }}', '{{ item.id }}')" {% endif %}>
{% if item.type == 'folder' %}<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
{% elif item.type == 'note' %}<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
{% elif item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div>
{% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div>
{% elif item.type == 'file' %}
{% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
{% elif item.file_type == 'video' %}<video class="item-preview" preload="metadata" muted loading="lazy"><source src="{{ hf_file_url_jinja(item.path, True) }}#t=0.5"></video>
{% elif item.file_type == 'pdf' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--accent);"><i class="fa-solid fa-file-pdf"></i></div>
{% elif item.file_type == 'text' %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--secondary);"><i class="fa-solid fa-file-lines"></i></div>
{% else %}<div class="item-preview" style="font-size: 2.5em; display: flex; align-items: center; justify-content: center; color: var(--text-muted);"><i class="fa-solid fa-file-circle-question"></i></div>
{% endif %}
{% endif %}
</div>
<div class="item-name-info">
<p class="item-name">{{ (item.title if item.type in ['note', 'todolist', 'shoppinglist'] else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p>
{% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
{% elif item.type in ['note', 'todolist', 'shoppinglist'] %}<p class="item-info">{{ item.modified_date }}</p>{% endif %}
</div>
<div class="item-actions-admin">
<form action="{{ url_for('admin_delete_item', tma_user_id_str=user_id, item_id=item.id) }}" method="post" onsubmit="return confirm('Are you sure you want to delete this?');">
<input type="hidden" name="current_folder_id" value="{{ current_folder_id }}">
<button type="submit" title="Delete Item"><i class="fa fa-trash"></i></button>
</form>
</div>
</div>
{% endfor %}
{% if not items %} <p>This folder is empty.</p> {% endif %}
</div>
</div>
<div class="modal" id="mediaModal" onclick="closeModal(event)">
<div class="modal-content">
<span onclick="closeModalManual()" class="modal-close-btn">&times;</span>
<div class="modal-main-content" id="modalContent"></div>
<div class="modal-actions">
<a id="modal-download-btn" class="btn download-btn" href="javascript:void(0);" style="display: none; width: 80%;">
<i class="fa-solid fa-download"></i> Download
</a>
</div>
</div>
</div>
<script>
async function initiateDownload(fileId) {
const downloadBtn = document.getElementById('modal-download-btn');
const originalHTML = downloadBtn.innerHTML;
downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>';
try {
const response = await fetch(`{{ url_for('admin_download_file', tma_user_id_str=user_id, file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
const data = await response.json();
if (data.status === 'success' && data.url) { window.open(data.url, '_blank'); }
else { alert(data.message || 'Failed to create download link.'); }
} catch (error) { alert('Network error while creating download link.'); }
finally { downloadBtn.innerHTML = originalHTML; closeModalManual(); }
}
async function openModal(srcOrUrl, type, itemId) {
if (!itemId) return;
const modal = document.getElementById('mediaModal');
const modalContent = document.getElementById('modalContent');
const downloadBtn = document.getElementById('modal-download-btn');
modalContent.innerHTML = '<div class="loading-spinner"></div>';
modal.style.display = 'flex';
downloadBtn.style.display = 'none';
try {
if (type === 'note' || type === 'todolist' || type === 'shoppinglist') {
const response = await fetch(`{{ url_for('admin_get_item', tma_user_id_str=user_id, item_id='__ID__') }}`.replace('__ID__', itemId));
const data = await response.json();
if(data.status === 'success') {
let contentHTML = `<div style="padding:15px; text-align:left;"><h3>${data.item.title.replace(/</g,"&lt;")}</h3><hr style="border-color:#333; margin:10px 0;">`;
if (type === 'note') contentHTML += `<pre>${data.item.content.replace(/</g,"&lt;")}</pre>`;
if (type === 'todolist') contentHTML += `<ul>${data.item.tasks.map(t => `<li><input type='checkbox' ${t.completed ? 'checked' : ''} disabled> ${t.text}</li>`).join('')}</ul>`;
if (type === 'shoppinglist') contentHTML += `<ul>${data.item.items.map(i => `<li><input type='checkbox' ${i.purchased ? 'checked' : ''} disabled> ${i.name} (x${i.quantity})</li>`).join('')}</ul>`;
contentHTML += `</div>`;
modalContent.innerHTML = contentHTML;
} else { throw new Error(data.message); }
} else if (type === 'image') {
modalContent.innerHTML = `<img src="${srcOrUrl}">`;
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
} else if (type === 'video') {
modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
} else if (type === 'pdf') {
modalContent.innerHTML = `<iframe src="${srcOrUrl}"></iframe>`;
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
} else if (type === 'text') {
const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Error: ${response.statusText}`);
const text = await response.text();
modalContent.innerHTML = `<pre>${text.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</pre>`;
downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
} else {
initiateDownload(itemId);
}
} catch (error) { modalContent.innerHTML = `<p>Preview Error: ${error.message}</p>`; }
}
function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
function closeModalManual() {
const modal = document.getElementById('mediaModal');
modal.style.display = 'none';
const video = modal.querySelector('video'); if (video) video.pause();
document.getElementById('modalContent').innerHTML = '';
document.getElementById('modal-download-btn').style.display = 'none';
}
const gridViewBtn = document.getElementById('grid-view-btn');
const listViewBtn = document.getElementById('list-view-btn');
const fileContainer = document.getElementById('file-container');
function setView(view) {
fileContainer.classList.toggle('list-view', view === 'list');
listViewBtn.classList.toggle('active', view === 'list');
gridViewBtn.classList.toggle('active', view !== 'list');
localStorage.setItem('adminViewMode', view);
}
gridViewBtn.addEventListener('click', () => setView('grid'));
listViewBtn.addEventListener('click', () => setView('list'));
document.addEventListener('DOMContentLoaded', () => { setView(localStorage.getItem('adminViewMode') || 'grid'); });
</script></body></html>
'''
ADMIN_USER_REMINDERS_HTML = '''
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin - User Reminders</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<style>''' + BASE_STYLE + '''
.admin-header { padding-bottom: 15px; border-bottom: 1px solid #333; margin-bottom: 20px; }
.reminders-list { list-style: none; }
.reminder-item-admin { background: var(--card-bg-dark); border-radius: 12px; padding: 15px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; }
.reminder-info span { display: block; }
.reminder-info .utc-time { font-size: 0.8em; color: var(--text-muted); }
</style></head><body>
<div class="app-header">
<div class="user-info"><a href="{{ url_for('admin_panel') }}" style="color: var(--primary); text-decoration: none;">Admin Panel</a></div>
</div>
<div class="container">
<div class="admin-header">
<h1>Reminders for {{ user.get('first_name', 'N/A') }}</h1>
<div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
{% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %}
{% endif %}{% endwith %}
<ul class="reminders-list">
{% for reminder in reminders %}
<li class="reminder-item-admin">
<div class="reminder-info">
<strong>{{ reminder.text }}</strong>
<span class="utc-time">Due (UTC): {{ reminder.due_datetime_utc }}</span>
<span class="utc-time">Notified: {{ 'Yes' if reminder.notified else 'No' }}</span>
</div>
<form action="{{ url_for('admin_delete_reminder', tma_user_id_str=user_id, reminder_id=reminder.id) }}" method="post" onsubmit="return confirm('Delete this reminder?');">
<button type="submit" class="btn delete-btn" style="padding: 8px 12px;"><i class="fa fa-trash"></i></button>
</form>
</li>
{% else %}
<li>No reminders for this user.</li>
{% endfor %}
</ul>
</div></body></html>
'''
@app.route('/admin')
def admin_redirect():
return redirect(url_for('admin_login'))
@app.route('/admhosto/login', methods=['GET', 'POST'])
def admin_login():
if session.get('admin_browser_logged_in'):
return redirect(url_for('admin_panel'))
if request.method == 'POST':
if request.form.get('username') == ADMIN_USERNAME and request.form.get('password') == ADMIN_PASSWORD:
session['admin_browser_logged_in'] = True
next_url = request.form.get('next') or url_for('admin_panel')
return redirect(next_url)
else:
flash('Invalid credentials.', 'error')
return render_template_string(ADMIN_LOGIN_HTML)
@app.route('/admhosto/logout')
def admin_logout():
session.pop('admin_browser_logged_in', None)
flash('You have been logged out.')
return redirect(url_for('admin_login'))
@app.route('/admhosto')
@admin_browser_login_required
def admin_panel():
data = load_data()
all_users = data.get('users', {})
search_query = request.args.get('q', '').lower()
processed_users = {}
for user_id, user_data in all_users.items():
user_data['item_count'] = count_items_recursive(user_data.get('filesystem'))
processed_users[user_id] = user_data
if search_query:
filtered_users = {}
for user_id, user_data in processed_users.items():
full_name = f"{user_data.get('first_name', '')} {user_data.get('last_name', '')}".lower()
username = user_data.get('telegram_username', '').lower()
if search_query in full_name or search_query in username:
filtered_users[user_id] = user_data
users_to_display = filtered_users
else:
users_to_display = processed_users
sorted_users = sorted(
users_to_display.items(),
key=lambda item: item[1].get('created_at', '0000-00-00 00:00:00'),
reverse=True
)
return render_template_string(ADMIN_PANEL_HTML, users=sorted_users, search_query=request.args.get('q', ''))
@app.route('/admhosto/user/<tma_user_id_str>')
@admin_browser_login_required
def admin_user_files(tma_user_id_str):
data = load_data()
user_data = data['users'].get(tma_user_id_str)
if not user_data:
flash('User not found.', 'error')
return redirect(url_for('admin_panel'))
initialize_user_filesystem_tma(user_data, tma_user_id_str)
current_folder_id = request.args.get('folder_id', 'root')
current_folder, _ = find_node_by_id(user_data['filesystem'], current_folder_id)
if not current_folder or current_folder.get('type') != 'folder':
flash('Folder not found!', 'error')
current_folder_id = 'root'
current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x['type'] not in ['folder', 'note'], x.get('name', x.get('original_filename', x.get('title', ''))).lower()))
breadcrumbs = []
temp_id = current_folder_id
while temp_id:
node, parent_node_bc = find_node_by_id(user_data['filesystem'], temp_id)
if not node: break
is_link = (node['id'] != current_folder_id)
breadcrumbs.append({'id': node['id'], 'name': node.get('name', 'Root'), 'is_link': is_link})
if not parent_node_bc: break
temp_id = parent_node_bc.get('id')
breadcrumbs.reverse()
return render_template_string(ADMIN_USER_FILES_HTML,
user_id=tma_user_id_str,
user=user_data,
items=items_in_folder,
current_folder_id=current_folder_id,
current_folder=current_folder,
breadcrumbs=breadcrumbs,
hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}")
@app.route('/admhosto/user/<tma_user_id_str>/reminders')
@admin_browser_login_required
def admin_user_reminders(tma_user_id_str):
data = load_data()
user_data = data['users'].get(tma_user_id_str)
if not user_data:
flash('User not found.', 'error')
return redirect(url_for('admin_panel'))
reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', ''), reverse=True)
return render_template_string(ADMIN_USER_REMINDERS_HTML, user_id=tma_user_id_str, user=user_data, reminders=reminders)
@app.route('/admhosto/download/<tma_user_id_str>/<file_id>')
@admin_browser_login_required
def admin_download_file(tma_user_id_str, file_id):
file_node = get_file_node_for_admin(tma_user_id_str, file_id)
if not file_node:
return jsonify({'status': 'error', 'message': 'File not found or access denied!'}), 404
token = uuid.uuid4().hex
cache.set(f"download_token_{token}", file_node, timeout=300)
public_url = url_for('public_download', token=token, _external=True)
return jsonify({'status': 'success', 'url': public_url})
@app.route('/admhosto/text/<tma_user_id_str>/<file_id>')
@admin_browser_login_required
def admin_get_text_content(tma_user_id_str, file_id):
file_node = get_file_node_for_admin(tma_user_id_str, file_id)
if not file_node or file_node.get('file_type') != 'text':
return Response("Text file not found", 404)
hf_path = file_node.get('path')
if not hf_path:
return Response("Error: file path is missing", 500)
file_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(hf_path)}?download=true"
try:
req_headers = {}
if HF_TOKEN_READ: req_headers["authorization"] = f"Bearer {HF_TOKEN_READ}"
response = requests.get(file_url, headers=req_headers)
response.raise_for_status()
if len(response.content) > 1 * 1024 * 1024:
return Response("File too large for preview.", 413)
try:
text_content = response.content.decode('utf-8')
except UnicodeDecodeError:
text_content = response.content.decode('latin-1', errors='ignore')
return Response(text_content, mimetype='text/plain')
except Exception as e:
return Response(f"Download error: {e}", 502)
@app.route('/admhosto/item/<tma_user_id_str>/<item_id>')
@admin_browser_login_required
def admin_get_item(tma_user_id_str, item_id):
data = load_data()
user_data = data['users'].get(tma_user_id_str)
if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
node, _ = find_node_by_id(user_data['filesystem'], item_id)
if not node: return jsonify({'status': 'error', 'message': 'Item not found'}), 404
return jsonify({'status': 'success', 'item': node})
@app.route('/admhosto/delete_item/<tma_user_id_str>/<item_id>', methods=['POST'])
@admin_browser_login_required
def admin_delete_item(tma_user_id_str, item_id):
data = load_data()
user_data = data['users'].get(tma_user_id_str)
current_folder_id = request.form.get('current_folder_id', 'root')
if not user_data:
flash('User not found.', 'error'); return redirect(url_for('admin_panel'))
node, _ = find_node_by_id(user_data['filesystem'], item_id)
if not node:
flash('Item not found.', 'error')
elif node.get('type') == 'file':
hf_path = node.get('path')
if not HF_TOKEN_WRITE: flash('Deletion not possible: write token not configured.', 'error')
else:
try:
api = HfApi()
if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
if remove_node(user_data['filesystem'], item_id)[0]:
try: save_data(data); flash('File deleted.')
except Exception: flash('File deleted from server, but DB update failed.', 'error')
except hf_utils.EntryNotFoundError:
if remove_node(user_data['filesystem'], item_id)[0]:
try: save_data(data); flash('File not found on server, removed from DB.')
except Exception: flash('DB save error (file not on server).', 'error')
except Exception as e: flash(f'Deletion error: {e}', 'error')
elif node.get('type') in ['note', 'todolist', 'shoppinglist']:
if remove_node(user_data['filesystem'], item_id)[0]:
try: save_data(data); flash(f'{node.get("type").capitalize()} deleted.')
except Exception: flash('DB update failed after item deletion.', 'error')
elif node.get('type') == 'folder':
if node.get('children'): flash('Folder is not empty.', 'error')
else:
if remove_node(user_data['filesystem'], item_id)[0]:
try: save_data(data); flash('Folder deleted.')
except Exception: flash('DB update failed after folder deletion.', 'error')
return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
@app.route('/admhosto/user/<tma_user_id_str>/delete_reminder/<reminder_id>', methods=['POST'])
@admin_browser_login_required
def admin_delete_reminder(tma_user_id_str, reminder_id):
data = load_data()
user_data = data['users'].get(tma_user_id_str)
if not user_data:
flash('User not found.', 'error'); return redirect(url_for('admin_panel'))
reminders = user_data.get('reminders', [])
initial_len = len(reminders)
user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id]
if len(user_data['reminders']) < initial_len:
try:
save_data(data)
flash('Reminder deleted successfully.', 'success')
except Exception as e:
flash(f'Failed to save data: {e}', 'error')
else:
flash('Reminder not found.', 'error')
return redirect(url_for('admin_user_reminders', tma_user_id_str=tma_user_id_str))
if __name__ == '__main__':
if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (write) is not set. Uploads/deletions will fail.")
if not HF_TOKEN_READ: logging.warning("HF_TOKEN_READ is not set. Downloads/previews might fail.")
if ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE": logging.warning("ADMIN_TELEGRAM_ID is not set.")
if ADMIN_USERNAME == "admin" and ADMIN_PASSWORD == "zeusadminpass":
logging.warning("Using default admin credentials. Please change them.")
if HF_TOKEN_WRITE or HF_TOKEN_READ:
download_db_from_hf()
else:
if not os.path.exists(DATA_FILE):
with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}, 'shared_links': {}}, f)
if HF_TOKEN_WRITE:
threading.Thread(target=periodic_backup, daemon=True).start()
threading.Thread(target=check_reminders, daemon=True).start()
app.run(debug=False, host='0.0.0.0', port=7860)