Eluza133 commited on
Commit
db7b058
·
verified ·
1 Parent(s): 038280f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +489 -199
app.py CHANGED
@@ -16,6 +16,8 @@ from functools import wraps
16
  from urllib.parse import quote
17
  import zipfile
18
  import tempfile
 
 
19
 
20
  app = Flask(__name__)
21
  app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma")
@@ -39,6 +41,7 @@ BASE_STYLE = '''
39
  --text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
40
  --glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
41
  --delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3);
 
42
  }
43
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
44
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -47,13 +50,14 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Robo
47
  .container { margin: 0 auto; max-width: 1200px; padding: 75px 15px 15px 15px; }
48
  .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; }
49
  .user-info { font-weight: 600; }
 
50
  .view-toggle button { background: none; border: none; color: var(--text-muted); font-size: 1.2em; padding: 5px; cursor: pointer; transition: var(--transition); }
51
  .view-toggle button:hover, .view-toggle button.active { color: var(--primary); }
52
  h2 { font-size: 1.3em; margin-bottom: 15px; margin-top: 15px; color: var(--text-dark); }
53
  .breadcrumbs { font-size: 1em; margin-bottom: 20px; white-space: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
54
  .breadcrumbs a { color: var(--accent); text-decoration: none; }
55
  .breadcrumbs span { margin: 0 5px; color: var(--text-muted); }
56
- input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #333; border-radius: 12px; background: #2a2a2a; color: var(--text-dark); font-size: 1em; }
57
  .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; }
58
  .btn:hover { filter: brightness(1.2); }
59
  .btn:active { transform: scale(0.98); }
@@ -70,6 +74,7 @@ input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #33
70
  .item-preview-wrapper { position: relative; width: 100%; padding-top: 75%; border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #2a2a2a; }
71
  .item-preview { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
72
  .item.folder .item-preview { object-fit: contain; font-size: 3.5em; color: var(--folder-color); }
 
73
  .item-name { font-size: 0.9em; font-weight: 500; word-break: break-all; margin: 5px 0; flex-grow: 1; }
74
  .item-info { font-size: 0.75em; color: var(--text-muted); }
75
  .item-actions { display: none; }
@@ -77,7 +82,7 @@ input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #33
77
  .file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; }
78
  .file-grid.list-view .item:hover { transform: translateY(0); }
79
  .file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; }
80
- .file-grid.list-view .item.folder .item-preview { font-size: 1.8em; }
81
  .file-grid.list-view .item-name-info { flex-grow: 1; }
82
  .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; }
83
  .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; }
@@ -97,6 +102,9 @@ input, select { width: 100%; padding: 14px; margin: 8px 0; border: 1px solid #33
97
  .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; }
98
  .fab:active { transform: scale(0.9); }
99
  .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; }
 
 
 
100
  '''
101
 
102
  def find_node_by_id(filesystem, node_id):
@@ -159,15 +167,15 @@ def get_all_folders(filesystem, exclude_ids=None):
159
  traverse(filesystem, "")
160
  return sorted(folders, key=lambda x: x['name'].lower())
161
 
162
- def count_files_recursive(node):
163
  if not node or not isinstance(node, dict):
164
  return 0
165
  count = 0
166
- if node.get('type') == 'file':
167
  count += 1
168
  if node.get('type') == 'folder' and 'children' in node:
169
  for child in node.get('children', []):
170
- count += count_files_recursive(child)
171
  return count
172
 
173
  def initialize_user_filesystem_tma(user_data, tma_user_id_str):
@@ -199,6 +207,7 @@ def load_data():
199
  data.setdefault('users', {})
200
  for tma_user_id_str, user_data_item in data['users'].items():
201
  initialize_user_filesystem_tma(user_data_item, tma_user_id_str)
 
202
  return data
203
  except Exception as e:
204
  logging.error(f"Error loading data: {e}")
@@ -242,6 +251,45 @@ def periodic_backup():
242
  upload_db_to_hf()
243
  time.sleep(1800)
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  def get_file_type(filename):
246
  filename_lower = filename.lower()
247
  if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video'
@@ -316,6 +364,7 @@ def auth_via_telegram():
316
  if tma_user_id_str not in data['users']:
317
  user_info['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
318
  user_info['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
 
319
  data['users'][tma_user_id_str] = user_info
320
  initialize_user_filesystem_tma(data['users'][tma_user_id_str], tma_user_id_str)
321
  else:
@@ -345,6 +394,7 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
345
  <div class="app-header">
346
  <div class="user-info">{{ display_name }}</div>
347
  <div class="view-toggle">
 
348
  <button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button>
349
  <button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button>
350
  </div>
@@ -364,10 +414,19 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
364
  <div id="progress-container"><div id="progress-bar"></div></div>
365
  <div class="file-grid" id="file-container">
366
  {% for item in items %}
367
- <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}" {% if item.type != 'folder' %}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 }}')"{% else %}onclick="window.Telegram.WebApp.HapticFeedback.impactOccurred('light'); window.location.href='{{ url_for('tma_dashboard', folder_id=item.id) }}'"{% endif %}>
 
 
 
 
 
 
 
368
  <div class="item-preview-wrapper">
369
  {% if item.type == 'folder' %}
370
  <div class="item-preview"><i class="fa-solid fa-folder"></i></div>
 
 
371
  {% elif item.type == 'file' %}
372
  {% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
373
  {% 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>
@@ -378,8 +437,9 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
378
  {% endif %}
379
  </div>
380
  <div class="item-name-info">
381
- <p class="item-name">{{ item.name if item.type == 'folder' else item.original_filename | truncate(30, True) }}</p>
382
- {% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>{% endif %}
 
383
  </div>
384
  </div>
385
  {% endfor %}
@@ -423,6 +483,7 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
423
  <label for="file-input" class="btn" style="width:100%; margin-bottom: 10px; background: var(--secondary);"><i class="fa-solid fa-upload"></i> Загрузить файлы</label>
424
  <button type="submit" id="upload-btn-modal" style="display:none;"></button>
425
  </form>
 
426
  <form method="POST" action="{{ url_for('create_folder_tma') }}">
427
  <input type="hidden" name="parent_folder_id" value="{{ current_folder_id }}">
428
  <input type="text" name="folder_name" placeholder="Имя новой папки" required>
@@ -431,31 +492,49 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
431
  <button class="btn" style="background: #555; width: 100%; margin-top: 10px;" onclick="closeFabModal()">Отмена</button>
432
  </div></div>
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  <div class="fab-container"><button id="fab" class="fab"><i class="fa-solid fa-plus"></i></button></div>
435
 
436
  <script>
437
  window.Telegram.WebApp.ready();
438
  window.Telegram.WebApp.expand();
439
-
440
  const haptic = window.Telegram.WebApp.HapticFeedback;
441
-
442
  async function openModal(srcOrUrl, type, itemId) {
443
  if (!srcOrUrl) return;
444
  haptic.impactOccurred('light');
445
  const modal = document.getElementById('mediaModal');
446
  const modalContent = document.getElementById('modalContent');
447
  const downloadBtn = document.getElementById('modal-download-btn');
448
-
449
  modalContent.innerHTML = '<div class="loading-spinner"></div>';
450
  modal.style.display = 'flex';
451
-
452
  if (type !== 'folder' && itemId) {
453
  downloadBtn.onclick = () => { initiateDownload(itemId); };
454
  downloadBtn.style.display = 'inline-block';
455
  } else {
456
  downloadBtn.style.display = 'none';
457
  }
458
-
459
  try {
460
  if (type === 'image') modalContent.innerHTML = `<img src="${srcOrUrl}">`;
461
  else if (type === 'video') modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
@@ -467,7 +546,6 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
467
  } else initiateDownload(itemId);
468
  } catch (error) { modalContent.innerHTML = `<p>Ошибка предпросмотра: ${error.message}</p>`; }
469
  }
470
-
471
  function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
472
  function closeModalManual() {
473
  const modal = document.getElementById('mediaModal');
@@ -476,38 +554,26 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
476
  document.getElementById('modalContent').innerHTML = '';
477
  document.getElementById('modal-download-btn').style.display = 'none';
478
  }
479
-
480
  async function initiateDownload(fileId) {
481
  haptic.impactOccurred('medium');
482
  const downloadBtn = document.getElementById('modal-download-btn');
483
  const originalHTML = downloadBtn.innerHTML;
484
  downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>';
485
  downloadBtn.onclick = null;
486
-
487
  try {
488
  const response = await fetch(`{{ url_for('download_tma', file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
489
  const data = await response.json();
490
  if (data.status === 'success' && data.url) {
491
  tmaDownloadFile(data.url);
492
  closeModalManual();
493
- } else {
494
- Telegram.WebApp.showAlert(data.message || 'Не удалось создать ссылку для скачивания.');
495
- }
496
- } catch (error) {
497
- Telegram.WebApp.showAlert('Сетевая ошибка при создании ссылки для скачивания.');
498
- } finally {
499
- if (downloadBtn) {
500
- downloadBtn.innerHTML = originalHTML;
501
- downloadBtn.onclick = () => { initiateDownload(fileId); };
502
- }
503
- }
504
  }
505
-
506
  function tmaDownloadFile(downloadUrl) {
507
  if (window.Telegram && window.Telegram.WebApp && Telegram.WebApp.openLink) { Telegram.WebApp.openLink(downloadUrl, {try_instant_view: false}); }
508
  else { window.open(downloadUrl, '_blank'); }
509
  }
510
-
511
  document.getElementById('upload-form')?.addEventListener('submit', function(e) {
512
  e.preventDefault(); const files = document.getElementById('file-input').files;
513
  if (files.length === 0) { Telegram.WebApp.showAlert('Выберите файлы.'); return; }
@@ -517,43 +583,31 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
517
  const progressBar = document.getElementById('progress-bar');
518
  progressContainer.style.display = 'block'; progressBar.style.width = '0%';
519
  const formData = new FormData(this); const xhr = new XMLHttpRequest();
520
- xhr.upload.addEventListener('progress', e => {
521
- if (e.lengthComputable) progressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%';
522
- });
523
  xhr.addEventListener('load', () => { haptic.notificationOccurred('success'); window.location.reload(); });
524
  xhr.addEventListener('error', () => { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Ошибка загрузки.'); });
525
  xhr.open('POST', this.action, true); xhr.send(formData);
526
  });
527
-
528
  let selectionMode = false; const selectedItems = new Set(); let longPressTimer;
529
  const mainContainer = document.getElementById('main-container');
530
  const selectionBar = document.getElementById('selection-bar');
531
  const selectionCount = document.getElementById('selection-count');
532
  const selectionDownloadBtn = document.getElementById('selection-download-btn');
533
  const allItems = document.querySelectorAll('.item');
534
-
535
  function toggleSelectionMode(enable) {
536
  selectionMode = enable;
537
  selectionBar.classList.toggle('visible', enable);
538
  if(enable) haptic.impactOccurred('heavy');
539
- if (!enable) {
540
- selectedItems.clear();
541
- allItems.forEach(item => item.classList.remove('selected'));
542
- }
543
  }
544
-
545
  function updateSelectionUI() {
546
  selectionCount.textContent = `Выбрано: ${selectedItems.size}`;
547
  const firstSelectedId = selectedItems.values().next().value;
548
  const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`);
549
-
550
  if (selectedItems.size === 1 && itemElement && itemElement.dataset.type === 'file') {
551
  selectionDownloadBtn.style.display = 'inline-block';
552
- } else {
553
- selectionDownloadBtn.style.display = 'none';
554
- }
555
  }
556
-
557
  allItems.forEach(item => {
558
  item.addEventListener('pointerdown', e => {
559
  if (e.pointerType === 'mouse' && e.button !== 0) return;
@@ -568,8 +622,7 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
568
  item.addEventListener('pointerleave', () => clearTimeout(longPressTimer));
569
  item.addEventListener('click', e => {
570
  if (selectionMode) {
571
- haptic.impactOccurred('light');
572
- e.preventDefault(); e.stopPropagation();
573
  item.classList.toggle('selected');
574
  if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id);
575
  updateSelectionUI();
@@ -577,26 +630,21 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
577
  }
578
  }, true);
579
  });
580
-
581
  async function performBatchAction(url, body) {
582
  try {
583
- const response = await fetch(url, {
584
- method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
585
- });
586
  const result = await response.json();
587
  if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
588
  else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); }
589
  } catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); }
590
  finally { toggleSelectionMode(false); }
591
  }
592
-
593
  function downloadSingleSelected() {
594
  if (selectedItems.size !== 1) return;
595
  const fileId = selectedItems.values().next().value;
596
  initiateDownload(fileId);
597
  toggleSelectionMode(false);
598
  }
599
-
600
  function showMoveModal() { if (selectedItems.size > 0) { haptic.impactOccurred('light'); document.getElementById('move-modal').style.display = 'flex'; }}
601
  function closeMoveModal() { document.getElementById('move-modal').style.display = 'none'; }
602
  function moveSelected() {
@@ -611,7 +659,103 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
611
  if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); }
612
  });
613
  }
614
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
615
  const gridViewBtn = document.getElementById('grid-view-btn');
616
  const listViewBtn = document.getElementById('list-view-btn');
617
  const fileContainer = document.getElementById('file-container');
@@ -624,28 +768,20 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
624
  }
625
  gridViewBtn.addEventListener('click', () => setView('grid'));
626
  listViewBtn.addEventListener('click', () => setView('list'));
627
-
628
  const fab = document.getElementById('fab');
629
  const fabModal = document.getElementById('fab-modal');
630
  fab.addEventListener('click', () => { haptic.impactOccurred('medium'); fabModal.style.display = 'flex'; });
631
  function closeFabModal() { fabModal.style.display = 'none'; }
632
  fabModal.addEventListener('click', e => { if (e.target.id === 'fab-modal') closeFabModal(); });
633
-
634
  document.addEventListener('DOMContentLoaded', () => {
635
  setView(localStorage.getItem('viewMode') || 'grid');
636
  const currentFolderId = '{{ current_folder_id }}';
637
  const parentFolderId = '{{ parent_folder_id }}';
638
-
639
  if (currentFolderId !== 'root') {
640
  let backButton = window.Telegram.WebApp.BackButton;
641
  backButton.show();
642
- backButton.onClick(() => {
643
- haptic.impactOccurred('light');
644
- window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`;
645
- });
646
- } else {
647
- window.Telegram.WebApp.BackButton.hide();
648
- }
649
  });
650
  </script></body></html>
651
  '''
@@ -673,7 +809,7 @@ def tma_dashboard():
673
 
674
  parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root'
675
 
676
- items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x['type'] != 'folder', x.get('name', x.get('original_filename', '')).lower()))
677
  if request.method == 'POST':
678
  if not HF_TOKEN_WRITE:
679
  flash('Загрузка невозможна: токен для записи не настроен.', 'error')
@@ -748,19 +884,19 @@ def create_folder_tma():
748
  else: flash('Не удалось найти родительскую папку.', 'error')
749
  return redirect(url_for('tma_dashboard', folder_id=parent_folder_id))
750
 
751
- def get_file_node_for_user(file_id):
752
  if not (session.get('telegram_user_id') or session.get('admin_browser_logged_in')):
753
  return None
754
  data = load_data()
755
  if session.get('admin_browser_logged_in'):
756
  for uid, udata in data.get('users', {}).items():
757
- node, _ = find_node_by_id(udata.get('filesystem', {}), file_id)
758
- if node and node.get('type') == 'file': return node
759
  else:
760
  user_data = data['users'].get(session['telegram_user_id'])
761
  if user_data:
762
- node, _ = find_node_by_id(user_data.get('filesystem', {}), file_id)
763
- if node and node.get('type') == 'file': return node
764
  return None
765
 
766
  def get_file_node_for_admin(tma_user_id_str, file_id):
@@ -776,8 +912,8 @@ def get_file_node_for_admin(tma_user_id_str, file_id):
776
 
777
  @app.route('/download_tma/<file_id>')
778
  def download_tma(file_id):
779
- file_node = get_file_node_for_user(file_id)
780
- if not file_node:
781
  return jsonify({'status': 'error', 'message': 'Файл не найден или доступ запрещен'}), 404
782
 
783
  token = uuid.uuid4().hex
@@ -839,7 +975,7 @@ def batch_download_tma():
839
  try:
840
  with zipfile.ZipFile(temp_zip_file.name, 'w', zipfile.ZIP_DEFLATED) as zf:
841
  for file_id in file_ids:
842
- file_node = get_file_node_for_user(file_id)
843
  if file_node and file_node.get('path'):
844
  hf_path = file_node['path']
845
  original_filename = file_node.get('original_filename', file_id)
@@ -860,61 +996,6 @@ def batch_download_tma():
860
  if os.path.exists(temp_zip_file.name):
861
  os.unlink(temp_zip_file.name)
862
 
863
- @app.route('/delete_file_tma/<file_id>', methods=['POST'])
864
- def delete_file_tma(file_id):
865
- if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
866
- tma_user_id = session['telegram_user_id']
867
- data = load_data()
868
- user_data = data['users'].get(tma_user_id)
869
- if not user_data: return redirect(url_for('tma_entry_page'))
870
- file_node, _ = find_node_by_id(user_data['filesystem'], file_id)
871
- current_view_folder_id = request.form.get('current_view_folder_id', 'root')
872
- if not file_node or file_node.get('type') != 'file':
873
- flash('Файл не найден.', 'error')
874
- else:
875
- hf_path = file_node.get('path')
876
- if not HF_TOKEN_WRITE:
877
- flash('Удаление невозможно: токен для записи не настроен.', 'error')
878
- else:
879
- try:
880
- api = HfApi()
881
- if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
882
- removed, _ = remove_node(user_data['filesystem'], file_id)
883
- if removed:
884
- try: save_data(data); flash('Файл удален.')
885
- except Exception: flash('Файл удален с сервера, но ошибка обновления базы.', 'error')
886
- except hf_utils.EntryNotFoundError:
887
- removed, _ = remove_node(user_data['filesystem'], file_id)
888
- if removed:
889
- try: save_data(data); flash('Файл не найден на сервере, удален из базы.')
890
- except Exception: flash('Ошибка сохранения (файл не на сервере).', 'error')
891
- except Exception as e:
892
- flash(f'Ошибка удаления: {e}', 'error')
893
- return redirect(url_for('tma_dashboard', folder_id=current_view_folder_id))
894
-
895
- @app.route('/delete_folder_tma/<folder_id>', methods=['POST'])
896
- def delete_folder_tma(folder_id):
897
- if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
898
- if folder_id == 'root':
899
- flash('Нельзя удалить корневую папку!', 'error'); return redirect(url_for('tma_dashboard'))
900
- tma_user_id = session['telegram_user_id']
901
- data = load_data()
902
- user_data = data['users'].get(tma_user_id)
903
- if not user_data: return redirect(url_for('tma_entry_page'))
904
-
905
- folder_node, parent_node = find_node_by_id(user_data['filesystem'], folder_id)
906
- if not folder_node or folder_node.get('type') != 'folder' or not parent_node:
907
- flash('Папка не найдена.', 'error')
908
- elif folder_node.get('children'):
909
- flash('Папку можно удалить только если она пуста.', 'error')
910
- else:
911
- removed, _ = remove_node(user_data['filesystem'], folder_id)
912
- if removed:
913
- try: save_data(data); flash(f'Папка "{folder_node.get("name")}" удалена.')
914
- except Exception: flash('Ошибка сохранения после удаления папки.', 'error')
915
- else: flash('Не удалось удалить папку.', 'error')
916
- return redirect(url_for('tma_dashboard', folder_id=parent_node.get('id', 'root')))
917
-
918
  @app.route('/batch_delete_tma', methods=['POST'])
919
  def batch_delete_tma():
920
  if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
@@ -936,6 +1017,9 @@ def batch_delete_tma():
936
  if node.get('children'): errors.append(f'Папка "{node.get("name")}" не пуста.'); continue
937
  if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
938
  else: errors.append(f'Ошибка удаления папки "{node.get("name")}".')
 
 
 
939
  elif node.get('type') == 'file':
940
  try:
941
  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)
@@ -990,7 +1074,7 @@ def batch_move_tma():
990
 
991
  @app.route('/get_text_content_tma/<file_id>')
992
  def get_text_content_tma(file_id):
993
- file_node = get_file_node_for_user(file_id)
994
  if not file_node or file_node.get('file_type') != 'text': return Response("Текстовый файл не найден", 404)
995
  hf_path = file_node.get('path')
996
  if not hf_path: return Response("Ошибка: путь к файлу отсутствует", 500)
@@ -1006,6 +1090,116 @@ def get_text_content_tma(file_id):
1006
  return Response(text_content, mimetype='text/plain')
1007
  except Exception as e: return Response(f"Ошибка загрузки: {e}", 502)
1008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
  @app.route('/tma_logout')
1010
  def tma_logout():
1011
  session.clear()
@@ -1039,12 +1233,13 @@ ADMIN_PANEL_HTML = '''
1039
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1040
  <style>''' + BASE_STYLE + '''
1041
  .user-list { list-style: none; padding: 0; }
1042
- .user-item { background: var(--card-bg-dark); border-radius: 12px; margin-bottom: 10px; padding: 15px; display: flex; justify-content: space-between; align-items: center; transition: var(--transition); }
1043
  .user-item:hover { background: #2a2a2a; }
1044
- .user-details { display: flex; align-items: center; gap: 15px; }
1045
  .user-avatar { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; background-color: #333; }
1046
  .user-info span { display: block; }
1047
  .user-info .id { font-size: 0.8em; color: var(--text-muted); }
 
1048
  .header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;}
1049
  .search-form { display: flex; gap: 10px; flex-grow: 1; max-width: 400px;}
1050
  .search-form input { margin: 0; }
@@ -1071,10 +1266,14 @@ ADMIN_PANEL_HTML = '''
1071
  <strong>{{ user.get('first_name', 'N/A') }} {{ user.get('last_name', '') }}</strong> (@{{ user.get('telegram_username', 'N/A') }})
1072
  <span class="id">ID: {{ user_id }}</span>
1073
  <span class="id">Created: {{ user.get('created_at', 'N/A') }}</span>
1074
- <span class="id">Files: <strong>{{ user.get('file_count', 0) }}</strong></span>
 
1075
  </div>
1076
  </div>
1077
- <a href="{{ url_for('admin_user_files', tma_user_id_str=user_id) }}" class="btn">View Files</a>
 
 
 
1078
  </li>
1079
  {% else %}
1080
  <li>No users found.</li>
@@ -1106,7 +1305,7 @@ ADMIN_USER_FILES_HTML = '''
1106
  </div>
1107
  <div class="container">
1108
  <div class="admin-header">
1109
- <h1>Files for {{ user.get('first_name', 'N/A') }}</h1>
1110
  <div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
1111
  </div>
1112
  {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
@@ -1123,9 +1322,14 @@ ADMIN_USER_FILES_HTML = '''
1123
  <div class="file-grid" id="file-container">
1124
  {% for item in items %}
1125
  <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
1126
- <div class="item-preview-wrapper" {% if item.type != 'folder' %}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 }}')"{% else %}onclick="window.location.href='{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=item.id) }}'"{% endif %}>
 
 
 
1127
  {% if item.type == 'folder' %}
1128
  <div class="item-preview"><i class="fa-solid fa-folder"></i></div>
 
 
1129
  {% elif item.type == 'file' %}
1130
  {% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
1131
  {% 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>
@@ -1136,16 +1340,15 @@ ADMIN_USER_FILES_HTML = '''
1136
  {% endif %}
1137
  </div>
1138
  <div class="item-name-info">
1139
- <p class="item-name">{{ item.name if item.type == 'folder' else item.original_filename | truncate(30, True) }}</p>
1140
- {% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>{% endif %}
 
1141
  </div>
1142
  <div class="item-actions-admin">
1143
- {% if item.type == 'file' %}
1144
- <form action="{{ url_for('admin_delete_file', tma_user_id_str=user_id, file_id=item.id) }}" method="post" onsubmit="return confirm('Are you sure you want to delete this file?');">
1145
- <input type="hidden" name="current_folder_id" value="{{ current_folder_id }}">
1146
- <button type="submit" title="Delete File"><i class="fa fa-trash"></i></button>
1147
- </form>
1148
- {% endif %}
1149
  </div>
1150
  </div>
1151
  {% endfor %}
@@ -1171,43 +1374,44 @@ ADMIN_USER_FILES_HTML = '''
1171
  try {
1172
  const response = await fetch(`{{ url_for('admin_download_file', tma_user_id_str=user_id, file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
1173
  const data = await response.json();
1174
- if (data.status === 'success' && data.url) {
1175
- window.open(data.url, '_blank');
1176
- } else {
1177
- alert(data.message || 'Failed to create download link.');
1178
- }
1179
- } catch (error) {
1180
- alert('Network error while creating download link.');
1181
- } finally {
1182
- downloadBtn.innerHTML = originalHTML;
1183
- closeModalManual();
1184
- }
1185
  }
1186
-
1187
  async function openModal(srcOrUrl, type, itemId) {
1188
- if (!srcOrUrl) return;
1189
  const modal = document.getElementById('mediaModal');
1190
  const modalContent = document.getElementById('modalContent');
1191
  const downloadBtn = document.getElementById('modal-download-btn');
1192
  modalContent.innerHTML = '<div class="loading-spinner"></div>';
1193
  modal.style.display = 'flex';
1194
-
1195
- if (type !== 'folder' && itemId) {
1196
- downloadBtn.onclick = () => initiateDownload(itemId);
1197
- downloadBtn.style.display = 'inline-block';
1198
- } else {
1199
- downloadBtn.style.display = 'none';
1200
- }
1201
-
1202
  try {
1203
- if (type === 'image') modalContent.innerHTML = `<img src="${srcOrUrl}">`;
1204
- else if (type === 'video') modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
1205
- else if (type === 'pdf') modalContent.innerHTML = `<iframe src="${srcOrUrl}"></iframe>`;
1206
- else if (type === 'text') {
 
 
 
 
 
 
 
 
 
 
 
 
1207
  const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Error: ${response.statusText}`);
1208
  const text = await response.text();
1209
  modalContent.innerHTML = `<pre>${text.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</pre>`;
1210
- } else initiateDownload(itemId);
 
 
 
1211
  } catch (error) { modalContent.innerHTML = `<p>Preview Error: ${error.message}</p>`; }
1212
  }
1213
  function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
@@ -1229,12 +1433,51 @@ ADMIN_USER_FILES_HTML = '''
1229
  }
1230
  gridViewBtn.addEventListener('click', () => setView('grid'));
1231
  listViewBtn.addEventListener('click', () => setView('list'));
1232
- document.addEventListener('DOMContentLoaded', () => {
1233
- setView(localStorage.getItem('adminViewMode') || 'grid');
1234
- });
1235
  </script></body></html>
1236
  '''
1237
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1238
  @app.route('/admin')
1239
  def admin_redirect():
1240
  return redirect(url_for('admin_login'))
@@ -1267,7 +1510,7 @@ def admin_panel():
1267
 
1268
  processed_users = {}
1269
  for user_id, user_data in all_users.items():
1270
- user_data['file_count'] = count_files_recursive(user_data.get('filesystem'))
1271
  processed_users[user_id] = user_data
1272
 
1273
  if search_query:
@@ -1307,7 +1550,7 @@ def admin_user_files(tma_user_id_str):
1307
  current_folder_id = 'root'
1308
  current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
1309
 
1310
- items_in_folder = sorted(current_folder.get('children', []), key=lambda x: (x['type'] != 'folder', x.get('name', x.get('original_filename', '')).lower()))
1311
 
1312
  breadcrumbs = []
1313
  temp_id = current_folder_id
@@ -1329,6 +1572,18 @@ def admin_user_files(tma_user_id_str):
1329
  breadcrumbs=breadcrumbs,
1330
  hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}")
1331
 
 
 
 
 
 
 
 
 
 
 
 
 
1332
  @app.route('/admhosto/download/<tma_user_id_str>/<file_id>')
1333
  @admin_browser_login_required
1334
  def admin_download_file(tma_user_id_str, file_id):
@@ -1366,58 +1621,93 @@ def admin_get_text_content(tma_user_id_str, file_id):
1366
  except Exception as e:
1367
  return Response(f"Download error: {e}", 502)
1368
 
1369
- @app.route('/admhosto/delete_user/<tma_user_id_str>', methods=['POST'])
1370
  @admin_browser_login_required
1371
- def admin_delete_user(tma_user_id_str):
1372
- return "User deleted"
 
 
 
 
 
 
1373
 
1374
- @app.route('/admhosto/delete_file/<tma_user_id_str>/<file_id>', methods=['POST'])
1375
  @admin_browser_login_required
1376
- def admin_delete_file(tma_user_id_str, file_id):
1377
  data = load_data()
1378
  user_data = data['users'].get(tma_user_id_str)
1379
  current_folder_id = request.form.get('current_folder_id', 'root')
1380
-
1381
  if not user_data:
1382
- flash('User not found.', 'error')
1383
- return redirect(url_for('admin_panel'))
1384
-
1385
- file_node, _ = find_node_by_id(user_data['filesystem'], file_id)
1386
- if not file_node or file_node.get('type') != 'file':
1387
- flash('File not found.', 'error')
1388
- else:
1389
- hf_path = file_node.get('path')
1390
- if not HF_TOKEN_WRITE:
1391
- flash('Deletion not possible: write token not configured.', 'error')
1392
  else:
1393
  try:
1394
  api = HfApi()
1395
  if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
1396
- removed, _ = remove_node(user_data['filesystem'], file_id)
1397
- if removed:
1398
  try: save_data(data); flash('File deleted.')
1399
  except Exception: flash('File deleted from server, but DB update failed.', 'error')
1400
  except hf_utils.EntryNotFoundError:
1401
- removed, _ = remove_node(user_data['filesystem'], file_id)
1402
- if removed:
1403
  try: save_data(data); flash('File not found on server, removed from DB.')
1404
  except Exception: flash('DB save error (file not on server).', 'error')
1405
- except Exception as e:
1406
- flash(f'Deletion error: {e}', 'error')
 
 
 
 
 
 
 
 
 
1407
  return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
1408
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1409
  if __name__ == '__main__':
1410
  if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (write) is not set. Uploads/deletions will fail.")
1411
  if not HF_TOKEN_READ: logging.warning("HF_TOKEN_READ is not set. Downloads/previews might fail.")
1412
  if ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE": logging.warning("ADMIN_TELEGRAM_ID is not set.")
1413
  if ADMIN_USERNAME == "admin" and ADMIN_PASSWORD == "zeusadminpass":
1414
  logging.warning("Using default admin credentials. Please change them.")
1415
- if HF_TOKEN_WRITE:
1416
- download_db_from_hf()
1417
- threading.Thread(target=periodic_backup, daemon=True).start()
1418
- elif HF_TOKEN_READ:
1419
  download_db_from_hf()
1420
  else:
1421
  if not os.path.exists(DATA_FILE):
1422
  with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f)
 
 
 
 
 
 
1423
  app.run(debug=False, host='0.0.0.0', port=7860)
 
16
  from urllib.parse import quote
17
  import zipfile
18
  import tempfile
19
+ import pytz
20
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
21
 
22
  app = Flask(__name__)
23
  app.secret_key = os.getenv("FLASK_SECRET_KEY", "supersecretkey_folders_unique_tma")
 
41
  --text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
42
  --glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
43
  --delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3);
44
+ --note-color: #6a5acd;
45
  }
46
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
47
  * { margin: 0; padding: 0; box-sizing: border-box; }
 
50
  .container { margin: 0 auto; max-width: 1200px; padding: 75px 15px 15px 15px; }
51
  .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; }
52
  .user-info { font-weight: 600; }
53
+ .view-toggle { display: flex; align-items: center; gap: 5px; }
54
  .view-toggle button { background: none; border: none; color: var(--text-muted); font-size: 1.2em; padding: 5px; cursor: pointer; transition: var(--transition); }
55
  .view-toggle button:hover, .view-toggle button.active { color: var(--primary); }
56
  h2 { font-size: 1.3em; margin-bottom: 15px; margin-top: 15px; color: var(--text-dark); }
57
  .breadcrumbs { font-size: 1em; margin-bottom: 20px; white-space: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; }
58
  .breadcrumbs a { color: var(--accent); text-decoration: none; }
59
  .breadcrumbs span { margin: 0 5px; color: var(--text-muted); }
60
+ 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; }
61
  .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; }
62
  .btn:hover { filter: brightness(1.2); }
63
  .btn:active { transform: scale(0.98); }
 
74
  .item-preview-wrapper { position: relative; width: 100%; padding-top: 75%; border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #2a2a2a; }
75
  .item-preview { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; }
76
  .item.folder .item-preview { object-fit: contain; font-size: 3.5em; color: var(--folder-color); }
77
+ .item.note .item-preview { object-fit: contain; font-size: 3.5em; color: var(--note-color); }
78
  .item-name { font-size: 0.9em; font-weight: 500; word-break: break-all; margin: 5px 0; flex-grow: 1; }
79
  .item-info { font-size: 0.75em; color: var(--text-muted); }
80
  .item-actions { display: none; }
 
82
  .file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; }
83
  .file-grid.list-view .item:hover { transform: translateY(0); }
84
  .file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; }
85
+ .file-grid.list-view .item.folder .item-preview, .file-grid.list-view .item.note .item-preview { font-size: 1.8em; }
86
  .file-grid.list-view .item-name-info { flex-grow: 1; }
87
  .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; }
88
  .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; }
 
102
  .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; }
103
  .fab:active { transform: scale(0.9); }
104
  .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; }
105
+ .reminder-item { display: flex; justify-content: space-between; align-items: center; padding: 8px; border-bottom: 1px solid #333; }
106
+ .reminder-item:last-child { border-bottom: none; }
107
+ .reminder-item button { background: var(--delete-color); color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; }
108
  '''
109
 
110
  def find_node_by_id(filesystem, node_id):
 
167
  traverse(filesystem, "")
168
  return sorted(folders, key=lambda x: x['name'].lower())
169
 
170
+ def count_items_recursive(node):
171
  if not node or not isinstance(node, dict):
172
  return 0
173
  count = 0
174
+ if node.get('type') in ['file', 'note']:
175
  count += 1
176
  if node.get('type') == 'folder' and 'children' in node:
177
  for child in node.get('children', []):
178
+ count += count_items_recursive(child)
179
  return count
180
 
181
  def initialize_user_filesystem_tma(user_data, tma_user_id_str):
 
207
  data.setdefault('users', {})
208
  for tma_user_id_str, user_data_item in data['users'].items():
209
  initialize_user_filesystem_tma(user_data_item, tma_user_id_str)
210
+ user_data_item.setdefault('reminders', [])
211
  return data
212
  except Exception as e:
213
  logging.error(f"Error loading data: {e}")
 
251
  upload_db_to_hf()
252
  time.sleep(1800)
253
 
254
+ def send_telegram_message(chat_id, text):
255
+ if not TELEGRAM_BOT_TOKEN:
256
+ logging.warning("TELEGRAM_BOT_TOKEN is not set. Cannot send message.")
257
+ return
258
+ url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
259
+ payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
260
+ try:
261
+ response = requests.post(url, json=payload)
262
+ response.raise_for_status()
263
+ logging.info(f"Sent message to {chat_id}")
264
+ except requests.exceptions.RequestException as e:
265
+ logging.error(f"Failed to send Telegram message to {chat_id}: {e}")
266
+
267
+ def check_reminders():
268
+ while True:
269
+ try:
270
+ data = load_data()
271
+ now_utc = datetime.now(pytz.utc)
272
+ made_changes = False
273
+ for user_id, user_data in data.get('users', {}).items():
274
+ if 'reminders' in user_data:
275
+ for reminder in user_data['reminders']:
276
+ if not reminder.get('notified', False):
277
+ due_time_str = reminder.get('due_datetime_utc')
278
+ if due_time_str:
279
+ due_time_utc = datetime.fromisoformat(due_time_str.replace('Z', '+00:00')).replace(tzinfo=pytz.utc)
280
+ if now_utc >= due_time_utc:
281
+ telegram_id = user_data.get('telegram_id')
282
+ if telegram_id:
283
+ message_text = f"🔔 <b>Напоминание:</b>\n\n{reminder['text']}"
284
+ send_telegram_message(telegram_id, message_text)
285
+ reminder['notified'] = True
286
+ made_changes = True
287
+ if made_changes:
288
+ save_data(data)
289
+ except Exception as e:
290
+ logging.error(f"Error in check_reminders thread: {e}")
291
+ time.sleep(60)
292
+
293
  def get_file_type(filename):
294
  filename_lower = filename.lower()
295
  if filename_lower.endswith(('.mp4', '.mov', '.avi', '.webm', '.mkv')): return 'video'
 
364
  if tma_user_id_str not in data['users']:
365
  user_info['created_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
366
  user_info['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
367
+ user_info['reminders'] = []
368
  data['users'][tma_user_id_str] = user_info
369
  initialize_user_filesystem_tma(data['users'][tma_user_id_str], tma_user_id_str)
370
  else:
 
394
  <div class="app-header">
395
  <div class="user-info">{{ display_name }}</div>
396
  <div class="view-toggle">
397
+ <button id="reminders-btn" title="Напоминания"><i class="fa-solid fa-bell"></i></button>
398
  <button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button>
399
  <button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button>
400
  </div>
 
414
  <div id="progress-container"><div id="progress-bar"></div></div>
415
  <div class="file-grid" id="file-container">
416
  {% for item in items %}
417
+ <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}"
418
+ {% if item.type == 'folder' %}
419
+ onclick="window.Telegram.WebApp.HapticFeedback.impactOccurred('light'); window.location.href='{{ url_for('tma_dashboard', folder_id=item.id) }}'"
420
+ {% elif item.type == 'note' %}
421
+ onclick="openNoteModal('{{ item.id }}')"
422
+ {% else %}
423
+ 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 }}')"
424
+ {% endif %}>
425
  <div class="item-preview-wrapper">
426
  {% if item.type == 'folder' %}
427
  <div class="item-preview"><i class="fa-solid fa-folder"></i></div>
428
+ {% elif item.type == 'note' %}
429
+ <div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
430
  {% elif item.type == 'file' %}
431
  {% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
432
  {% 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>
 
437
  {% endif %}
438
  </div>
439
  <div class="item-name-info">
440
+ <p class="item-name">{{ (item.title if item.type == 'note' else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p>
441
+ {% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
442
+ {% elif item.type == 'note' %}<p class="item-info">{{ item.modified_date }}</p>{% endif %}
443
  </div>
444
  </div>
445
  {% endfor %}
 
483
  <label for="file-input" class="btn" style="width:100%; margin-bottom: 10px; background: var(--secondary);"><i class="fa-solid fa-upload"></i> Загрузить файлы</label>
484
  <button type="submit" id="upload-btn-modal" style="display:none;"></button>
485
  </form>
486
+ <button class="btn" style="width:100%; margin-bottom: 10px; background: var(--note-color);" onclick="openNoteModal()"><i class="fa-solid fa-note-sticky"></i> Создать заметку</button>
487
  <form method="POST" action="{{ url_for('create_folder_tma') }}">
488
  <input type="hidden" name="parent_folder_id" value="{{ current_folder_id }}">
489
  <input type="text" name="folder_name" placeholder="Имя новой папки" required>
 
492
  <button class="btn" style="background: #555; width: 100%; margin-top: 10px;" onclick="closeFabModal()">Отмена</button>
493
  </div></div>
494
 
495
+ <div class="modal" id="note-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
496
+ <h4 id="note-modal-title">Новая заметка</h4>
497
+ <input type="hidden" id="note-id-input">
498
+ <input type="text" id="note-title-input" placeholder="Заголовок заметки" style="font-size: 1.1em; margin-bottom: 10px;">
499
+ <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>
500
+ <div style="display: flex; gap: 10px; margin-top: 15px;">
501
+ <button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveNote()">Сохранить</button>
502
+ <button class="btn" style="background: #555; flex-grow: 1;" onclick="closeNoteModal()">Отмена</button>
503
+ </div>
504
+ </div></div>
505
+
506
+ <div class="modal" id="reminders-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
507
+ <h4>Напоминания</h4>
508
+ <div id="reminders-list" style="max-height: 40vh; overflow-y: auto; margin-bottom: 15px; font-size: 0.9em;"></div>
509
+ <h5 style="margin-top: 15px;">Новое напоминание</h5>
510
+ <input type="text" id="reminder-text-input" placeholder="Текст напоминания" required>
511
+ <input type="datetime-local" id="reminder-datetime-input" required>
512
+ <div style="display: flex; gap: 10px; margin-top: 15px;">
513
+ <button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="createReminder()">Создать</button>
514
+ <button class="btn" style="background: #555; flex-grow: 1;" onclick="closeRemindersModal()">Закрыть</button>
515
+ </div>
516
+ </div></div>
517
+
518
  <div class="fab-container"><button id="fab" class="fab"><i class="fa-solid fa-plus"></i></button></div>
519
 
520
  <script>
521
  window.Telegram.WebApp.ready();
522
  window.Telegram.WebApp.expand();
 
523
  const haptic = window.Telegram.WebApp.HapticFeedback;
 
524
  async function openModal(srcOrUrl, type, itemId) {
525
  if (!srcOrUrl) return;
526
  haptic.impactOccurred('light');
527
  const modal = document.getElementById('mediaModal');
528
  const modalContent = document.getElementById('modalContent');
529
  const downloadBtn = document.getElementById('modal-download-btn');
 
530
  modalContent.innerHTML = '<div class="loading-spinner"></div>';
531
  modal.style.display = 'flex';
 
532
  if (type !== 'folder' && itemId) {
533
  downloadBtn.onclick = () => { initiateDownload(itemId); };
534
  downloadBtn.style.display = 'inline-block';
535
  } else {
536
  downloadBtn.style.display = 'none';
537
  }
 
538
  try {
539
  if (type === 'image') modalContent.innerHTML = `<img src="${srcOrUrl}">`;
540
  else if (type === 'video') modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
 
546
  } else initiateDownload(itemId);
547
  } catch (error) { modalContent.innerHTML = `<p>Ошибка предпросмотра: ${error.message}</p>`; }
548
  }
 
549
  function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
550
  function closeModalManual() {
551
  const modal = document.getElementById('mediaModal');
 
554
  document.getElementById('modalContent').innerHTML = '';
555
  document.getElementById('modal-download-btn').style.display = 'none';
556
  }
 
557
  async function initiateDownload(fileId) {
558
  haptic.impactOccurred('medium');
559
  const downloadBtn = document.getElementById('modal-download-btn');
560
  const originalHTML = downloadBtn.innerHTML;
561
  downloadBtn.innerHTML = '<div class="loading-spinner" style="width:20px; height:20px; border-width:2px;"></div>';
562
  downloadBtn.onclick = null;
 
563
  try {
564
  const response = await fetch(`{{ url_for('download_tma', file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
565
  const data = await response.json();
566
  if (data.status === 'success' && data.url) {
567
  tmaDownloadFile(data.url);
568
  closeModalManual();
569
+ } else { Telegram.WebApp.showAlert(data.message || 'Не удалось создать ссылку для скачивания.'); }
570
+ } catch (error) { Telegram.WebApp.showAlert('Сетевая ошибка при создании ссылки для скачивания.'); }
571
+ finally { if (downloadBtn) { downloadBtn.innerHTML = originalHTML; downloadBtn.onclick = () => { initiateDownload(fileId); }; } }
 
 
 
 
 
 
 
 
572
  }
 
573
  function tmaDownloadFile(downloadUrl) {
574
  if (window.Telegram && window.Telegram.WebApp && Telegram.WebApp.openLink) { Telegram.WebApp.openLink(downloadUrl, {try_instant_view: false}); }
575
  else { window.open(downloadUrl, '_blank'); }
576
  }
 
577
  document.getElementById('upload-form')?.addEventListener('submit', function(e) {
578
  e.preventDefault(); const files = document.getElementById('file-input').files;
579
  if (files.length === 0) { Telegram.WebApp.showAlert('Выберите файлы.'); return; }
 
583
  const progressBar = document.getElementById('progress-bar');
584
  progressContainer.style.display = 'block'; progressBar.style.width = '0%';
585
  const formData = new FormData(this); const xhr = new XMLHttpRequest();
586
+ xhr.upload.addEventListener('progress', e => { if (e.lengthComputable) progressBar.style.width = Math.round((e.loaded / e.total) * 100) + '%'; });
 
 
587
  xhr.addEventListener('load', () => { haptic.notificationOccurred('success'); window.location.reload(); });
588
  xhr.addEventListener('error', () => { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Ошибка загрузки.'); });
589
  xhr.open('POST', this.action, true); xhr.send(formData);
590
  });
 
591
  let selectionMode = false; const selectedItems = new Set(); let longPressTimer;
592
  const mainContainer = document.getElementById('main-container');
593
  const selectionBar = document.getElementById('selection-bar');
594
  const selectionCount = document.getElementById('selection-count');
595
  const selectionDownloadBtn = document.getElementById('selection-download-btn');
596
  const allItems = document.querySelectorAll('.item');
 
597
  function toggleSelectionMode(enable) {
598
  selectionMode = enable;
599
  selectionBar.classList.toggle('visible', enable);
600
  if(enable) haptic.impactOccurred('heavy');
601
+ if (!enable) { selectedItems.clear(); allItems.forEach(item => item.classList.remove('selected')); }
 
 
 
602
  }
 
603
  function updateSelectionUI() {
604
  selectionCount.textContent = `Выбрано: ${selectedItems.size}`;
605
  const firstSelectedId = selectedItems.values().next().value;
606
  const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`);
 
607
  if (selectedItems.size === 1 && itemElement && itemElement.dataset.type === 'file') {
608
  selectionDownloadBtn.style.display = 'inline-block';
609
+ } else { selectionDownloadBtn.style.display = 'none'; }
 
 
610
  }
 
611
  allItems.forEach(item => {
612
  item.addEventListener('pointerdown', e => {
613
  if (e.pointerType === 'mouse' && e.button !== 0) return;
 
622
  item.addEventListener('pointerleave', () => clearTimeout(longPressTimer));
623
  item.addEventListener('click', e => {
624
  if (selectionMode) {
625
+ haptic.impactOccurred('light'); e.preventDefault(); e.stopPropagation();
 
626
  item.classList.toggle('selected');
627
  if (selectedItems.has(item.dataset.id)) selectedItems.delete(item.dataset.id); else selectedItems.add(item.dataset.id);
628
  updateSelectionUI();
 
630
  }
631
  }, true);
632
  });
 
633
  async function performBatchAction(url, body) {
634
  try {
635
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
 
 
636
  const result = await response.json();
637
  if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
638
  else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); }
639
  } catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); }
640
  finally { toggleSelectionMode(false); }
641
  }
 
642
  function downloadSingleSelected() {
643
  if (selectedItems.size !== 1) return;
644
  const fileId = selectedItems.values().next().value;
645
  initiateDownload(fileId);
646
  toggleSelectionMode(false);
647
  }
 
648
  function showMoveModal() { if (selectedItems.size > 0) { haptic.impactOccurred('light'); document.getElementById('move-modal').style.display = 'flex'; }}
649
  function closeMoveModal() { document.getElementById('move-modal').style.display = 'none'; }
650
  function moveSelected() {
 
659
  if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); }
660
  });
661
  }
662
+ async function openNoteModal(noteId = null) {
663
+ haptic.impactOccurred('light');
664
+ closeFabModal();
665
+ const modal = document.getElementById('note-modal');
666
+ const titleEl = document.getElementById('note-modal-title');
667
+ const idInput = document.getElementById('note-id-input');
668
+ const titleInput = document.getElementById('note-title-input');
669
+ const contentInput = document.getElementById('note-content-input');
670
+ if (noteId) {
671
+ titleEl.textContent = 'Редактировать заметку';
672
+ const response = await fetch(`{{ url_for('get_note_tma', note_id='__ID__') }}`.replace('__ID__', noteId));
673
+ const data = await response.json();
674
+ if (data.status === 'success') {
675
+ idInput.value = data.note.id;
676
+ titleInput.value = data.note.title;
677
+ contentInput.value = data.note.content;
678
+ } else { Telegram.WebApp.showAlert('Ошибка з��грузки заметки.'); return; }
679
+ } else {
680
+ titleEl.textContent = 'Новая заметка';
681
+ idInput.value = ''; titleInput.value = ''; contentInput.value = '';
682
+ }
683
+ modal.style.display = 'flex';
684
+ }
685
+ function closeNoteModal() { document.getElementById('note-modal').style.display = 'none'; }
686
+ async function saveNote() {
687
+ const id = document.getElementById('note-id-input').value;
688
+ const title = document.getElementById('note-title-input').value;
689
+ const content = document.getElementById('note-content-input').value;
690
+ if (!title.trim()) { Telegram.WebApp.showAlert('Заголовок не может быть пустым.'); return; }
691
+ const payload = {
692
+ note_id: id, title: title, content: content, parent_folder_id: '{{ current_folder_id }}'
693
+ };
694
+ const response = await fetch('{{ url_for("create_or_update_note_tma") }}', {
695
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
696
+ });
697
+ const result = await response.json();
698
+ if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
699
+ else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); }
700
+ }
701
+ function closeRemindersModal() { document.getElementById('reminders-modal').style.display = 'none'; }
702
+ async function openRemindersModal() {
703
+ haptic.impactOccurred('light');
704
+ const modal = document.getElementById('reminders-modal');
705
+ const listEl = document.getElementById('reminders-list');
706
+ listEl.innerHTML = '<div class="loading-spinner"></div>';
707
+ modal.style.display = 'flex';
708
+ try {
709
+ const response = await fetch('{{ url_for("get_reminders_tma") }}');
710
+ const data = await response.json();
711
+ if (data.status === 'success') {
712
+ listEl.innerHTML = '';
713
+ if (data.reminders.length === 0) listEl.innerHTML = '<p>Напоминаний нет.</p>';
714
+ data.reminders.forEach(r => {
715
+ const dt = new Date(r.due_datetime_local);
716
+ const el = document.createElement('div');
717
+ el.className = 'reminder-item';
718
+ el.innerHTML = `
719
+ <div>
720
+ <span>${r.text}</span><br>
721
+ <small style="color:var(--text-muted)">${dt.toLocaleString()}</small>
722
+ </div>
723
+ <button onclick="deleteReminder('${r.id}')">&times;</button>
724
+ `;
725
+ listEl.appendChild(el);
726
+ });
727
+ } else { listEl.innerHTML = '<p>Ошибка загрузки.</p>'; }
728
+ } catch (e) { listEl.innerHTML = '<p>Сетевая ошибка.</p>'; }
729
+ }
730
+ async function createReminder() {
731
+ const text = document.getElementById('reminder-text-input').value;
732
+ const datetimeLocal = document.getElementById('reminder-datetime-input').value;
733
+ if (!text.trim() || !datetimeLocal) { Telegram.WebApp.showAlert('Заполните все поля.'); return; }
734
+ const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
735
+ const payload = { text, datetime_local: datetimeLocal, user_timezone: userTimezone };
736
+ const response = await fetch('{{ url_for("create_reminder_tma") }}', {
737
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
738
+ });
739
+ const result = await response.json();
740
+ if (result.status === 'success') {
741
+ haptic.notificationOccurred('success');
742
+ document.getElementById('reminder-text-input').value = '';
743
+ document.getElementById('reminder-datetime-input').value = '';
744
+ openRemindersModal();
745
+ } else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка.'); }
746
+ }
747
+ async function deleteReminder(reminderId) {
748
+ Telegram.WebApp.showConfirm('Удалить это напоминание?', async (ok) => {
749
+ if (ok) {
750
+ haptic.impactOccurred('heavy');
751
+ const response = await fetch(`{{ url_for('delete_reminder_tma', reminder_id='__ID__') }}`.replace('__ID__', reminderId), { method: 'POST' });
752
+ const result = await response.json();
753
+ if (result.status === 'success') { openRemindersModal(); }
754
+ else { Telegram.WebApp.showAlert('Ошибка удаления.'); }
755
+ }
756
+ });
757
+ }
758
+ document.getElementById('reminders-btn').addEventListener('click', openRemindersModal);
759
  const gridViewBtn = document.getElementById('grid-view-btn');
760
  const listViewBtn = document.getElementById('list-view-btn');
761
  const fileContainer = document.getElementById('file-container');
 
768
  }
769
  gridViewBtn.addEventListener('click', () => setView('grid'));
770
  listViewBtn.addEventListener('click', () => setView('list'));
 
771
  const fab = document.getElementById('fab');
772
  const fabModal = document.getElementById('fab-modal');
773
  fab.addEventListener('click', () => { haptic.impactOccurred('medium'); fabModal.style.display = 'flex'; });
774
  function closeFabModal() { fabModal.style.display = 'none'; }
775
  fabModal.addEventListener('click', e => { if (e.target.id === 'fab-modal') closeFabModal(); });
 
776
  document.addEventListener('DOMContentLoaded', () => {
777
  setView(localStorage.getItem('viewMode') || 'grid');
778
  const currentFolderId = '{{ current_folder_id }}';
779
  const parentFolderId = '{{ parent_folder_id }}';
 
780
  if (currentFolderId !== 'root') {
781
  let backButton = window.Telegram.WebApp.BackButton;
782
  backButton.show();
783
+ backButton.onClick(() => { haptic.impactOccurred('light'); window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`; });
784
+ } else { window.Telegram.WebApp.BackButton.hide(); }
 
 
 
 
 
785
  });
786
  </script></body></html>
787
  '''
 
809
 
810
  parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root'
811
 
812
+ 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()))
813
  if request.method == 'POST':
814
  if not HF_TOKEN_WRITE:
815
  flash('Загрузка невозможна: токен для записи не настроен.', 'error')
 
884
  else: flash('Не удалось найти родительскую папку.', 'error')
885
  return redirect(url_for('tma_dashboard', folder_id=parent_folder_id))
886
 
887
+ def get_item_node_for_user(item_id):
888
  if not (session.get('telegram_user_id') or session.get('admin_browser_logged_in')):
889
  return None
890
  data = load_data()
891
  if session.get('admin_browser_logged_in'):
892
  for uid, udata in data.get('users', {}).items():
893
+ node, _ = find_node_by_id(udata.get('filesystem', {}), item_id)
894
+ if node: return node
895
  else:
896
  user_data = data['users'].get(session['telegram_user_id'])
897
  if user_data:
898
+ node, _ = find_node_by_id(user_data.get('filesystem', {}), item_id)
899
+ if node: return node
900
  return None
901
 
902
  def get_file_node_for_admin(tma_user_id_str, file_id):
 
912
 
913
  @app.route('/download_tma/<file_id>')
914
  def download_tma(file_id):
915
+ file_node = get_item_node_for_user(file_id)
916
+ if not file_node or file_node.get('type') != 'file':
917
  return jsonify({'status': 'error', 'message': 'Файл не найден или доступ запрещен'}), 404
918
 
919
  token = uuid.uuid4().hex
 
975
  try:
976
  with zipfile.ZipFile(temp_zip_file.name, 'w', zipfile.ZIP_DEFLATED) as zf:
977
  for file_id in file_ids:
978
+ file_node = get_item_node_for_user(file_id)
979
  if file_node and file_node.get('path'):
980
  hf_path = file_node['path']
981
  original_filename = file_node.get('original_filename', file_id)
 
996
  if os.path.exists(temp_zip_file.name):
997
  os.unlink(temp_zip_file.name)
998
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
999
  @app.route('/batch_delete_tma', methods=['POST'])
1000
  def batch_delete_tma():
1001
  if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
 
1017
  if node.get('children'): errors.append(f'Папка "{node.get("name")}" не пуста.'); continue
1018
  if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
1019
  else: errors.append(f'Ошибка удаления папки "{node.get("name")}".')
1020
+ elif node.get('type') == 'note':
1021
+ if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
1022
+ else: errors.append(f'Ошибка удаления "{node.get("title")}".')
1023
  elif node.get('type') == 'file':
1024
  try:
1025
  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)
 
1074
 
1075
  @app.route('/get_text_content_tma/<file_id>')
1076
  def get_text_content_tma(file_id):
1077
+ file_node = get_item_node_for_user(file_id)
1078
  if not file_node or file_node.get('file_type') != 'text': return Response("Текстовый файл не найден", 404)
1079
  hf_path = file_node.get('path')
1080
  if not hf_path: return Response("Ошибка: путь к файлу отсутствует", 500)
 
1090
  return Response(text_content, mimetype='text/plain')
1091
  except Exception as e: return Response(f"Ошибка загрузки: {e}", 502)
1092
 
1093
+ @app.route('/get_note_tma/<note_id>')
1094
+ def get_note_tma(note_id):
1095
+ if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
1096
+ note_node = get_item_node_for_user(note_id)
1097
+ if not note_node or note_node.get('type') != 'note':
1098
+ return jsonify({'status': 'error', 'message': 'Note not found'}), 404
1099
+ return jsonify({'status': 'success', 'note': note_node})
1100
+
1101
+ @app.route('/create_or_update_note_tma', methods=['POST'])
1102
+ def create_or_update_note_tma():
1103
+ if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
1104
+ tma_user_id = session['telegram_user_id']
1105
+ data = load_data()
1106
+ user_data = data['users'].get(tma_user_id)
1107
+ if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
1108
+
1109
+ payload = request.json
1110
+ note_id = payload.get('note_id')
1111
+ title = payload.get('title', '').strip()
1112
+ content = payload.get('content', '')
1113
+ parent_folder_id = payload.get('parent_folder_id', 'root')
1114
+ now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
1115
+
1116
+ if not title: return jsonify({'status': 'error', 'message': 'Title cannot be empty.'}), 400
1117
+
1118
+ if note_id:
1119
+ node, _ = find_node_by_id(user_data['filesystem'], note_id)
1120
+ if not node or node.get('type') != 'note':
1121
+ return jsonify({'status': 'error', 'message': 'Note not found'}), 404
1122
+ node['title'] = title
1123
+ node['content'] = content
1124
+ node['modified_date'] = now_str
1125
+ else:
1126
+ new_note_id = uuid.uuid4().hex
1127
+ note_data = {
1128
+ 'type': 'note', 'id': new_note_id, 'title': title, 'content': content,
1129
+ 'created_date': now_str, 'modified_date': now_str
1130
+ }
1131
+ if not add_node(user_data['filesystem'], parent_folder_id, note_data):
1132
+ return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404
1133
+
1134
+ try:
1135
+ save_data(data)
1136
+ return jsonify({'status': 'success', 'message': 'Note saved.'})
1137
+ except Exception as e:
1138
+ return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500
1139
+
1140
+ @app.route('/get_reminders_tma')
1141
+ def get_reminders_tma():
1142
+ if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
1143
+ user_data = load_data()['users'].get(session['telegram_user_id'])
1144
+ if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
1145
+
1146
+ reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', ''))
1147
+ return jsonify({'status': 'success', 'reminders': reminders})
1148
+
1149
+ @app.route('/create_reminder_tma', methods=['POST'])
1150
+ def create_reminder_tma():
1151
+ if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
1152
+ tma_user_id = session['telegram_user_id']
1153
+ data = load_data()
1154
+ user_data = data['users'].get(tma_user_id)
1155
+ if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
1156
+
1157
+ payload = request.json
1158
+ text = payload.get('text', '').strip()
1159
+ dt_local_str = payload.get('datetime_local')
1160
+ user_tz_str = payload.get('user_timezone', 'UTC')
1161
+ if not text or not dt_local_str:
1162
+ return jsonify({'status': 'error', 'message': 'Missing required fields'}), 400
1163
+
1164
+ try:
1165
+ user_tz = ZoneInfo(user_tz_str)
1166
+ except ZoneInfoNotFoundError:
1167
+ user_tz = pytz.timezone('UTC')
1168
+
1169
+ dt_local = datetime.fromisoformat(dt_local_str)
1170
+ dt_aware = dt_local.astimezone(user_tz)
1171
+ dt_utc = dt_aware.astimezone(pytz.utc)
1172
+
1173
+ new_reminder = {
1174
+ 'id': uuid.uuid4().hex, 'text': text,
1175
+ 'due_datetime_utc': dt_utc.isoformat().replace('+00:00', 'Z'),
1176
+ 'due_datetime_local': dt_local.isoformat(),
1177
+ 'user_timezone': user_tz_str, 'notified': False
1178
+ }
1179
+ user_data.setdefault('reminders', []).append(new_reminder)
1180
+ try:
1181
+ save_data(data)
1182
+ return jsonify({'status': 'success'})
1183
+ except Exception as e:
1184
+ return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
1185
+
1186
+ @app.route('/delete_reminder_tma/<reminder_id>', methods=['POST'])
1187
+ def delete_reminder_tma(reminder_id):
1188
+ if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
1189
+ tma_user_id = session['telegram_user_id']
1190
+ data = load_data()
1191
+ user_data = data['users'].get(tma_user_id)
1192
+ if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
1193
+
1194
+ reminders = user_data.get('reminders', [])
1195
+ user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id]
1196
+
1197
+ try:
1198
+ save_data(data)
1199
+ return jsonify({'status': 'success'})
1200
+ except Exception as e:
1201
+ return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
1202
+
1203
  @app.route('/tma_logout')
1204
  def tma_logout():
1205
  session.clear()
 
1233
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1234
  <style>''' + BASE_STYLE + '''
1235
  .user-list { list-style: none; padding: 0; }
1236
+ .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;}
1237
  .user-item:hover { background: #2a2a2a; }
1238
+ .user-details { display: flex; align-items: center; gap: 15px; flex-grow: 1; }
1239
  .user-avatar { width: 50px; height: 50px; border-radius: 50%; object-fit: cover; background-color: #333; }
1240
  .user-info span { display: block; }
1241
  .user-info .id { font-size: 0.8em; color: var(--text-muted); }
1242
+ .user-actions { display: flex; gap: 10px; flex-shrink: 0; }
1243
  .header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px;}
1244
  .search-form { display: flex; gap: 10px; flex-grow: 1; max-width: 400px;}
1245
  .search-form input { margin: 0; }
 
1266
  <strong>{{ user.get('first_name', 'N/A') }} {{ user.get('last_name', '') }}</strong> (@{{ user.get('telegram_username', 'N/A') }})
1267
  <span class="id">ID: {{ user_id }}</span>
1268
  <span class="id">Created: {{ user.get('created_at', 'N/A') }}</span>
1269
+ <span class="id">Items: <strong>{{ user.get('item_count', 0) }}</strong></span>
1270
+ <span class="id">Reminders: <strong>{{ user.get('reminders', [])|length }}</strong></span>
1271
  </div>
1272
  </div>
1273
+ <div class="user-actions">
1274
+ <a href="{{ url_for('admin_user_files', tma_user_id_str=user_id) }}" class="btn">View Items</a>
1275
+ <a href="{{ url_for('admin_user_reminders', tma_user_id_str=user_id) }}" class="btn folder-btn">Reminders</a>
1276
+ </div>
1277
  </li>
1278
  {% else %}
1279
  <li>No users found.</li>
 
1305
  </div>
1306
  <div class="container">
1307
  <div class="admin-header">
1308
+ <h1>Items for {{ user.get('first_name', 'N/A') }}</h1>
1309
  <div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
1310
  </div>
1311
  {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
 
1322
  <div class="file-grid" id="file-container">
1323
  {% for item in items %}
1324
  <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
1325
+ <div class="item-preview-wrapper"
1326
+ {% if item.type == 'folder' %} onclick="window.location.href='{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=item.id) }}'"
1327
+ {% elif item.type == 'note' %} onclick="openModal(null, 'note', '{{ item.id }}')"
1328
+ {% 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 %}>
1329
  {% if item.type == 'folder' %}
1330
  <div class="item-preview"><i class="fa-solid fa-folder"></i></div>
1331
+ {% elif item.type == 'note' %}
1332
+ <div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
1333
  {% elif item.type == 'file' %}
1334
  {% if item.file_type == 'image' %}<img class="item-preview" src="{{ hf_file_url_jinja(item.path) }}" loading="lazy">
1335
  {% 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>
 
1340
  {% endif %}
1341
  </div>
1342
  <div class="item-name-info">
1343
+ <p class="item-name">{{ (item.title if item.type == 'note' else item.name if item.type == 'folder' else item.original_filename) | truncate(30, True) }}</p>
1344
+ {% if item.type == 'file' %}<p class="item-info">{{ item.upload_date }}</p>
1345
+ {% elif item.type == 'note' %}<p class="item-info">{{ item.modified_date }}</p>{% endif %}
1346
  </div>
1347
  <div class="item-actions-admin">
1348
+ <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?');">
1349
+ <input type="hidden" name="current_folder_id" value="{{ current_folder_id }}">
1350
+ <button type="submit" title="Delete Item"><i class="fa fa-trash"></i></button>
1351
+ </form>
 
 
1352
  </div>
1353
  </div>
1354
  {% endfor %}
 
1374
  try {
1375
  const response = await fetch(`{{ url_for('admin_download_file', tma_user_id_str=user_id, file_id='__FILE_ID__') }}`.replace('__FILE_ID__', fileId));
1376
  const data = await response.json();
1377
+ if (data.status === 'success' && data.url) { window.open(data.url, '_blank'); }
1378
+ else { alert(data.message || 'Failed to create download link.'); }
1379
+ } catch (error) { alert('Network error while creating download link.'); }
1380
+ finally { downloadBtn.innerHTML = originalHTML; closeModalManual(); }
 
 
 
 
 
 
 
1381
  }
 
1382
  async function openModal(srcOrUrl, type, itemId) {
1383
+ if (!itemId) return;
1384
  const modal = document.getElementById('mediaModal');
1385
  const modalContent = document.getElementById('modalContent');
1386
  const downloadBtn = document.getElementById('modal-download-btn');
1387
  modalContent.innerHTML = '<div class="loading-spinner"></div>';
1388
  modal.style.display = 'flex';
1389
+ downloadBtn.style.display = 'none';
1390
+
 
 
 
 
 
 
1391
  try {
1392
+ if (type === 'note') {
1393
+ const response = await fetch(`{{ url_for('admin_get_note', tma_user_id_str=user_id, note_id='__ID__') }}`.replace('__ID__', itemId));
1394
+ const data = await response.json();
1395
+ if(data.status === 'success') {
1396
+ modalContent.innerHTML = `<div style="padding:15px; text-align:left;"><h3>${data.note.title.replace(/</g,"&lt;")}</h3><hr style="border-color:#333; margin:10px 0;"><pre>${data.note.content.replace(/</g,"&lt;")}</pre></div>`;
1397
+ } else { throw new Error(data.message); }
1398
+ } else if (type === 'image') {
1399
+ modalContent.innerHTML = `<img src="${srcOrUrl}">`;
1400
+ downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
1401
+ } else if (type === 'video') {
1402
+ modalContent.innerHTML = `<video controls autoplay loop playsinline><source src="${srcOrUrl}"></video>`;
1403
+ downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
1404
+ } else if (type === 'pdf') {
1405
+ modalContent.innerHTML = `<iframe src="${srcOrUrl}"></iframe>`;
1406
+ downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
1407
+ } else if (type === 'text') {
1408
  const response = await fetch(srcOrUrl); if (!response.ok) throw new Error(`Error: ${response.statusText}`);
1409
  const text = await response.text();
1410
  modalContent.innerHTML = `<pre>${text.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}</pre>`;
1411
+ downloadBtn.onclick = () => initiateDownload(itemId); downloadBtn.style.display = 'inline-block';
1412
+ } else {
1413
+ initiateDownload(itemId);
1414
+ }
1415
  } catch (error) { modalContent.innerHTML = `<p>Preview Error: ${error.message}</p>`; }
1416
  }
1417
  function closeModal(event) { if (event.target.id === 'mediaModal') closeModalManual(); }
 
1433
  }
1434
  gridViewBtn.addEventListener('click', () => setView('grid'));
1435
  listViewBtn.addEventListener('click', () => setView('list'));
1436
+ document.addEventListener('DOMContentLoaded', () => { setView(localStorage.getItem('adminViewMode') || 'grid'); });
 
 
1437
  </script></body></html>
1438
  '''
1439
 
1440
+ ADMIN_USER_REMINDERS_HTML = '''
1441
+ <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin - User Reminders</title>
1442
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1443
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
1444
+ <style>''' + BASE_STYLE + '''
1445
+ .admin-header { padding-bottom: 15px; border-bottom: 1px solid #333; margin-bottom: 20px; }
1446
+ .reminders-list { list-style: none; }
1447
+ .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; }
1448
+ .reminder-info span { display: block; }
1449
+ .reminder-info .utc-time { font-size: 0.8em; color: var(--text-muted); }
1450
+ </style></head><body>
1451
+ <div class="app-header">
1452
+ <div class="user-info"><a href="{{ url_for('admin_panel') }}" style="color: var(--primary); text-decoration: none;">Admin Panel</a></div>
1453
+ </div>
1454
+ <div class="container">
1455
+ <div class="admin-header">
1456
+ <h1>Reminders for {{ user.get('first_name', 'N/A') }}</h1>
1457
+ <div class="user-details">@{{ user.get('telegram_username', 'N/A') }} (ID: {{ user_id }})</div>
1458
+ </div>
1459
+ {% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
1460
+ {% for category, message in messages %}<div class="flash {{ category }}">{{ message }}</div>{% endfor %}
1461
+ {% endif %}{% endwith %}
1462
+ <ul class="reminders-list">
1463
+ {% for reminder in reminders %}
1464
+ <li class="reminder-item-admin">
1465
+ <div class="reminder-info">
1466
+ <strong>{{ reminder.text }}</strong>
1467
+ <span class="utc-time">Due (UTC): {{ reminder.due_datetime_utc }}</span>
1468
+ <span class="utc-time">Notified: {{ 'Yes' if reminder.notified else 'No' }}</span>
1469
+ </div>
1470
+ <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?');">
1471
+ <button type="submit" class="btn delete-btn" style="padding: 8px 12px;"><i class="fa fa-trash"></i></button>
1472
+ </form>
1473
+ </li>
1474
+ {% else %}
1475
+ <li>No reminders for this user.</li>
1476
+ {% endfor %}
1477
+ </ul>
1478
+ </div></body></html>
1479
+ '''
1480
+
1481
  @app.route('/admin')
1482
  def admin_redirect():
1483
  return redirect(url_for('admin_login'))
 
1510
 
1511
  processed_users = {}
1512
  for user_id, user_data in all_users.items():
1513
+ user_data['item_count'] = count_items_recursive(user_data.get('filesystem'))
1514
  processed_users[user_id] = user_data
1515
 
1516
  if search_query:
 
1550
  current_folder_id = 'root'
1551
  current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
1552
 
1553
+ 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()))
1554
 
1555
  breadcrumbs = []
1556
  temp_id = current_folder_id
 
1572
  breadcrumbs=breadcrumbs,
1573
  hf_file_url_jinja=lambda path, download=False: f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{quote(path)}{'?download=true' if download else ''}")
1574
 
1575
+ @app.route('/admhosto/user/<tma_user_id_str>/reminders')
1576
+ @admin_browser_login_required
1577
+ def admin_user_reminders(tma_user_id_str):
1578
+ data = load_data()
1579
+ user_data = data['users'].get(tma_user_id_str)
1580
+ if not user_data:
1581
+ flash('User not found.', 'error')
1582
+ return redirect(url_for('admin_panel'))
1583
+
1584
+ reminders = sorted(user_data.get('reminders', []), key=lambda r: r.get('due_datetime_utc', ''), reverse=True)
1585
+ return render_template_string(ADMIN_USER_REMINDERS_HTML, user_id=tma_user_id_str, user=user_data, reminders=reminders)
1586
+
1587
  @app.route('/admhosto/download/<tma_user_id_str>/<file_id>')
1588
  @admin_browser_login_required
1589
  def admin_download_file(tma_user_id_str, file_id):
 
1621
  except Exception as e:
1622
  return Response(f"Download error: {e}", 502)
1623
 
1624
+ @app.route('/admhosto/note/<tma_user_id_str>/<note_id>')
1625
  @admin_browser_login_required
1626
+ def admin_get_note(tma_user_id_str, note_id):
1627
+ data = load_data()
1628
+ user_data = data['users'].get(tma_user_id_str)
1629
+ if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
1630
+ node, _ = find_node_by_id(user_data['filesystem'], note_id)
1631
+ if not node or node.get('type') != 'note':
1632
+ return jsonify({'status': 'error', 'message': 'Note not found'}), 404
1633
+ return jsonify({'status': 'success', 'note': node})
1634
 
1635
+ @app.route('/admhosto/delete_item/<tma_user_id_str>/<item_id>', methods=['POST'])
1636
  @admin_browser_login_required
1637
+ def admin_delete_item(tma_user_id_str, item_id):
1638
  data = load_data()
1639
  user_data = data['users'].get(tma_user_id_str)
1640
  current_folder_id = request.form.get('current_folder_id', 'root')
 
1641
  if not user_data:
1642
+ flash('User not found.', 'error'); return redirect(url_for('admin_panel'))
1643
+
1644
+ node, _ = find_node_by_id(user_data['filesystem'], item_id)
1645
+ if not node:
1646
+ flash('Item not found.', 'error')
1647
+ elif node.get('type') == 'file':
1648
+ hf_path = node.get('path')
1649
+ if not HF_TOKEN_WRITE: flash('Deletion not possible: write token not configured.', 'error')
 
 
1650
  else:
1651
  try:
1652
  api = HfApi()
1653
  if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
1654
+ if remove_node(user_data['filesystem'], item_id)[0]:
 
1655
  try: save_data(data); flash('File deleted.')
1656
  except Exception: flash('File deleted from server, but DB update failed.', 'error')
1657
  except hf_utils.EntryNotFoundError:
1658
+ if remove_node(user_data['filesystem'], item_id)[0]:
 
1659
  try: save_data(data); flash('File not found on server, removed from DB.')
1660
  except Exception: flash('DB save error (file not on server).', 'error')
1661
+ except Exception as e: flash(f'Deletion error: {e}', 'error')
1662
+ elif node.get('type') == 'note':
1663
+ if remove_node(user_data['filesystem'], item_id)[0]:
1664
+ try: save_data(data); flash('Note deleted.')
1665
+ except Exception: flash('DB update failed after note deletion.', 'error')
1666
+ elif node.get('type') == 'folder':
1667
+ if node.get('children'): flash('Folder is not empty.', 'error')
1668
+ else:
1669
+ if remove_node(user_data['filesystem'], item_id)[0]:
1670
+ try: save_data(data); flash('Folder deleted.')
1671
+ except Exception: flash('DB update failed after folder deletion.', 'error')
1672
  return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
1673
 
1674
+ @app.route('/admhosto/user/<tma_user_id_str>/delete_reminder/<reminder_id>', methods=['POST'])
1675
+ @admin_browser_login_required
1676
+ def admin_delete_reminder(tma_user_id_str, reminder_id):
1677
+ data = load_data()
1678
+ user_data = data['users'].get(tma_user_id_str)
1679
+ if not user_data:
1680
+ flash('User not found.', 'error'); return redirect(url_for('admin_panel'))
1681
+
1682
+ reminders = user_data.get('reminders', [])
1683
+ initial_len = len(reminders)
1684
+ user_data['reminders'] = [r for r in reminders if r.get('id') != reminder_id]
1685
+ if len(user_data['reminders']) < initial_len:
1686
+ try:
1687
+ save_data(data)
1688
+ flash('Reminder deleted successfully.', 'success')
1689
+ except Exception as e:
1690
+ flash(f'Failed to save data: {e}', 'error')
1691
+ else:
1692
+ flash('Reminder not found.', 'error')
1693
+ return redirect(url_for('admin_user_reminders', tma_user_id_str=tma_user_id_str))
1694
+
1695
  if __name__ == '__main__':
1696
  if not HF_TOKEN_WRITE: logging.warning("HF_TOKEN (write) is not set. Uploads/deletions will fail.")
1697
  if not HF_TOKEN_READ: logging.warning("HF_TOKEN_READ is not set. Downloads/previews might fail.")
1698
  if ADMIN_TELEGRAM_ID == "YOUR_ADMIN_TELEGRAM_USER_ID_HERE": logging.warning("ADMIN_TELEGRAM_ID is not set.")
1699
  if ADMIN_USERNAME == "admin" and ADMIN_PASSWORD == "zeusadminpass":
1700
  logging.warning("Using default admin credentials. Please change them.")
1701
+
1702
+ if HF_TOKEN_WRITE or HF_TOKEN_READ:
 
 
1703
  download_db_from_hf()
1704
  else:
1705
  if not os.path.exists(DATA_FILE):
1706
  with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'users': {}}, f)
1707
+
1708
+ if HF_TOKEN_WRITE:
1709
+ threading.Thread(target=periodic_backup, daemon=True).start()
1710
+
1711
+ threading.Thread(target=check_reminders, daemon=True).start()
1712
+
1713
  app.run(debug=False, host='0.0.0.0', port=7860)