Eluza133 commited on
Commit
1f0e1b8
·
verified ·
1 Parent(s): 6ec0068

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +466 -486
app.py CHANGED
@@ -34,6 +34,7 @@ UPLOAD_FOLDER = 'uploads_tma'
34
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
35
  cache = Cache(app, config={'CACHE_TYPE': 'simple'})
36
  logging.basicConfig(level=logging.INFO)
 
37
 
38
  BASE_STYLE = '''
39
  :root {
@@ -42,7 +43,8 @@ BASE_STYLE = '''
42
  --text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
43
  --glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
44
  --delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3);
45
- --note-color: #6a5acd; --share-color: #4caf50; --todolist-color: #20c997; --shoppinglist-color: #fd7e14;
 
46
  }
47
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
48
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -67,6 +69,7 @@ input, select, textarea { width: 100%; padding: 14px; margin: 8px 0; border: 1px
67
  .delete-btn { background: var(--delete-color); }
68
  .folder-btn { background: var(--folder-color); }
69
  .share-btn { background: var(--share-color); }
 
70
  .flash { text-align: center; margin-bottom: 15px; padding: 12px; border-radius: 10px; background: rgba(0, 221, 235, 0.1); color: var(--secondary); }
71
  .flash.error { background: rgba(255, 68, 68, 0.1); color: var(--delete-color); }
72
  .file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; }
@@ -86,7 +89,8 @@ input, select, textarea { width: 100%; padding: 14px; margin: 8px 0; border: 1px
86
  .file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; }
87
  .file-grid.list-view .item:hover { transform: translateY(0); }
88
  .file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; }
89
- .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; }
 
90
  .file-grid.list-view .item-name-info { flex-grow: 1; }
91
  .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; }
92
  .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; }
@@ -123,9 +127,23 @@ input, select, textarea { width: 100%; padding: 14px; margin: 8px 0; border: 1px
123
  .shared-link-info strong { word-break: break-all; }
124
  .shared-link-info small { color: var(--text-muted); display: block; }
125
  .shared-link-actions button { background: none; border: none; color: var(--text-muted); font-size: 1.1em; cursor: pointer; padding: 5px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  '''
127
 
128
- PUBLIC_FOLDER_PAGE_HTML = '''
129
  <!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
130
  <title>Общая папка: {{ folder.name }}</title>
131
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -180,7 +198,7 @@ body { padding-bottom: 30px; }
180
  </div></body></html>
181
  '''
182
 
183
- PUBLIC_SHOPPING_LIST_PAGE_HTML = '''
184
  <!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
185
  <title>Список покупок: {{ list_data.title }}</title>
186
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -189,96 +207,85 @@ PUBLIC_SHOPPING_LIST_PAGE_HTML = '''
189
  <style>''' + BASE_STYLE + '''
190
  body { padding-bottom: 30px; }
191
  .public-header { padding: 15px; text-align: center; border-bottom: 1px solid #333; margin-bottom: 20px; }
192
- .shopping-list { list-style: none; padding: 0; }
193
- .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; }
194
- .shopping-item.purchased { opacity: 0.6; }
195
- .shopping-item.purchased .item-name { text-decoration: line-through; }
196
- .shopping-item input[type="checkbox"] { width: 22px; height: 22px; flex-shrink: 0; accent-color: var(--accent); }
197
- .item-details { flex-grow: 1; }
198
- .item-name { font-weight: 500; }
199
- .item-quantity { color: var(--text-muted); font-size: 0.9em; }
200
  </style></head><body>
201
  <div class="public-header">
202
  <h1>Список покупок</h1>
203
- <h2>{{ list_data.title }}</h2>
204
  <p style="color: var(--text-muted);">Автор: {{ user.first_name or user.telegram_username }}</p>
205
  </div>
206
  <div class="container" style="padding-top: 15px;">
207
- <ul class="shopping-list" id="shopping-list">
208
  <div class="loading-spinner"></div>
209
- </ul>
210
  </div>
211
  <script>
212
- const listId = '{{ link.id }}';
213
- const listContainer = document.getElementById('shopping-list');
214
-
215
  function renderList(items) {
216
  listContainer.innerHTML = '';
217
  if (!items || items.length === 0) {
218
- listContainer.innerHTML = '<p>Список пуст.</p>';
219
  return;
220
  }
221
 
222
  items.sort((a, b) => a.purchased - b.purchased);
223
 
224
  items.forEach(item => {
225
- const li = document.createElement('li');
226
- li.className = 'shopping-item';
227
  if (item.purchased) {
228
- li.classList.add('purchased');
229
  }
230
- li.innerHTML = `
231
- <input type="checkbox" id="item-${item.id}" data-id="${item.id}" ${item.purchased ? 'checked' : ''}>
232
- <div class="item-details">
233
- <label for="item-${item.id}" class="item-name">${item.name}</label>
234
- <div class="item-quantity">Количество: ${item.quantity}</div>
235
- </div>
236
  `;
237
- listContainer.appendChild(li);
238
  });
239
  }
240
 
241
  async function fetchList() {
242
  try {
243
- const response = await fetch(`{{ url_for('get_shared_list_data', link_id=link.id) }}`);
244
- if (!response.ok) throw new Error('Network response was not ok');
 
 
 
245
  const data = await response.json();
246
  if (data.status === 'success') {
247
- renderList(data.items);
 
248
  } else {
249
- listContainer.innerHTML = `<p>Ошибка загрузки списка: ${data.message}</p>`;
250
  }
251
- } catch (error) {
252
- console.error('Fetch error:', error);
253
- listContainer.innerHTML = '<p>Не удалось обновить список. Проверьте соединение.</p>';
254
  }
255
  }
256
-
257
- async function updateItemStatus(itemId, isPurchased) {
258
  try {
259
- await fetch(`{{ url_for('update_shared_item_status', link_id=link.id) }}`, {
260
- method: 'POST',
261
- headers: { 'Content-Type': 'application/json' },
262
- body: JSON.stringify({ item_id: itemId, purchased: isPurchased })
 
263
  });
264
- fetchList();
265
- } catch (error) {
266
- console.error('Update error:', error);
267
- alert('Ошибка обновления статуса товара.');
 
 
268
  }
269
  }
270
 
271
- listContainer.addEventListener('change', (event) => {
272
- if (event.target.type === 'checkbox') {
273
- const itemId = event.target.dataset.id;
274
- const isPurchased = event.target.checked;
275
- updateItemStatus(itemId, isPurchased);
276
- }
277
- });
278
-
279
  document.addEventListener('DOMContentLoaded', () => {
280
  fetchList();
281
- setInterval(fetchList, 5000);
282
  });
283
  </script>
284
  </body></html>
@@ -291,11 +298,11 @@ def find_node_by_id(filesystem, node_id):
291
  queue = [(filesystem, None)]
292
  while queue:
293
  current_node, parent = queue.pop(0)
294
- if current_node.get('type') == 'folder' and 'children' in current_node:
295
  for i, child in enumerate(current_node['children']):
296
- if child.get('id') == node_id:
297
  return child, current_node
298
- if child.get('type') == 'folder':
299
  queue.append((child, current_node))
300
  return None, None
301
 
@@ -311,7 +318,7 @@ def add_node(filesystem, parent_id, node_data):
311
  def remove_node(filesystem, node_id):
312
  node_to_remove, parent_node = find_node_by_id(filesystem, node_id)
313
  if node_to_remove and parent_node and 'children' in parent_node:
314
- parent_node['children'] = [child for child in parent_node['children'] if child.get('id') != node_id]
315
  return True, node_to_remove
316
  return False, None
317
 
@@ -342,6 +349,18 @@ def get_all_folders(filesystem, exclude_ids=None):
342
  traverse(filesystem, "")
343
  return sorted(folders, key=lambda x: x['name'].lower())
344
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  def count_items_recursive(node):
346
  if not node or not isinstance(node, dict):
347
  return 0
@@ -354,7 +373,7 @@ def count_items_recursive(node):
354
  return count
355
 
356
  def initialize_user_filesystem_tma(user_data, tma_user_id_str):
357
- if 'filesystem' not in user_data:
358
  user_data['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
359
  if 'files' in user_data and isinstance(user_data['files'], list):
360
  for old_file in user_data['files']:
@@ -390,14 +409,15 @@ def load_data():
390
  return {'users': {}, 'shared_links': {}}
391
 
392
  def save_data(data):
393
- try:
394
- with open(DATA_FILE, 'w', encoding='utf-8') as file:
395
- json.dump(data, file, ensure_ascii=False, indent=4)
396
- upload_db_to_hf()
397
- cache.clear()
398
- except Exception as e:
399
- logging.error(f"Error saving data: {e}")
400
- raise
 
401
 
402
  def upload_db_to_hf():
403
  if not HF_TOKEN_WRITE: return
@@ -566,20 +586,11 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
566
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
567
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
568
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
569
- <style>''' + BASE_STYLE + '''
570
- .list-editor-items { max-height: 40vh; overflow-y: auto; margin-bottom: 10px; }
571
- .list-editor-item { display: flex; align-items: center; gap: 10px; padding: 5px; border-radius: 8px; }
572
- .list-editor-item:hover { background: #333; }
573
- .list-editor-item input[type="text"] { margin: 0; flex-grow: 1; }
574
- .list-editor-item input[type="number"] { margin: 0; width: 60px; text-align: center; }
575
- .list-editor-item .quantity-controls button { padding: 5px 10px; font-size: 1em; background: #444; }
576
- .list-editor-item .delete-item-btn { color: var(--delete-color); background: none; border: none; font-size: 1.2em; cursor: pointer; }
577
- .list-editor-add-item-form { display: flex; gap: 10px; margin-top: 10px; }
578
- </style></head><body>
579
  <div class="app-header">
580
  <div class="user-info">{{ display_name }}</div>
581
  <div class="view-toggle">
582
- <a href="{{ url_for('tma_archive') }}" style="text-decoration: none;"><button title="Архив"><i class="fa-solid fa-archive"></i></button></a>
583
  <button id="reminders-btn" title="Напоминания"><i class="fa-solid fa-bell"></i></button>
584
  <button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button>
585
  <button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button>
@@ -605,11 +616,9 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
605
  onclick="window.Telegram.WebApp.HapticFeedback.impactOccurred('light'); window.location.href='{{ url_for('tma_dashboard', folder_id=item.id) }}'"
606
  {% elif item.type == 'note' %}
607
  onclick="openNoteModal('{{ item.id }}')"
608
- {% elif item.type == 'todolist' %}
609
- onclick="openListModal('todolist', '{{ item.id }}')"
610
- {% elif item.type == 'shoppinglist' %}
611
- onclick="openListModal('shoppinglist', '{{ item.id }}')"
612
- {% else %}
613
  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 }}')"
614
  {% endif %}>
615
  <div class="item-preview-wrapper">
@@ -650,8 +659,8 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
650
 
651
  <div id="selection-bar">
652
  <span id="selection-count"></span>
653
- <button id="selection-archive-btn" class="btn" style="background: var(--todolist-color); display:none;" onclick="archiveSelected()"><i class="fa-solid fa-archive"></i></button>
654
  <button id="selection-share-btn" class="btn share-btn" onclick="openShareModal()" style="display:none;"><i class="fa-solid fa-share-alt"></i></button>
 
655
  <button id="selection-download-btn" class="btn download-btn" onclick="downloadSingleSelected()" style="display:none;"><i class="fa-solid fa-download"></i></button>
656
  <button class="btn" style="background: var(--accent);" onclick="showMoveModal()"><i class="fa-solid fa-arrow-right-to-bracket"></i></button>
657
  <button class="btn delete-btn" onclick="deleteSelected()"><i class="fa-solid fa-trash-can"></i></button>
@@ -670,21 +679,11 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
670
  <div class="modal" id="fab-modal"><div class="modal-content">
671
  <h4>Добавить в "{{ current_folder.name if current_folder_id != 'root' else 'Главная' }}"</h4>
672
  <div class="fab-options">
673
- <label for="file-input" class="fab-option" id="fab-option-upload">
674
- <i class="fa-solid fa-upload"></i><span>Файлы</span>
675
- </label>
676
- <div class="fab-option" id="fab-option-note" onclick="openNoteModal()">
677
- <i class="fa-solid fa-note-sticky"></i><span>Заметку</span>
678
- </div>
679
- <div class="fab-option" id="fab-option-folder">
680
- <i class="fa-solid fa-folder-plus"></i><span>Папку</span>
681
- </div>
682
- <div class="fab-option" id="fab-option-todolist" onclick="openListModal('todolist')">
683
- <i class="fa-solid fa-list-check"></i><span>Список дел</span>
684
- </div>
685
- <div class="fab-option" id="fab-option-shoppinglist" onclick="openListModal('shoppinglist')">
686
- <i class="fa-solid fa-cart-shopping"></i><span>Покупки</span>
687
- </div>
688
  </div>
689
  <form id="upload-form" method="POST" enctype="multipart/form-data" action="{{ url_for('tma_dashboard') }}" style="display:none;">
690
  <input type="hidden" name="current_folder_id" value="{{ current_folder_id }}">
@@ -710,16 +709,19 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
710
  </div>
711
  </div></div>
712
 
713
- <div class="modal" id="list-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
714
- <h4 id="list-modal-title">Новый список</h4>
715
- <input type="hidden" id="list-id-input">
716
- <input type="hidden" id="list-type-input">
717
- <input type="text" id="list-title-input" placeholder="Название списка" style="font-size: 1.1em; margin-bottom: 10px;">
718
- <div id="list-items-container" class="list-editor-items"></div>
719
- <div id="list-add-item-form-container"></div>
720
- <div style="display: flex; gap: 10px; margin-top: 15px;">
 
 
 
721
  <button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveList()">Сохранить</button>
722
- <button class="btn" style="background: #555; flex-grow: 1;" onclick="closeListModal()">Отмена</button>
723
  </div>
724
  </div></div>
725
 
@@ -838,20 +840,17 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
838
  }
839
  function updateSelectionUI() {
840
  selectionCount.textContent = `Выбрано: ${selectedItems.size}`;
841
- if (selectedItems.size !== 1) {
842
- selectionDownloadBtn.style.display = 'none';
843
- selectionShareBtn.style.display = 'none';
844
- selectionArchiveBtn.style.display = 'none';
845
- return;
846
- }
847
  const firstSelectedId = selectedItems.values().next().value;
848
  const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`);
849
- if (!itemElement) return;
850
- const itemType = itemElement.dataset.type;
851
-
852
- selectionDownloadBtn.style.display = (itemType === 'file') ? 'inline-block' : 'none';
853
- selectionShareBtn.style.display = (itemType === 'folder' || itemType === 'shoppinglist') ? 'inline-block' : 'none';
854
- selectionArchiveBtn.style.display = (itemType === 'todolist' || itemType === 'shoppinglist') ? 'inline-block' : 'none';
 
 
 
855
  }
856
  allItems.forEach(item => {
857
  item.addEventListener('pointerdown', e => {
@@ -904,11 +903,15 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
904
  if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); }
905
  });
906
  }
907
- function archiveSelected() {
908
- if (selectedItems.size !== 1) return;
909
- const listId = selectedItems.values().next().value;
910
  haptic.impactOccurred('medium');
911
- performBatchAction(`{{ url_for("archive_list_tma", list_id='__ID__') }}`.replace('__ID__', listId), {});
 
 
 
 
 
912
  }
913
  async function openNoteModal(noteId = null) {
914
  haptic.impactOccurred('light');
@@ -1013,8 +1016,9 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
1013
  const itemId = selectedItems.values().next().value;
1014
  const itemElement = document.querySelector(`.item[data-id='${itemId}']`);
1015
  const itemType = itemElement.dataset.type;
 
 
1016
  const listEl = document.getElementById('existing-links-list');
1017
- document.getElementById('share-modal-title').textContent = itemType === 'folder' ? 'Поделиться папкой' : 'Поделиться списком';
1018
  listEl.innerHTML = '<div class="loading-spinner"></div>';
1019
  document.getElementById('share-modal').style.display = 'flex';
1020
  const response = await fetch(`{{ url_for('get_public_links', item_id='ITEM_ID') }}`.replace('ITEM_ID', itemId));
@@ -1042,7 +1046,8 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
1042
  }
1043
  async function createShareLink() {
1044
  const itemId = selectedItems.values().next().value;
1045
- const itemType = document.querySelector(`.item[data-id='${itemId}']`).dataset.type;
 
1046
  const name = document.getElementById('share-link-name').value;
1047
  const duration_hours = document.getElementById('share-link-duration').value;
1048
  const response = await fetch('{{ url_for("create_public_link") }}', {
@@ -1082,121 +1087,8 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
1082
  haptic.notificationOccurred('error');
1083
  });
1084
  }
1085
- function setupListModal(type, itemsContainer, addFormContainer) {
1086
- itemsContainer.innerHTML = '';
1087
- if (type === 'todolist') {
1088
- addFormContainer.innerHTML = `
1089
- <form onsubmit="addTodoItem(event)" class="list-editor-add-item-form">
1090
- <input type="text" id="add-task-input" placeholder="Новая задача" required>
1091
- <button type="submit" class="btn" style="padding: 10px 15px;">+</button>
1092
- </form>`;
1093
- } else if (type === 'shoppinglist') {
1094
- addFormContainer.innerHTML = `
1095
- <form onsubmit="addShoppingItem(event)" class="list-editor-add-item-form">
1096
- <input type="text" id="add-shopping-name-input" placeholder="Название товара" required style="flex-grow:1;">
1097
- <input type="number" id="add-shopping-qty-input" value="1" min="1" style="width: 70px;">
1098
- <button type="submit" class="btn" style="padding: 10px 15px;">+</button>
1099
- </form>`;
1100
- }
1101
- }
1102
- function renderTodoItem(item) {
1103
- return `<div class="list-editor-item" data-id="${item.id}">
1104
- <input type="checkbox" ${item.completed ? 'checked' : ''}>
1105
- <input type="text" value="${item.text.replace(/"/g, '&quot;')}">
1106
- <button class="delete-item-btn" onclick="this.parentElement.remove()">&times;</button>
1107
- </div>`;
1108
- }
1109
- function renderShoppingItem(item) {
1110
- return `<div class="list-editor-item" data-id="${item.id}">
1111
- <input type="text" value="${item.name.replace(/"/g, '&quot;')}" class="shopping-item-name">
1112
- <div class="quantity-controls" style="display:flex; align-items:center;">
1113
- <button type="button" onclick="this.nextElementSibling.stepDown()">-</button>
1114
- <input type="number" value="${item.quantity}" min="1" class="shopping-item-qty">
1115
- <button type="button" onclick="this.previousElementSibling.stepUp()">+</button>
1116
- </div>
1117
- <button class="delete-item-btn" onclick="this.parentElement.remove()">&times;</button>
1118
- </div>`;
1119
- }
1120
- function addTodoItem(event) {
1121
- event.preventDefault();
1122
- const input = document.getElementById('add-task-input');
1123
- if (!input.value.trim()) return;
1124
- const newItem = { id: 'new_' + Date.now(), text: input.value.trim(), completed: false };
1125
- document.getElementById('list-items-container').insertAdjacentHTML('beforeend', renderTodoItem(newItem));
1126
- input.value = '';
1127
- }
1128
- function addShoppingItem(event) {
1129
- event.preventDefault();
1130
- const nameInput = document.getElementById('add-shopping-name-input');
1131
- const qtyInput = document.getElementById('add-shopping-qty-input');
1132
- if (!nameInput.value.trim()) return;
1133
- const newItem = { id: 'new_' + Date.now(), name: nameInput.value.trim(), quantity: parseInt(qtyInput.value) || 1, purchased: false };
1134
- document.getElementById('list-items-container').insertAdjacentHTML('beforeend', renderShoppingItem(newItem));
1135
- nameInput.value = '';
1136
- qtyInput.value = '1';
1137
- }
1138
- async function openListModal(type, listId = null) {
1139
- haptic.impactOccurred('light');
1140
- closeFabModal();
1141
- const modal = document.getElementById('list-modal');
1142
- const titleEl = document.getElementById('list-modal-title');
1143
- const idInput = document.getElementById('list-id-input');
1144
- const typeInput = document.getElementById('list-type-input');
1145
- const titleInput = document.getElementById('list-title-input');
1146
- const itemsContainer = document.getElementById('list-items-container');
1147
- const addFormContainer = document.getElementById('list-add-item-form-container');
1148
-
1149
- typeInput.value = type;
1150
- setupListModal(type, itemsContainer, addFormContainer);
1151
-
1152
- if (listId) {
1153
- titleEl.textContent = type === 'todolist' ? 'Редактировать список дел' : 'Редактировать список покупок';
1154
- const response = await fetch(`{{ url_for('get_list_tma', list_id='__ID__') }}`.replace('__ID__', listId));
1155
- const data = await response.json();
1156
- if (data.status === 'success') {
1157
- idInput.value = data.list.id;
1158
- titleInput.value = data.list.title;
1159
- itemsContainer.innerHTML = '';
1160
- if (type === 'todolist') data.list.tasks.forEach(item => itemsContainer.insertAdjacentHTML('beforeend', renderTodoItem(item)));
1161
- if (type === 'shoppinglist') data.list.items.forEach(item => itemsContainer.insertAdjacentHTML('beforeend', renderShoppingItem(item)));
1162
- } else { Telegram.WebApp.showAlert('Ошибка загрузки списка.'); return; }
1163
- } else {
1164
- titleEl.textContent = type === 'todolist' ? 'Новый список дел' : 'Новый список покупок';
1165
- idInput.value = ''; titleInput.value = ''; itemsContainer.innerHTML = '';
1166
- }
1167
- modal.style.display = 'flex';
1168
- }
1169
- function closeListModal() { document.getElementById('list-modal').style.display = 'none'; }
1170
- async function saveList() {
1171
- const id = document.getElementById('list-id-input').value;
1172
- const type = document.getElementById('list-type-input').value;
1173
- const title = document.getElementById('list-title-input').value;
1174
- if (!title.trim()) { Telegram.WebApp.showAlert('Название не может быть пустым.'); return; }
1175
-
1176
- let payload = { list_id: id, type: type, title: title, parent_folder_id: '{{ current_folder_id }}' };
1177
-
1178
- if (type === 'todolist') {
1179
- payload.tasks = Array.from(document.querySelectorAll('#list-items-container .list-editor-item')).map(el => ({
1180
- id: el.dataset.id.startsWith('new_') ? null : el.dataset.id,
1181
- text: el.querySelector('input[type="text"]').value,
1182
- completed: el.querySelector('input[type="checkbox"]').checked
1183
- }));
1184
- } else if (type === 'shoppinglist') {
1185
- payload.items = Array.from(document.querySelectorAll('#list-items-container .list-editor-item')).map(el => ({
1186
- id: el.dataset.id.startsWith('new_') ? null : el.dataset.id,
1187
- name: el.querySelector('.shopping-item-name').value,
1188
- quantity: parseInt(el.querySelector('.shopping-item-qty').value) || 1
1189
- }));
1190
- }
1191
-
1192
- const response = await fetch('{{ url_for("create_or_update_list_tma") }}', {
1193
- method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
1194
- });
1195
- const result = await response.json();
1196
- if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
1197
- else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); }
1198
- }
1199
  document.getElementById('reminders-btn').addEventListener('click', openRemindersModal);
 
1200
  const gridViewBtn = document.getElementById('grid-view-btn');
1201
  const listViewBtn = document.getElementById('list-view-btn');
1202
  const fileContainer = document.getElementById('file-container');
@@ -1230,12 +1122,111 @@ TMA_DASHBOARD_HTML_TEMPLATE = '''
1230
  backButton.onClick(() => { haptic.impactOccurred('light'); window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`; });
1231
  } else { window.Telegram.WebApp.BackButton.hide(); }
1232
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
  </script></body></html>
1234
  '''
1235
 
1236
- TMA_ARCHIVE_PAGE_HTML = '''
1237
  <!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
1238
- <title>Архив - Zeus Cloud</title>
1239
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1240
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
1241
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
@@ -1244,12 +1235,12 @@ TMA_ARCHIVE_PAGE_HTML = '''
1244
  <div class="app-header">
1245
  <div class="user-info">{{ display_name }}</div>
1246
  <div class="view-toggle">
1247
- <a href="{{ url_for('tma_dashboard') }}" style="text-decoration: none;"><button title="Домой"><i class="fa-solid fa-home"></i></button></a>
1248
  </div>
1249
  </div>
1250
- <div class="container" id="main-container">
1251
  <h2>Архив</h2>
1252
- <div class="file-grid list-view" id="file-container">
1253
  {% for item in items %}
1254
  <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
1255
  <div class="item-preview-wrapper">
@@ -1257,13 +1248,12 @@ TMA_ARCHIVE_PAGE_HTML = '''
1257
  {% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div>
1258
  {% endif %}
1259
  </div>
1260
- <div class="item-name-info" style="flex-grow: 1;">
1261
  <p class="item-name">{{ item.title }}</p>
1262
- <p class="item-info">Заархивировано: {{ item.modified_date }}</p>
1263
- <p class="item-info">Путь: {{ item.path_string }}</p>
1264
  </div>
1265
- <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>
1266
- <button class="btn delete-btn" onclick="deleteItem('{{ item.id }}')" title="Удалить" style="padding: 8px 12px;"><i class="fa-solid fa-trash"></i></button>
1267
  </div>
1268
  {% endfor %}
1269
  {% if not items %}<p>Архив пуст.</p>{% endif %}
@@ -1271,71 +1261,31 @@ TMA_ARCHIVE_PAGE_HTML = '''
1271
  </div>
1272
  <script>
1273
  window.Telegram.WebApp.ready();
1274
- window.Telegram.WebApp.expand();
1275
  const haptic = window.Telegram.WebApp.HapticFeedback;
1276
 
1277
- async function performAction(url, confirmMessage) {
1278
- Telegram.WebApp.showConfirm(confirmMessage, async (ok) => {
1279
- if (ok) {
1280
- haptic.impactOccurred('heavy');
1281
- try {
1282
- const response = await fetch(url, { method: 'POST' });
1283
- const result = await response.json();
1284
- if (result.status === 'success') {
1285
- haptic.notificationOccurred('success');
1286
- window.location.reload();
1287
- } else {
1288
- haptic.notificationOccurred('error');
1289
- Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.');
1290
- }
1291
- } catch (error) {
1292
- haptic.notificationOccurred('error');
1293
- Telegram.WebApp.showAlert('Сетевая ошибка.');
1294
- }
1295
- }
1296
- });
1297
  }
1298
-
1299
  function unarchiveItem(itemId) {
1300
- const url = `{{ url_for("archive_list_tma", list_id='__ID__') }}`.replace('__ID__', itemId) + '?unarchive=true';
1301
- performAction(url, 'Разархивировать этот список?');
1302
  }
1303
 
1304
  function deleteItem(itemId) {
1305
- const url = `{{ url_for("batch_delete_tma") }}`;
1306
- Telegram.WebApp.showConfirm('Вы уверены, что хотите удалить этот список навсегда?', async (ok) => {
1307
  if (ok) {
1308
  haptic.impactOccurred('heavy');
1309
- try {
1310
- const response = await fetch(url, {
1311
- method: 'POST',
1312
- headers: { 'Content-Type': 'application/json' },
1313
- body: JSON.stringify({ item_ids: [itemId] })
1314
- });
1315
- const result = await response.json();
1316
- if (result.status === 'success') {
1317
- haptic.notificationOccurred('success');
1318
- window.location.reload();
1319
- } else {
1320
- haptic.notificationOccurred('error');
1321
- Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.');
1322
- }
1323
- } catch (error) {
1324
- haptic.notificationOccurred('error');
1325
- Telegram.WebApp.showAlert('Сетевая ошибка.');
1326
- }
1327
  }
1328
  });
1329
  }
1330
-
1331
- document.addEventListener('DOMContentLoaded', () => {
1332
- let backButton = window.Telegram.WebApp.BackButton;
1333
- backButton.show();
1334
- backButton.onClick(() => {
1335
- haptic.impactOccurred('light');
1336
- window.location.href = `{{ url_for('tma_dashboard') }}`;
1337
- });
1338
- });
1339
  </script>
1340
  </body></html>
1341
  '''
@@ -1362,10 +1312,10 @@ def tma_dashboard():
1362
  current_folder, parent_folder = find_node_by_id(user_data['filesystem'], 'root')
1363
 
1364
  parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root'
1365
-
1366
- items_in_folder = [item for item in current_folder.get('children', []) if not item.get('archived')]
1367
- 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()))
1368
 
 
 
 
1369
  if request.method == 'POST':
1370
  if not HF_TOKEN_WRITE:
1371
  flash('Загрузка невозможна: токен для записи не настроен.', 'error')
@@ -1420,27 +1370,18 @@ def tma_dashboard():
1420
  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)
1421
 
1422
  @app.route('/tma_archive')
1423
- def tma_archive():
1424
  if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
1425
  tma_user_id = session['telegram_user_id']
1426
  display_name = session.get('telegram_display_name', 'Пользователь')
1427
  data = load_data()
1428
  user_data = data['users'].get(tma_user_id)
1429
  if not user_data: return redirect(url_for('tma_entry_page'))
1430
-
1431
- archived_items = []
1432
- def find_archived(node):
1433
- if node.get('type') == 'folder':
1434
- for child in node.get('children', []):
1435
- if child.get('archived'):
1436
- child['path_string'] = get_node_path_string(user_data['filesystem'], node.get('id'))
1437
- archived_items.append(child)
1438
- if child.get('type') == 'folder':
1439
- find_archived(child)
1440
- find_archived(user_data.get('filesystem'))
1441
 
 
1442
  sorted_items = sorted(archived_items, key=lambda x: x.get('modified_date', ''), reverse=True)
1443
- return render_template_string(TMA_ARCHIVE_PAGE_HTML, display_name=display_name, items=sorted_items)
 
1444
 
1445
  @app.route('/create_folder_tma', methods=['POST'])
1446
  def create_folder_tma():
@@ -1589,23 +1530,22 @@ def batch_delete_tma():
1589
  if not node:
1590
  errors.append(f"Элемент {item_id} не найден.")
1591
  continue
1592
- item_type = node.get('type')
1593
- item_name = node.get('name') or node.get('title') or node.get('original_filename')
 
 
 
1594
 
1595
- if item_type == 'folder':
1596
- if node.get('children'): errors.append(f'Папка "{item_name}" не пуста.'); continue
1597
- if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
1598
- else: errors.append(f'Ошибка удаления папки "{item_name}".')
1599
- elif item_type in ['note', 'todolist', 'shoppinglist']:
1600
- if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
1601
- else: errors.append(f'Ошибка удаления "{item_name}".')
1602
- elif item_type == 'file':
1603
- try:
1604
- 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)
1605
- except hf_utils.EntryNotFoundError: pass
1606
- except Exception as e: errors.append(f'Ошибка удаления "{item_name}" с сервера: {e}'); continue
1607
  if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
1608
- else: errors.append(f'Ошибка удаления "{item_name}" из базы.')
1609
 
1610
  if success_count > 0:
1611
  try: save_data(data)
@@ -1642,6 +1582,7 @@ def batch_move_tma():
1642
  if item_id == destination_id: continue
1643
  removed, node_to_move = remove_node(user_data['filesystem'], item_id)
1644
  if removed and node_to_move:
 
1645
  if add_node(user_data['filesystem'], destination_id, node_to_move): moved_count += 1
1646
  else: errors.append(f'Ошибка добавления {item_id} в новую папку.')
1647
  else: errors.append(f'Не удалось извлечь {item_id}.')
@@ -1652,6 +1593,56 @@ def batch_move_tma():
1652
  if errors: return jsonify({'status': 'error', 'message': f'Перемещено {moved_count}. Ошибки: ' + "; ".join(errors)})
1653
  return jsonify({'status': 'success', 'message': f'Перемещено {moved_count} элемент(ов).'})
1654
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1655
  @app.route('/get_text_content_tma/<file_id>')
1656
  def get_text_content_tma(file_id):
1657
  file_node = get_item_node_for_user(file_id)
@@ -1732,76 +1723,41 @@ def create_or_update_list_tma():
1732
  data = load_data()
1733
  user_data = data['users'].get(tma_user_id)
1734
  if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
1735
-
1736
  payload = request.json
1737
  list_id = payload.get('list_id')
1738
  list_type = payload.get('type')
1739
  title = payload.get('title', '').strip()
 
1740
  parent_folder_id = payload.get('parent_folder_id', 'root')
1741
  now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
1742
 
1743
- if not title or list_type not in ['todolist', 'shoppinglist']:
1744
- return jsonify({'status': 'error', 'message': 'Invalid data provided.'}), 400
 
 
 
1745
 
1746
  if list_id:
1747
  node, _ = find_node_by_id(user_data['filesystem'], list_id)
1748
- if not node or node.get('type') != list_type:
1749
- return jsonify({'status': 'error', 'message': 'List not found'}), 404
1750
  node['title'] = title
 
1751
  node['modified_date'] = now_str
1752
- if list_type == 'todolist':
1753
- node['tasks'] = payload.get('tasks', [])
1754
- for task in node['tasks']:
1755
- if not task.get('id'): task['id'] = uuid.uuid4().hex
1756
- elif list_type == 'shoppinglist':
1757
- existing_items = {item['id']: item for item in node.get('items', [])}
1758
- new_items_data = payload.get('items', [])
1759
- updated_items = []
1760
- for item_data in new_items_data:
1761
- item_id = item_data.get('id')
1762
- if item_id and item_id in existing_items:
1763
- existing_items[item_id].update(item_data)
1764
- updated_items.append(existing_items[item_id])
1765
- else:
1766
- item_data['id'] = uuid.uuid4().hex
1767
- item_data['purchased'] = False
1768
- updated_items.append(item_data)
1769
- node['items'] = updated_items
1770
-
1771
  else:
1772
  new_list_id = uuid.uuid4().hex
1773
  list_data = {
1774
- 'type': list_type, 'id': new_list_id, 'title': title, 'archived': False,
1775
- 'created_date': now_str, 'modified_date': now_str
1776
  }
1777
- if list_type == 'todolist':
1778
- list_data['tasks'] = payload.get('tasks', [])
1779
- for task in list_data['tasks']: task['id'] = uuid.uuid4().hex
1780
- elif list_type == 'shoppinglist':
1781
- list_data['items'] = payload.get('items', [])
1782
- for item in list_data['items']:
1783
- item['id'] = uuid.uuid4().hex
1784
- item['purchased'] = False
1785
-
1786
  if not add_node(user_data['filesystem'], parent_folder_id, list_data):
1787
  return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404
1788
 
1789
- try: save_data(data); return jsonify({'status': 'success', 'message': 'List saved.'})
1790
- except Exception as e: return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500
1791
-
1792
- @app.route('/archive_list_tma/<list_id>', methods=['POST'])
1793
- def archive_list_tma(list_id):
1794
- if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Unauthorized'}), 401
1795
- list_node = get_item_node_for_user(list_id)
1796
- if not list_node or list_node.get('type') not in ['todolist', 'shoppinglist']:
1797
- return jsonify({'status': 'error', 'message': 'List not found'}), 404
1798
-
1799
- unarchive = request.args.get('unarchive', 'false').lower() == 'true'
1800
- list_node['archived'] = not unarchive
1801
- list_node['modified_date'] = datetime.now().strftime('%Y-%m-%d %H:%M')
1802
-
1803
- try: save_data(load_data()); return jsonify({'status': 'success'})
1804
- except Exception as e: return jsonify({'status': 'error', 'message': f'Failed to save: {e}'}), 500
1805
 
1806
  @app.route('/get_reminders_tma')
1807
  def get_reminders_tma():
@@ -1891,10 +1847,12 @@ def create_public_link():
1891
  return jsonify({'status': 'error', 'message': 'Элемент не найден или не может быть опубликован.'}), 404
1892
 
1893
  now = datetime.now(pytz.utc)
1894
- expires_at_iso = None
1895
  if duration_hours > 0:
1896
  expires_at = now + timedelta(hours=duration_hours)
1897
  expires_at_iso = expires_at.isoformat()
 
 
1898
 
1899
  link_id = uuid.uuid4().hex
1900
  link_data = {
@@ -1906,10 +1864,7 @@ def create_public_link():
1906
 
1907
  try:
1908
  save_data(data)
1909
- if item_type == 'folder':
1910
- public_url = url_for('shared_folder_view', link_id=link_id, _external=True)
1911
- else: # shoppinglist
1912
- public_url = url_for('shared_list_view', link_id=link_id, _external=True)
1913
  return jsonify({'status': 'success', 'url': public_url})
1914
  except Exception as e:
1915
  return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
@@ -1957,21 +1912,46 @@ def get_public_links(item_id):
1957
  for link_id in link_ids:
1958
  link_data = data['shared_links'].get(link_id)
1959
  if link_data:
1960
- if link_data.get('item_type') == 'folder':
1961
- link_data['url'] = url_for('shared_folder_view', link_id=link_id, _external=True)
1962
- else:
1963
- link_data['url'] = url_for('shared_list_view', link_id=link_id, _external=True)
1964
  links_details.append(link_data)
1965
 
1966
  return jsonify({'status': 'success', 'links': links_details})
1967
 
1968
  @app.route('/shared/<link_id>')
1969
- @app.route('/shared/<link_id>/<subfolder_id>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1970
  def shared_folder_view(link_id, subfolder_id=None):
1971
  data = load_data()
1972
  link_data = data['shared_links'].get(link_id)
1973
 
1974
- if not link_data or link_data.get('item_type') != 'folder': return "Ссылка недействительна.", 404
1975
 
1976
  if link_data.get('expires_at'):
1977
  expires_at = datetime.fromisoformat(link_data['expires_at'])
@@ -1990,93 +1970,79 @@ def shared_folder_view(link_id, subfolder_id=None):
1990
 
1991
  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()))
1992
 
1993
- 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 ''}")
1994
 
1995
- @app.route('/shared_list/<link_id>')
1996
- def shared_list_view(link_id):
1997
  data = load_data()
1998
  link_data = data['shared_links'].get(link_id)
1999
- if not link_data or link_data.get('item_type') != 'shoppinglist': return "Ссылка недействительна.", 404
 
2000
  if link_data.get('expires_at'):
2001
  expires_at = datetime.fromisoformat(link_data['expires_at'])
2002
- if datetime.now(pytz.utc) > expires_at: return "Срок действия ссылки истек.", 410
2003
-
 
2004
  user_id = link_data['user_id']
2005
  user_data = data['users'].get(user_id)
2006
- if not user_data: return "Владелец не найден.", 404
2007
-
2008
- list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
2009
- if not list_node or list_node.get('type') != 'shoppinglist': return "Список не найден.", 404
2010
 
2011
- return render_template_string(PUBLIC_SHOPPING_LIST_PAGE_HTML, list_data=list_node, user=user_data, link=link_data)
 
 
2012
 
2013
- @app.route('/get_shared_list_data/<link_id>')
2014
- def get_shared_list_data(link_id):
2015
  data = load_data()
2016
- link_data = data['shared_links'].get(link_id)
2017
- if not link_data or link_data.get('item_type') != 'shoppinglist': return jsonify({'status':'error', 'message':'Ссылка недействительна'}), 404
 
 
 
2018
  user_id = link_data['user_id']
2019
  user_data = data['users'].get(user_id)
2020
- if not user_data: return jsonify({'status':'error', 'message':'Владелец не найден'}), 404
 
2021
  list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
2022
- if not list_node: return jsonify({'status':'error', 'message':'Список не найден'}), 404
2023
-
2024
- return jsonify({'status': 'success', 'items': list_node.get('items', [])})
2025
 
2026
- @app.route('/update_shared_item_status/<link_id>', methods=['POST'])
2027
- def update_shared_item_status(link_id):
2028
  data = load_data()
2029
- link_data = data['shared_links'].get(link_id)
2030
- if not link_data or link_data.get('item_type') != 'shoppinglist': return jsonify({'status': 'error', 'message': 'Ссылка недействительна'}), 404
2031
-
 
 
2032
  user_id = link_data['user_id']
2033
  user_data = data['users'].get(user_id)
2034
- if not user_data: return jsonify({'status': 'error', 'message': 'Владелец не найден'}), 404
2035
-
2036
- list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
2037
- if not list_node: return jsonify({'status': 'error', 'message': 'Список не найден'}), 404
2038
 
2039
- payload = request.json
2040
- item_id_to_update = payload.get('item_id')
2041
- purchased_status = payload.get('purchased')
2042
 
2043
  item_found = False
2044
  for item in list_node.get('items', []):
2045
- if item.get('id') == item_id_to_update:
2046
- item['purchased'] = purchased_status
2047
  item_found = True
2048
  break
2049
-
2050
  if item_found:
 
2051
  try:
2052
  save_data(data)
2053
  return jsonify({'status': 'success'})
2054
  except Exception as e:
2055
  return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
2056
  else:
2057
- return jsonify({'status': 'error', 'message': 'Элемент не найден в списке'}), 404
2058
 
2059
- @app.route('/public_download/<link_id>/<item_id>')
2060
- def public_download_via_link(link_id, item_id):
2061
- data = load_data()
2062
- link_data = data['shared_links'].get(link_id)
2063
- if not link_data: return Response("Ссылка недействительна.", status=404)
2064
-
2065
- if link_data.get('expires_at'):
2066
- expires_at = datetime.fromisoformat(link_data['expires_at'])
2067
- if datetime.now(pytz.utc) > expires_at:
2068
- return Response("Срок действия ссылки истек.", status=410)
2069
-
2070
- user_id = link_data['user_id']
2071
- user_data = data['users'].get(user_id)
2072
- if not user_data: return Response("Владелец не найден.", status=404)
2073
-
2074
- item_node, _ = find_node_by_id(user_data['filesystem'], item_id)
2075
- if not item_node: return Response("Элемент не найден.", status=404)
2076
-
2077
- token = uuid.uuid4().hex
2078
- cache.set(f"download_token_{token}", item_node, timeout=300)
2079
- return redirect(url_for('public_download', token=token))
2080
 
2081
  ADMIN_LOGIN_HTML = '''
2082
  <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin Login</title>
@@ -2196,8 +2162,9 @@ ADMIN_USER_FILES_HTML = '''
2196
  <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
2197
  <div class="item-preview-wrapper"
2198
  {% if item.type == 'folder' %} onclick="window.location.href='{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=item.id) }}'"
2199
- {% elif item.type in ['note', 'todolist', 'shoppinglist'] %} onclick="openModal(null, '{{item.type}}', '{{ item.id }}')"
2200
  {% 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 %}>
 
2201
  {% if item.type == 'folder' %}<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
2202
  {% elif item.type == 'note' %}<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
2203
  {% elif item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div>
@@ -2265,12 +2232,19 @@ ADMIN_USER_FILES_HTML = '''
2265
  const response = await fetch(`{{ url_for('admin_get_item', tma_user_id_str=user_id, item_id='__ID__') }}`.replace('__ID__', itemId));
2266
  const data = await response.json();
2267
  if(data.status === 'success') {
2268
- 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;">`;
2269
- if (type === 'note') contentHTML += `<pre>${data.item.content.replace(/</g,"&lt;")}</pre>`;
2270
- if (type === 'todolist') contentHTML += `<ul>${data.item.tasks.map(t => `<li><input type='checkbox' ${t.completed ? 'checked' : ''} disabled> ${t.text}</li>`).join('')}</ul>`;
2271
- 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>`;
2272
- contentHTML += `</div>`;
2273
- modalContent.innerHTML = contentHTML;
 
 
 
 
 
 
 
2274
  } else { throw new Error(data.message); }
2275
  } else if (type === 'image') {
2276
  modalContent.innerHTML = `<img src="${srcOrUrl}">`;
@@ -2426,8 +2400,9 @@ def admin_user_files(tma_user_id_str):
2426
  flash('Folder not found!', 'error')
2427
  current_folder_id = 'root'
2428
  current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
2429
-
2430
- 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()))
 
2431
 
2432
  breadcrumbs = []
2433
  temp_id = current_folder_id
@@ -2505,7 +2480,8 @@ def admin_get_item(tma_user_id_str, item_id):
2505
  user_data = data['users'].get(tma_user_id_str)
2506
  if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
2507
  node, _ = find_node_by_id(user_data['filesystem'], item_id)
2508
- if not node: return jsonify({'status': 'error', 'message': 'Item not found'}), 404
 
2509
  return jsonify({'status': 'success', 'item': node})
2510
 
2511
  @app.route('/admhosto/delete_item/<tma_user_id_str>/<item_id>', methods=['POST'])
@@ -2520,31 +2496,34 @@ def admin_delete_item(tma_user_id_str, item_id):
2520
  node, _ = find_node_by_id(user_data['filesystem'], item_id)
2521
  if not node:
2522
  flash('Item not found.', 'error')
2523
- elif node.get('type') == 'file':
2524
- hf_path = node.get('path')
2525
- if not HF_TOKEN_WRITE: flash('Deletion not possible: write token not configured.', 'error')
2526
- else:
2527
- try:
2528
- api = HfApi()
2529
- if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
2530
- if remove_node(user_data['filesystem'], item_id)[0]:
2531
- try: save_data(data); flash('File deleted.')
2532
- except Exception: flash('File deleted from server, but DB update failed.', 'error')
2533
- except hf_utils.EntryNotFoundError:
2534
- if remove_node(user_data['filesystem'], item_id)[0]:
2535
- try: save_data(data); flash('File not found on server, removed from DB.')
2536
- except Exception: flash('DB save error (file not on server).', 'error')
2537
- except Exception as e: flash(f'Deletion error: {e}', 'error')
2538
- elif node.get('type') in ['note', 'todolist', 'shoppinglist']:
 
 
 
2539
  if remove_node(user_data['filesystem'], item_id)[0]:
2540
- try: save_data(data); flash(f'{node.get("type").capitalize()} deleted.')
2541
- except Exception: flash('DB update failed after item deletion.', 'error')
2542
- elif node.get('type') == 'folder':
2543
- if node.get('children'): flash('Folder is not empty.', 'error')
 
2544
  else:
2545
- if remove_node(user_data['filesystem'], item_id)[0]:
2546
- try: save_data(data); flash('Folder deleted.')
2547
- except Exception: flash('DB update failed after folder deletion.', 'error')
2548
  return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
2549
 
2550
  @app.route('/admhosto/user/<tma_user_id_str>/delete_reminder/<reminder_id>', methods=['POST'])
@@ -2587,3 +2566,4 @@ if __name__ == '__main__':
2587
  threading.Thread(target=check_reminders, daemon=True).start()
2588
 
2589
  app.run(debug=False, host='0.0.0.0', port=7860)
 
 
34
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
35
  cache = Cache(app, config={'CACHE_TYPE': 'simple'})
36
  logging.basicConfig(level=logging.INFO)
37
+ save_data_lock = threading.Lock()
38
 
39
  BASE_STYLE = '''
40
  :root {
 
43
  --text-dark: #e0e0e0; --text-muted: #888; --shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
44
  --glass-bg: rgba(30, 30, 30, 0.7); --transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
45
  --delete-color: #ff4444; --folder-color: #ffc107; --selection-color: rgba(139, 92, 246, 0.3);
46
+ --note-color: #6a5acd; --share-color: #4caf50; --archive-color: #78909c;
47
+ --todolist-color: #29b6f6; --shoppinglist-color: #ffa726;
48
  }
49
  @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
50
  * { margin: 0; padding: 0; box-sizing: border-box; }
 
69
  .delete-btn { background: var(--delete-color); }
70
  .folder-btn { background: var(--folder-color); }
71
  .share-btn { background: var(--share-color); }
72
+ .archive-btn { background: var(--archive-color); }
73
  .flash { text-align: center; margin-bottom: 15px; padding: 12px; border-radius: 10px; background: rgba(0, 221, 235, 0.1); color: var(--secondary); }
74
  .flash.error { background: rgba(255, 68, 68, 0.1); color: var(--delete-color); }
75
  .file-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; }
 
89
  .file-grid.list-view .item { flex-direction: row; align-items: center; text-align: left; padding: 8px; }
90
  .file-grid.list-view .item:hover { transform: translateY(0); }
91
  .file-grid.list-view .item-preview-wrapper { width: 45px; height: 45px; padding-top: 0; margin-bottom: 0; margin-right: 15px; flex-shrink: 0; }
92
+ .file-grid.list-view .item.folder .item-preview, .file-grid.list-view .item.note .item-preview,
93
+ .file-grid.list-view .item.todolist .item-preview, .file-grid.list-view .item.shoppinglist .item-preview { font-size: 1.8em; }
94
  .file-grid.list-view .item-name-info { flex-grow: 1; }
95
  .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; }
96
  .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; }
 
127
  .shared-link-info strong { word-break: break-all; }
128
  .shared-link-info small { color: var(--text-muted); display: block; }
129
  .shared-link-actions button { background: none; border: none; color: var(--text-muted); font-size: 1.1em; cursor: pointer; padding: 5px; }
130
+ .list-editor-item { display: flex; align-items: center; gap: 10px; padding: 8px; border-radius: 8px; }
131
+ .list-editor-item:hover { background-color: #2a2a2a; }
132
+ .list-editor-item input[type=checkbox] { width: 20px; height: 20px; flex-shrink: 0; }
133
+ .list-editor-item input[type=text] { margin: 0; flex-grow: 1; }
134
+ .list-editor-item .quantity-controls { display: flex; align-items: center; gap: 5px; }
135
+ .list-editor-item .quantity-controls input { width: 50px; text-align: center; padding: 8px; margin: 0; }
136
+ .list-editor-item .quantity-controls button { background: #333; border: none; color: white; border-radius: 50%; width: 28px; height: 28px; font-weight: bold; cursor: pointer; }
137
+ .list-editor-item .delete-item-btn { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.2em; }
138
+ .list-editor-item.completed span { text-decoration: line-through; color: var(--text-muted); }
139
+ .public-list-item { display: flex; align-items: center; gap: 15px; padding: 12px; border-bottom: 1px solid #333; }
140
+ .public-list-item input[type=checkbox] { width: 22px; height: 22px; cursor: pointer; }
141
+ .public-list-item label { flex-grow: 1; cursor: pointer; }
142
+ .public-list-item.purchased label { text-decoration: line-through; color: var(--text-muted); }
143
+ .public-list-item .quantity { font-weight: bold; color: var(--secondary); background: #2a2a2a; padding: 2px 8px; border-radius: 6px; }
144
  '''
145
 
146
+ PUBLIC_SHARE_PAGE_HTML = '''
147
  <!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
148
  <title>Общая папка: {{ folder.name }}</title>
149
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
198
  </div></body></html>
199
  '''
200
 
201
+ PUBLIC_SHOPPING_LIST_HTML = '''
202
  <!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
203
  <title>Список покупок: {{ list_data.title }}</title>
204
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 
207
  <style>''' + BASE_STYLE + '''
208
  body { padding-bottom: 30px; }
209
  .public-header { padding: 15px; text-align: center; border-bottom: 1px solid #333; margin-bottom: 20px; }
210
+ .list-container { max-width: 700px; margin: 0 auto; background: var(--card-bg-dark); border-radius: 16px; padding: 10px; }
 
 
 
 
 
 
 
211
  </style></head><body>
212
  <div class="public-header">
213
  <h1>Список покупок</h1>
214
+ <h2 id="list-title">{{ list_data.title }}</h2>
215
  <p style="color: var(--text-muted);">Автор: {{ user.first_name or user.telegram_username }}</p>
216
  </div>
217
  <div class="container" style="padding-top: 15px;">
218
+ <div class="list-container" id="shopping-list-container">
219
  <div class="loading-spinner"></div>
220
+ </div>
221
  </div>
222
  <script>
223
+ const linkId = '{{ link.id }}';
224
+ const listContainer = document.getElementById('shopping-list-container');
225
+
226
  function renderList(items) {
227
  listContainer.innerHTML = '';
228
  if (!items || items.length === 0) {
229
+ listContainer.innerHTML = '<p style="text-align: center; padding: 20px;">Список пуст.</p>';
230
  return;
231
  }
232
 
233
  items.sort((a, b) => a.purchased - b.purchased);
234
 
235
  items.forEach(item => {
236
+ const itemEl = document.createElement('div');
237
+ itemEl.className = 'public-list-item';
238
  if (item.purchased) {
239
+ itemEl.classList.add('purchased');
240
  }
241
+ itemEl.innerHTML = `
242
+ <input type="checkbox" id="item-${item.id}" ${item.purchased ? 'checked' : ''} onchange="toggleItem('${item.id}')">
243
+ <label for="item-${item.id}">${item.name}</label>
244
+ <span class="quantity">${item.quantity}</span>
 
 
245
  `;
246
+ listContainer.appendChild(itemEl);
247
  });
248
  }
249
 
250
  async function fetchList() {
251
  try {
252
+ const response = await fetch(`{{ url_for('public_list_data', link_id=link.id) }}`);
253
+ if (!response.ok) {
254
+ listContainer.innerHTML = '<p>Ошибка загрузки списка.</p>';
255
+ return;
256
+ }
257
  const data = await response.json();
258
  if (data.status === 'success') {
259
+ document.getElementById('list-title').textContent = data.list.title;
260
+ renderList(data.list.items);
261
  } else {
262
+ listContainer.innerHTML = `<p>${data.message || 'Ошибка.'}</p>`;
263
  }
264
+ } catch (e) {
265
+ listContainer.innerHTML = '<p>Сетевая ошибка.</p>';
 
266
  }
267
  }
268
+
269
+ async function toggleItem(itemId) {
270
  try {
271
+ const checkbox = document.getElementById(`item-${itemId}`);
272
+ if(checkbox) checkbox.disabled = true;
273
+
274
+ await fetch(`{{ url_for('public_toggle_item', link_id=link.id, item_id='ITEM_ID') }}`.replace('ITEM_ID', itemId), {
275
+ method: 'POST'
276
  });
277
+ await fetchList();
278
+ } catch (e) {
279
+ alert('Не удалось обновить элемент. Пожалуйста, обновите страницу.');
280
+ } finally {
281
+ const checkbox = document.getElementById(`item-${itemId}`);
282
+ if(checkbox) checkbox.disabled = false;
283
  }
284
  }
285
 
 
 
 
 
 
 
 
 
286
  document.addEventListener('DOMContentLoaded', () => {
287
  fetchList();
288
+ setInterval(fetchList, 5000);
289
  });
290
  </script>
291
  </body></html>
 
298
  queue = [(filesystem, None)]
299
  while queue:
300
  current_node, parent = queue.pop(0)
301
+ if 'children' in current_node and isinstance(current_node['children'], list):
302
  for i, child in enumerate(current_node['children']):
303
+ if isinstance(child, dict) and child.get('id') == node_id:
304
  return child, current_node
305
+ if isinstance(child, dict) and child.get('type') == 'folder':
306
  queue.append((child, current_node))
307
  return None, None
308
 
 
318
  def remove_node(filesystem, node_id):
319
  node_to_remove, parent_node = find_node_by_id(filesystem, node_id)
320
  if node_to_remove and parent_node and 'children' in parent_node:
321
+ parent_node['children'] = [child for child in parent_node['children'] if not (isinstance(child, dict) and child.get('id') == node_id)]
322
  return True, node_to_remove
323
  return False, None
324
 
 
349
  traverse(filesystem, "")
350
  return sorted(folders, key=lambda x: x['name'].lower())
351
 
352
+ def get_all_archived_items(filesystem):
353
+ archived_items = []
354
+ def traverse(node):
355
+ if isinstance(node, dict):
356
+ if node.get('is_archived'):
357
+ archived_items.append(node)
358
+ if 'children' in node:
359
+ for child in node.get('children', []):
360
+ traverse(child)
361
+ traverse(filesystem)
362
+ return archived_items
363
+
364
  def count_items_recursive(node):
365
  if not node or not isinstance(node, dict):
366
  return 0
 
373
  return count
374
 
375
  def initialize_user_filesystem_tma(user_data, tma_user_id_str):
376
+ if 'filesystem' not in user_data or not isinstance(user_data['filesystem'], dict):
377
  user_data['filesystem'] = {"type": "folder", "id": "root", "name": "root", "children": []}
378
  if 'files' in user_data and isinstance(user_data['files'], list):
379
  for old_file in user_data['files']:
 
409
  return {'users': {}, 'shared_links': {}}
410
 
411
  def save_data(data):
412
+ with save_data_lock:
413
+ try:
414
+ with open(DATA_FILE, 'w', encoding='utf-8') as file:
415
+ json.dump(data, file, ensure_ascii=False, indent=4)
416
+ upload_db_to_hf()
417
+ cache.clear()
418
+ except Exception as e:
419
+ logging.error(f"Error saving data: {e}")
420
+ raise
421
 
422
  def upload_db_to_hf():
423
  if not HF_TOKEN_WRITE: return
 
586
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
587
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
588
  <script src="https://telegram.org/js/telegram-web-app.js"></script>
589
+ <style>''' + BASE_STYLE + '''</style></head><body>
 
 
 
 
 
 
 
 
 
590
  <div class="app-header">
591
  <div class="user-info">{{ display_name }}</div>
592
  <div class="view-toggle">
593
+ <button id="archive-btn" title="Архив"><i class="fa-solid fa-box-archive"></i></button>
594
  <button id="reminders-btn" title="Напоминания"><i class="fa-solid fa-bell"></i></button>
595
  <button id="grid-view-btn" title="Сетка"><i class="fa fa-th-large"></i></button>
596
  <button id="list-view-btn" title="Список"><i class="fa fa-bars"></i></button>
 
616
  onclick="window.Telegram.WebApp.HapticFeedback.impactOccurred('light'); window.location.href='{{ url_for('tma_dashboard', folder_id=item.id) }}'"
617
  {% elif item.type == 'note' %}
618
  onclick="openNoteModal('{{ item.id }}')"
619
+ {% elif item.type in ['todolist', 'shoppinglist'] %}
620
+ onclick="openListEditorModal('{{ item.id }}', '{{ item.type }}')"
621
+ {% elif item.type == 'file' %}
 
 
622
  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 }}')"
623
  {% endif %}>
624
  <div class="item-preview-wrapper">
 
659
 
660
  <div id="selection-bar">
661
  <span id="selection-count"></span>
 
662
  <button id="selection-share-btn" class="btn share-btn" onclick="openShareModal()" style="display:none;"><i class="fa-solid fa-share-alt"></i></button>
663
+ <button id="selection-archive-btn" class="btn archive-btn" onclick="archiveSelected()" style="display:none;"><i class="fa-solid fa-box-archive"></i></button>
664
  <button id="selection-download-btn" class="btn download-btn" onclick="downloadSingleSelected()" style="display:none;"><i class="fa-solid fa-download"></i></button>
665
  <button class="btn" style="background: var(--accent);" onclick="showMoveModal()"><i class="fa-solid fa-arrow-right-to-bracket"></i></button>
666
  <button class="btn delete-btn" onclick="deleteSelected()"><i class="fa-solid fa-trash-can"></i></button>
 
679
  <div class="modal" id="fab-modal"><div class="modal-content">
680
  <h4>Добавить в "{{ current_folder.name if current_folder_id != 'root' else 'Главная' }}"</h4>
681
  <div class="fab-options">
682
+ <label for="file-input" class="fab-option" id="fab-option-upload"><i class="fa-solid fa-upload"></i><span>Файлы</span></label>
683
+ <div class="fab-option" id="fab-option-note" onclick="openNoteModal()"><i class="fa-solid fa-note-sticky"></i><span>Заметку</span></div>
684
+ <div class="fab-option" id="fab-option-folder"><i class="fa-solid fa-folder-plus"></i><span>Папку</span></div>
685
+ <div class="fab-option" id="fab-option-todolist" onclick="openListEditorModal(null, 'todolist')"><i class="fa-solid fa-list-check"></i><span>Список дел</span></div>
686
+ <div class="fab-option" id="fab-option-shoppinglist" onclick="openListEditorModal(null, 'shoppinglist')"><i class="fa-solid fa-cart-shopping"></i><span>Покупки</span></div>
 
 
 
 
 
 
 
 
 
 
687
  </div>
688
  <form id="upload-form" method="POST" enctype="multipart/form-data" action="{{ url_for('tma_dashboard') }}" style="display:none;">
689
  <input type="hidden" name="current_folder_id" value="{{ current_folder_id }}">
 
709
  </div>
710
  </div></div>
711
 
712
+ <div class="modal" id="list-editor-modal"><div class="modal-content" style="padding: 20px; max-width: 500px; width: 90%;">
713
+ <h4 id="list-editor-title">Новый список</h4>
714
+ <input type="hidden" id="list-editor-id">
715
+ <input type="hidden" id="list-editor-type">
716
+ <input type="text" id="list-editor-title-input" placeholder="Название списка" style="font-size: 1.1em; margin-bottom: 10px;">
717
+ <div id="list-editor-items-container" style="max-height: 45vh; overflow-y: auto; margin-bottom: 10px;"></div>
718
+ <div style="display: flex; gap: 10px; margin-bottom: 15px;">
719
+ <input type="text" id="list-editor-new-item-text" placeholder="Новый элемент..." style="margin: 0;">
720
+ <button class="btn" onclick="addListItemToEditor()" style="padding: 14px 20px;">+</button>
721
+ </div>
722
+ <div style="display: flex; gap: 10px;">
723
  <button class="btn" style="background: var(--accent); flex-grow: 1;" onclick="saveList()">Сохранить</button>
724
+ <button class="btn" style="background: #555; flex-grow: 1;" onclick="closeListEditorModal()">Отмена</button>
725
  </div>
726
  </div></div>
727
 
 
840
  }
841
  function updateSelectionUI() {
842
  selectionCount.textContent = `Выбрано: ${selectedItems.size}`;
 
 
 
 
 
 
843
  const firstSelectedId = selectedItems.values().next().value;
844
  const itemElement = document.querySelector(`.item[data-id='${firstSelectedId}']`);
845
+ const itemType = itemElement?.dataset.type;
846
+ const isSingleSelection = selectedItems.size === 1;
847
+
848
+ selectionDownloadBtn.style.display = (isSingleSelection && itemType === 'file') ? 'inline-block' : 'none';
849
+ selectionShareBtn.style.display = (isSingleSelection && (itemType === 'folder' || itemType === 'shoppinglist')) ? 'inline-block' : 'none';
850
+ selectionArchiveBtn.style.display = (selectedItems.size > 0 && Array.from(selectedItems).every(id => {
851
+ const el = document.querySelector(`.item[data-id='${id}']`);
852
+ return el && (el.dataset.type === 'todolist' || el.dataset.type === 'shoppinglist');
853
+ })) ? 'inline-block' : 'none';
854
  }
855
  allItems.forEach(item => {
856
  item.addEventListener('pointerdown', e => {
 
903
  if (ok) { haptic.impactOccurred('heavy'); performBatchAction('{{ url_for("batch_delete_tma") }}', { item_ids: Array.from(selectedItems) }); }
904
  });
905
  }
906
+ function archiveSelected() {
907
+ if (selectedItems.size === 0) return;
 
908
  haptic.impactOccurred('medium');
909
+ Telegram.WebApp.showConfirm(`Архивировать ${selectedItems.size} списк(ов)?`, ok => {
910
+ if(ok) {
911
+ haptic.impactOccurred('heavy');
912
+ performBatchAction('{{ url_for("batch_archive_tma") }}', { item_ids: Array.from(selectedItems) });
913
+ }
914
+ });
915
  }
916
  async function openNoteModal(noteId = null) {
917
  haptic.impactOccurred('light');
 
1016
  const itemId = selectedItems.values().next().value;
1017
  const itemElement = document.querySelector(`.item[data-id='${itemId}']`);
1018
  const itemType = itemElement.dataset.type;
1019
+ const title = itemType === 'folder' ? 'Поделиться папкой' : 'Поделиться списком покупок';
1020
+ document.getElementById('share-modal-title').textContent = title;
1021
  const listEl = document.getElementById('existing-links-list');
 
1022
  listEl.innerHTML = '<div class="loading-spinner"></div>';
1023
  document.getElementById('share-modal').style.display = 'flex';
1024
  const response = await fetch(`{{ url_for('get_public_links', item_id='ITEM_ID') }}`.replace('ITEM_ID', itemId));
 
1046
  }
1047
  async function createShareLink() {
1048
  const itemId = selectedItems.values().next().value;
1049
+ const itemElement = document.querySelector(`.item[data-id='${itemId}']`);
1050
+ const itemType = itemElement.dataset.type;
1051
  const name = document.getElementById('share-link-name').value;
1052
  const duration_hours = document.getElementById('share-link-duration').value;
1053
  const response = await fetch('{{ url_for("create_public_link") }}', {
 
1087
  haptic.notificationOccurred('error');
1088
  });
1089
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1090
  document.getElementById('reminders-btn').addEventListener('click', openRemindersModal);
1091
+ document.getElementById('archive-btn').addEventListener('click', () => { window.location.href = '{{ url_for("tma_archive_view") }}'; });
1092
  const gridViewBtn = document.getElementById('grid-view-btn');
1093
  const listViewBtn = document.getElementById('list-view-btn');
1094
  const fileContainer = document.getElementById('file-container');
 
1122
  backButton.onClick(() => { haptic.impactOccurred('light'); window.location.href = `{{ url_for('tma_dashboard') }}?folder_id=${parentFolderId}`; });
1123
  } else { window.Telegram.WebApp.BackButton.hide(); }
1124
  });
1125
+ function closeListEditorModal() { document.getElementById('list-editor-modal').style.display = 'none'; }
1126
+ async function openListEditorModal(listId = null, listType) {
1127
+ haptic.impactOccurred('light');
1128
+ closeFabModal();
1129
+ const modal = document.getElementById('list-editor-modal');
1130
+ const titleEl = document.getElementById('list-editor-title');
1131
+ const idInput = document.getElementById('list-editor-id');
1132
+ const typeInput = document.getElementById('list-editor-type');
1133
+ const titleInput = document.getElementById('list-editor-title-input');
1134
+ const itemsContainer = document.getElementById('list-editor-items-container');
1135
+
1136
+ typeInput.value = listType;
1137
+ titleEl.textContent = listType === 'todolist' ? 'Список дел' : 'Список покупок';
1138
+ titleInput.value = '';
1139
+ itemsContainer.innerHTML = '';
1140
+ idInput.value = '';
1141
+
1142
+ if (listId) {
1143
+ idInput.value = listId;
1144
+ const response = await fetch(`{{ url_for('get_list_tma', list_id='__ID__') }}`.replace('__ID__', listId));
1145
+ const data = await response.json();
1146
+ if (data.status === 'success') {
1147
+ titleInput.value = data.list.title;
1148
+ (data.list.items || []).forEach(item => addListItemToEditor(item));
1149
+ } else {
1150
+ Telegram.WebApp.showAlert('Ошибка загрузки списка');
1151
+ return;
1152
+ }
1153
+ }
1154
+ modal.style.display = 'flex';
1155
+ }
1156
+ function addListItemToEditor(item = null) {
1157
+ const textInput = document.getElementById('list-editor-new-item-text');
1158
+ const listType = document.getElementById('list-editor-type').value;
1159
+ const text = item ? (item.name || item.text) : textInput.value.trim();
1160
+ if (!text) return;
1161
+ const itemId = item ? item.id : 'new_' + new Date().getTime();
1162
+ const checked = item ? (item.completed || item.purchased) : false;
1163
+ const quantity = item ? item.quantity : 1;
1164
+ const itemsContainer = document.getElementById('list-editor-items-container');
1165
+ const itemEl = document.createElement('div');
1166
+ itemEl.className = 'list-editor-item';
1167
+ itemEl.dataset.id = itemId;
1168
+ let itemHTML = `
1169
+ <input type="checkbox" ${checked ? 'checked' : ''}>
1170
+ <input type="text" value="${text.replace(/"/g, '&quot;')}">
1171
+ `;
1172
+ if (listType === 'shoppinglist') {
1173
+ itemHTML += `
1174
+ <div class="quantity-controls">
1175
+ <button type="button" onclick="changeQuantity(this, -1)">-</button>
1176
+ <input type="number" value="${quantity || 1}" min="1" onchange="this.value = Math.max(1, parseInt(this.value) || 1)">
1177
+ <button type="button" onclick="changeQuantity(this, 1)">+</button>
1178
+ </div>
1179
+ `;
1180
+ }
1181
+ itemHTML += `<button type="button" class="delete-item-btn" onclick="this.parentElement.remove()"><i class="fa-solid fa-times"></i></button>`;
1182
+ itemEl.innerHTML = itemHTML;
1183
+ itemsContainer.appendChild(itemEl);
1184
+ if (!item) textInput.value = '';
1185
+ }
1186
+ function changeQuantity(btn, delta) {
1187
+ const input = btn.parentElement.querySelector('input[type=number]');
1188
+ let value = parseInt(input.value) || 1;
1189
+ value += delta;
1190
+ if (value < 1) value = 1;
1191
+ input.value = value;
1192
+ }
1193
+ async function saveList() {
1194
+ const id = document.getElementById('list-editor-id').value;
1195
+ const type = document.getElementById('list-editor-type').value;
1196
+ const title = document.getElementById('list-editor-title-input').value.trim();
1197
+ if (!title) { Telegram.WebApp.showAlert('Название не может быть пустым.'); return; }
1198
+ const items = [];
1199
+ document.querySelectorAll('#list-editor-items-container .list-editor-item').forEach(el => {
1200
+ const itemText = el.querySelector('input[type=text]').value.trim();
1201
+ if (!itemText) return;
1202
+ const item = {
1203
+ id: el.dataset.id.startsWith('new_') ? null : el.dataset.id,
1204
+ text: itemText,
1205
+ name: itemText,
1206
+ completed: el.querySelector('input[type=checkbox]').checked,
1207
+ purchased: el.querySelector('input[type=checkbox]').checked
1208
+ };
1209
+ if (type === 'shoppinglist') {
1210
+ item.quantity = parseInt(el.querySelector('input[type=number]').value) || 1;
1211
+ }
1212
+ items.push(item);
1213
+ });
1214
+ const payload = {
1215
+ list_id: id, type: type, title: title, items: items, parent_folder_id: '{{ current_folder_id }}'
1216
+ };
1217
+ const response = await fetch('{{ url_for("create_or_update_list_tma") }}', {
1218
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
1219
+ });
1220
+ const result = await response.json();
1221
+ if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
1222
+ else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Ошибка сохранения.'); }
1223
+ }
1224
  </script></body></html>
1225
  '''
1226
 
1227
+ ARCHIVED_LISTS_HTML = '''
1228
  <!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
1229
+ <title>Архив</title>
1230
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1231
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&display=swap" rel="stylesheet">
1232
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
 
1235
  <div class="app-header">
1236
  <div class="user-info">{{ display_name }}</div>
1237
  <div class="view-toggle">
1238
+ <a href="{{ url_for('tma_dashboard') }}" style="color: var(--text-muted); font-size: 1.2em; padding: 5px;"><i class="fa-solid fa-arrow-left"></i></a>
1239
  </div>
1240
  </div>
1241
+ <div class="container">
1242
  <h2>Архив</h2>
1243
+ <div class="file-grid list-view">
1244
  {% for item in items %}
1245
  <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
1246
  <div class="item-preview-wrapper">
 
1248
  {% elif item.type == 'shoppinglist' %}<div class="item-preview"><i class="fa-solid fa-cart-shopping"></i></div>
1249
  {% endif %}
1250
  </div>
1251
+ <div class="item-name-info">
1252
  <p class="item-name">{{ item.title }}</p>
1253
+ <p class="item-info">Заархивировано</p>
 
1254
  </div>
1255
+ <button class="btn archive-btn" onclick="unarchiveItem('{{ item.id }}')" style="margin-left:auto; padding: 8px 12px; font-size: 0.8em;">Восстановить</button>
1256
+ <button class="btn delete-btn" onclick="deleteItem('{{ item.id }}')" style="padding: 8px 12px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i></button>
1257
  </div>
1258
  {% endfor %}
1259
  {% if not items %}<p>Архив пуст.</p>{% endif %}
 
1261
  </div>
1262
  <script>
1263
  window.Telegram.WebApp.ready();
 
1264
  const haptic = window.Telegram.WebApp.HapticFeedback;
1265
 
1266
+ async function performAction(url, body) {
1267
+ try {
1268
+ const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
1269
+ const result = await response.json();
1270
+ if (result.status === 'success') { haptic.notificationOccurred('success'); window.location.reload(); }
1271
+ else { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert(result.message || 'Произошла ошибка.'); }
1272
+ } catch (error) { haptic.notificationOccurred('error'); Telegram.WebApp.showAlert('Сетевая ошибка.'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
1273
  }
1274
+
1275
  function unarchiveItem(itemId) {
1276
+ haptic.impactOccurred('medium');
1277
+ performAction('{{ url_for("batch_unarchive_tma") }}', { item_ids: [itemId] });
1278
  }
1279
 
1280
  function deleteItem(itemId) {
1281
+ haptic.impactOccurred('medium');
1282
+ Telegram.WebApp.showConfirm('Удалить этот список навсегда?', ok => {
1283
  if (ok) {
1284
  haptic.impactOccurred('heavy');
1285
+ performAction('{{ url_for("batch_delete_tma") }}', { item_ids: [itemId] });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1286
  }
1287
  });
1288
  }
 
 
 
 
 
 
 
 
 
1289
  </script>
1290
  </body></html>
1291
  '''
 
1312
  current_folder, parent_folder = find_node_by_id(user_data['filesystem'], 'root')
1313
 
1314
  parent_folder_id = parent_folder.get('id', 'root') if parent_folder else 'root'
 
 
 
1315
 
1316
+ items_in_folder = [item for item in current_folder.get('children', []) if not item.get('is_archived')]
1317
+ items_in_folder = sorted(items_in_folder, key=lambda x: (x['type'] not in ['folder', 'note', 'todolist', 'shoppinglist'], x.get('name', x.get('original_filename', x.get('title', ''))).lower()))
1318
+
1319
  if request.method == 'POST':
1320
  if not HF_TOKEN_WRITE:
1321
  flash('Загрузка невозможна: токен для записи не настроен.', 'error')
 
1370
  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)
1371
 
1372
  @app.route('/tma_archive')
1373
+ def tma_archive_view():
1374
  if 'telegram_user_id' not in session: return redirect(url_for('tma_entry_page'))
1375
  tma_user_id = session['telegram_user_id']
1376
  display_name = session.get('telegram_display_name', 'Пользователь')
1377
  data = load_data()
1378
  user_data = data['users'].get(tma_user_id)
1379
  if not user_data: return redirect(url_for('tma_entry_page'))
 
 
 
 
 
 
 
 
 
 
 
1380
 
1381
+ archived_items = get_all_archived_items(user_data.get('filesystem'))
1382
  sorted_items = sorted(archived_items, key=lambda x: x.get('modified_date', ''), reverse=True)
1383
+
1384
+ return render_template_string(ARCHIVED_LISTS_HTML, display_name=display_name, items=sorted_items)
1385
 
1386
  @app.route('/create_folder_tma', methods=['POST'])
1387
  def create_folder_tma():
 
1530
  if not node:
1531
  errors.append(f"Элемент {item_id} не найден.")
1532
  continue
1533
+ node_type = node.get('type')
1534
+ node_name = node.get('name') or node.get('title') or node.get('original_filename', 'элемент')
1535
+
1536
+ if node_type == 'folder':
1537
+ if node.get('children'): errors.append(f'Папка "{node_name}" не пуста.'); continue
1538
 
1539
+ # For all types that can be deleted
1540
+ if node_type in ['folder', 'note', 'todolist', 'shoppinglist', 'file']:
1541
+ if node_type == 'file':
1542
+ try:
1543
+ 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)
1544
+ except hf_utils.EntryNotFoundError: pass
1545
+ except Exception as e: errors.append(f'Ошибка удаления "{node_name}" с сервера: {e}'); continue
1546
+
 
 
 
 
1547
  if remove_node(user_data['filesystem'], item_id)[0]: success_count += 1
1548
+ else: errors.append(f'Ошибка удаления "{node_name}" из базы.')
1549
 
1550
  if success_count > 0:
1551
  try: save_data(data)
 
1582
  if item_id == destination_id: continue
1583
  removed, node_to_move = remove_node(user_data['filesystem'], item_id)
1584
  if removed and node_to_move:
1585
+ node_to_move['is_archived'] = False
1586
  if add_node(user_data['filesystem'], destination_id, node_to_move): moved_count += 1
1587
  else: errors.append(f'Ошибка добавления {item_id} в новую папку.')
1588
  else: errors.append(f'Не удалось извлечь {item_id}.')
 
1593
  if errors: return jsonify({'status': 'error', 'message': f'Перемещено {moved_count}. Ошибки: ' + "; ".join(errors)})
1594
  return jsonify({'status': 'success', 'message': f'Перемещено {moved_count} элемент(ов).'})
1595
 
1596
+ @app.route('/batch_archive_tma', methods=['POST'])
1597
+ def batch_archive_tma():
1598
+ if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
1599
+ tma_user_id = session['telegram_user_id']
1600
+ data = load_data()
1601
+ user_data = data['users'].get(tma_user_id)
1602
+ if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404
1603
+ item_ids = request.json.get('item_ids', [])
1604
+ if not item_ids: return jsonify({'status': 'error', 'message': 'Не выбраны элементы.'}), 400
1605
+
1606
+ archived_count = 0
1607
+ for item_id in item_ids:
1608
+ node, _ = find_node_by_id(user_data['filesystem'], item_id)
1609
+ if node and node.get('type') in ['todolist', 'shoppinglist']:
1610
+ node['is_archived'] = True
1611
+ archived_count += 1
1612
+
1613
+ if archived_count > 0:
1614
+ try:
1615
+ save_data(data)
1616
+ return jsonify({'status': 'success', 'message': f'Архивировано {archived_count} списк(ов).'})
1617
+ except Exception as e:
1618
+ return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
1619
+ return jsonify({'status': 'error', 'message': 'Не найдено списков для архивации.'})
1620
+
1621
+ @app.route('/batch_unarchive_tma', methods=['POST'])
1622
+ def batch_unarchive_tma():
1623
+ if 'telegram_user_id' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован.'}), 401
1624
+ tma_user_id = session['telegram_user_id']
1625
+ data = load_data()
1626
+ user_data = data['users'].get(tma_user_id)
1627
+ if not user_data: return jsonify({'status': 'error', 'message': 'Пользователь не найден.'}), 404
1628
+ item_ids = request.json.get('item_ids', [])
1629
+ if not item_ids: return jsonify({'status': 'error', 'message': 'Не выбраны элементы.'}), 400
1630
+
1631
+ unarchived_count = 0
1632
+ for item_id in item_ids:
1633
+ node, _ = find_node_by_id(user_data['filesystem'], item_id)
1634
+ if node and node.get('is_archived'):
1635
+ node['is_archived'] = False
1636
+ unarchived_count += 1
1637
+
1638
+ if unarchived_count > 0:
1639
+ try:
1640
+ save_data(data)
1641
+ return jsonify({'status': 'success', 'message': f'Восстановлено {unarchived_count} списк(ов).'})
1642
+ except Exception as e:
1643
+ return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
1644
+ return jsonify({'status': 'error', 'message': 'Не найдено списков для восстановления.'})
1645
+
1646
  @app.route('/get_text_content_tma/<file_id>')
1647
  def get_text_content_tma(file_id):
1648
  file_node = get_item_node_for_user(file_id)
 
1723
  data = load_data()
1724
  user_data = data['users'].get(tma_user_id)
1725
  if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
1726
+
1727
  payload = request.json
1728
  list_id = payload.get('list_id')
1729
  list_type = payload.get('type')
1730
  title = payload.get('title', '').strip()
1731
+ items = payload.get('items', [])
1732
  parent_folder_id = payload.get('parent_folder_id', 'root')
1733
  now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
1734
 
1735
+ if not title: return jsonify({'status': 'error', 'message': 'Title cannot be empty.'}), 400
1736
+ if list_type not in ['todolist', 'shoppinglist']: return jsonify({'status': 'error', 'message': 'Invalid list type.'}), 400
1737
+
1738
+ for item in items:
1739
+ if not item.get('id'): item['id'] = uuid.uuid4().hex
1740
 
1741
  if list_id:
1742
  node, _ = find_node_by_id(user_data['filesystem'], list_id)
1743
+ if not node or node.get('type') != list_type: return jsonify({'status': 'error', 'message': 'List not found'}), 404
 
1744
  node['title'] = title
1745
+ node['items'] = items
1746
  node['modified_date'] = now_str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1747
  else:
1748
  new_list_id = uuid.uuid4().hex
1749
  list_data = {
1750
+ 'type': list_type, 'id': new_list_id, 'title': title, 'items': items,
1751
+ 'created_date': now_str, 'modified_date': now_str, 'is_archived': False
1752
  }
 
 
 
 
 
 
 
 
 
1753
  if not add_node(user_data['filesystem'], parent_folder_id, list_data):
1754
  return jsonify({'status': 'error', 'message': 'Parent folder not found'}), 404
1755
 
1756
+ try:
1757
+ save_data(data)
1758
+ return jsonify({'status': 'success', 'message': 'List saved.'})
1759
+ except Exception as e:
1760
+ return jsonify({'status': 'error', 'message': f'Failed to save data: {e}'}), 500
 
 
 
 
 
 
 
 
 
 
 
1761
 
1762
  @app.route('/get_reminders_tma')
1763
  def get_reminders_tma():
 
1847
  return jsonify({'status': 'error', 'message': 'Элемент не найден или не может быть опубликован.'}), 404
1848
 
1849
  now = datetime.now(pytz.utc)
1850
+ expires_at = None
1851
  if duration_hours > 0:
1852
  expires_at = now + timedelta(hours=duration_hours)
1853
  expires_at_iso = expires_at.isoformat()
1854
+ else:
1855
+ expires_at_iso = None
1856
 
1857
  link_id = uuid.uuid4().hex
1858
  link_data = {
 
1864
 
1865
  try:
1866
  save_data(data)
1867
+ public_url = url_for('shared_item_view', link_id=link_id, _external=True)
 
 
 
1868
  return jsonify({'status': 'success', 'url': public_url})
1869
  except Exception as e:
1870
  return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
 
1912
  for link_id in link_ids:
1913
  link_data = data['shared_links'].get(link_id)
1914
  if link_data:
1915
+ link_data['url'] = url_for('shared_item_view', link_id=link_id, _external=True)
 
 
 
1916
  links_details.append(link_data)
1917
 
1918
  return jsonify({'status': 'success', 'links': links_details})
1919
 
1920
  @app.route('/shared/<link_id>')
1921
+ def shared_item_view(link_id):
1922
+ data = load_data()
1923
+ link_data = data.get('shared_links', {}).get(link_id)
1924
+ if not link_data: return "Ссылка недействительна.", 404
1925
+
1926
+ if link_data.get('expires_at'):
1927
+ expires_at = datetime.fromisoformat(link_data['expires_at'])
1928
+ if datetime.now(pytz.utc) > expires_at:
1929
+ return "Срок действия ссылки истек.", 410
1930
+
1931
+ user_id = link_data['user_id']
1932
+ user_data = data['users'].get(user_id)
1933
+ if not user_data: return "Владелец не найден.", 404
1934
+
1935
+ item_id = link_data['item_id']
1936
+ item_node, _ = find_node_by_id(user_data['filesystem'], item_id)
1937
+
1938
+ if not item_node: return "Элемент не найден.", 404
1939
+
1940
+ if link_data['item_type'] == 'folder':
1941
+ return redirect(url_for('shared_folder_view', link_id=link_id))
1942
+ elif link_data['item_type'] == 'shoppinglist':
1943
+ return render_template_string(PUBLIC_SHOPPING_LIST_HTML, list_data=item_node, user=user_data, link=link_data)
1944
+ else:
1945
+ return "Неподдерживаемый тип элемента для обмена.", 400
1946
+
1947
+
1948
+ @app.route('/shared/<link_id>/folder')
1949
+ @app.route('/shared/<link_id>/folder/<subfolder_id>')
1950
  def shared_folder_view(link_id, subfolder_id=None):
1951
  data = load_data()
1952
  link_data = data['shared_links'].get(link_id)
1953
 
1954
+ if not link_data or link_data['item_type'] != 'folder': return "Ссылка недействительна.", 404
1955
 
1956
  if link_data.get('expires_at'):
1957
  expires_at = datetime.fromisoformat(link_data['expires_at'])
 
1970
 
1971
  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()))
1972
 
1973
+ return render_template_string(PUBLIC_SHARE_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 ''}")
1974
 
1975
+ @app.route('/public_download/<link_id>/<item_id>')
1976
+ def public_download_via_link(link_id, item_id):
1977
  data = load_data()
1978
  link_data = data['shared_links'].get(link_id)
1979
+ if not link_data: return Response("Ссылка недействительна.", status=404)
1980
+
1981
  if link_data.get('expires_at'):
1982
  expires_at = datetime.fromisoformat(link_data['expires_at'])
1983
+ if datetime.now(pytz.utc) > expires_at:
1984
+ return Response("Срок действия ссылки истек.", status=410)
1985
+
1986
  user_id = link_data['user_id']
1987
  user_data = data['users'].get(user_id)
1988
+ if not user_data: return Response("Владелец не найден.", status=404)
1989
+
1990
+ item_node, _ = find_node_by_id(user_data['filesystem'], item_id)
1991
+ if not item_node: return Response("Элемент не найден.", status=404)
1992
 
1993
+ token = uuid.uuid4().hex
1994
+ cache.set(f"download_token_{token}", item_node, timeout=300)
1995
+ return redirect(url_for('public_download', token=token))
1996
 
1997
+ @app.route('/api/public_list_data/<link_id>')
1998
+ def public_list_data(link_id):
1999
  data = load_data()
2000
+ link_data = data.get('shared_links', {}).get(link_id)
2001
+ if not link_data: return jsonify({'status': 'error', 'message': 'Ссылка не найдена.'}), 404
2002
+ if link_data.get('expires_at') and datetime.now(pytz.utc) > datetime.fromisoformat(link_data['expires_at']):
2003
+ return jsonify({'status': 'error', 'message': 'Срок действия ссылки истек.'}), 410
2004
+
2005
  user_id = link_data['user_id']
2006
  user_data = data['users'].get(user_id)
2007
+ if not user_data: return jsonify({'status': 'error', 'message': 'Владелец не найден.'}), 404
2008
+
2009
  list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
2010
+ if not list_node: return jsonify({'status': 'error', 'message': 'Список не найден.'}), 404
2011
+
2012
+ return jsonify({'status': 'success', 'list': list_node})
2013
 
2014
+ @app.route('/api/public_toggle_item/<link_id>/<item_id>', methods=['POST'])
2015
+ def public_toggle_item(link_id, item_id):
2016
  data = load_data()
2017
+ link_data = data.get('shared_links', {}).get(link_id)
2018
+ if not link_data: return jsonify({'status': 'error', 'message': 'Ссылка не найдена.'}), 404
2019
+ if link_data.get('expires_at') and datetime.now(pytz.utc) > datetime.fromisoformat(link_data['expires_at']):
2020
+ return jsonify({'status': 'error', 'message': 'Срок действия ссылки истек.'}), 410
2021
+
2022
  user_id = link_data['user_id']
2023
  user_data = data['users'].get(user_id)
2024
+ if not user_data: return jsonify({'status': 'error', 'message': 'Владелец не найден.'}), 404
 
 
 
2025
 
2026
+ list_node, _ = find_node_by_id(user_data['filesystem'], link_data['item_id'])
2027
+ if not list_node: return jsonify({'status': 'error', 'message': 'Список не найден.'}), 404
 
2028
 
2029
  item_found = False
2030
  for item in list_node.get('items', []):
2031
+ if item.get('id') == item_id:
2032
+ item['purchased'] = not item.get('purchased', False)
2033
  item_found = True
2034
  break
2035
+
2036
  if item_found:
2037
+ list_node['modified_date'] = datetime.now().strftime('%Y-%m-%d %H:%M')
2038
  try:
2039
  save_data(data)
2040
  return jsonify({'status': 'success'})
2041
  except Exception as e:
2042
  return jsonify({'status': 'error', 'message': f'Ошибка сохранения: {e}'}), 500
2043
  else:
2044
+ return jsonify({'status': 'error', 'message': 'Элемент в списке не найден.'}), 404
2045
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2046
 
2047
  ADMIN_LOGIN_HTML = '''
2048
  <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Admin Login</title>
 
2162
  <div class="item {{ item.type }}" data-id="{{ item.id }}" data-type="{{ item.type }}">
2163
  <div class="item-preview-wrapper"
2164
  {% if item.type == 'folder' %} onclick="window.location.href='{{ url_for('admin_user_files', tma_user_id_str=user_id, folder_id=item.id) }}'"
2165
+ {% elif item.type in ['note', 'todolist', 'shoppinglist'] %} onclick="openModal(null, '{{ item.type }}', '{{ item.id }}')"
2166
  {% 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 %}>
2167
+
2168
  {% if item.type == 'folder' %}<div class="item-preview"><i class="fa-solid fa-folder"></i></div>
2169
  {% elif item.type == 'note' %}<div class="item-preview"><i class="fa-solid fa-note-sticky"></i></div>
2170
  {% elif item.type == 'todolist' %}<div class="item-preview"><i class="fa-solid fa-list-check"></i></div>
 
2232
  const response = await fetch(`{{ url_for('admin_get_item', tma_user_id_str=user_id, item_id='__ID__') }}`.replace('__ID__', itemId));
2233
  const data = await response.json();
2234
  if(data.status === 'success') {
2235
+ let contentHTML = `<h3>${data.item.title.replace(/</g,"&lt;")}</h3><hr style="border-color:#333; margin:10px 0;">`;
2236
+ if (type === 'note') {
2237
+ contentHTML += `<pre>${data.item.content.replace(/</g,"&lt;")}</pre>`;
2238
+ } else {
2239
+ contentHTML += '<ul>';
2240
+ data.item.items.forEach(subItem => {
2241
+ let text = subItem.text || subItem.name;
2242
+ let checked = subItem.completed || subItem.purchased;
2243
+ contentHTML += `<li style="${checked ? 'text-decoration:line-through; color:var(--text-muted);' : ''}">${text} ${type === 'shoppinglist' ? `(${subItem.quantity})` : ''}</li>`;
2244
+ });
2245
+ contentHTML += '</ul>';
2246
+ }
2247
+ modalContent.innerHTML = `<div style="padding:15px; text-align:left;">${contentHTML}</div>`;
2248
  } else { throw new Error(data.message); }
2249
  } else if (type === 'image') {
2250
  modalContent.innerHTML = `<img src="${srcOrUrl}">`;
 
2400
  flash('Folder not found!', 'error')
2401
  current_folder_id = 'root'
2402
  current_folder, _ = find_node_by_id(user_data['filesystem'], 'root')
2403
+
2404
+ items_in_folder = [item for item in current_folder.get('children', []) if not item.get('is_archived')]
2405
+ items_in_folder = sorted(items_in_folder, key=lambda x: (x['type'] not in ['folder', 'note', 'todolist', 'shoppinglist'], x.get('name', x.get('original_filename', x.get('title', ''))).lower()))
2406
 
2407
  breadcrumbs = []
2408
  temp_id = current_folder_id
 
2480
  user_data = data['users'].get(tma_user_id_str)
2481
  if not user_data: return jsonify({'status': 'error', 'message': 'User not found'}), 404
2482
  node, _ = find_node_by_id(user_data['filesystem'], item_id)
2483
+ if not node:
2484
+ return jsonify({'status': 'error', 'message': 'Item not found'}), 404
2485
  return jsonify({'status': 'success', 'item': node})
2486
 
2487
  @app.route('/admhosto/delete_item/<tma_user_id_str>/<item_id>', methods=['POST'])
 
2496
  node, _ = find_node_by_id(user_data['filesystem'], item_id)
2497
  if not node:
2498
  flash('Item not found.', 'error')
2499
+ else:
2500
+ node_type = node.get('type')
2501
+ if node_type == 'file':
2502
+ hf_path = node.get('path')
2503
+ if not HF_TOKEN_WRITE: flash('Deletion not possible: write token not configured.', 'error')
2504
+ else:
2505
+ try:
2506
+ api = HfApi()
2507
+ if hf_path: api.delete_file(path_in_repo=hf_path, repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE)
2508
+ except hf_utils.EntryNotFoundError:
2509
+ pass # Already deleted from remote, just remove from DB
2510
+ except Exception as e:
2511
+ flash(f'Deletion error from remote storage: {e}', 'error')
2512
+ return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
2513
+ elif node_type == 'folder' and node.get('children'):
2514
+ flash('Folder is not empty.', 'error')
2515
+ return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
2516
+
2517
+ # If we are here, we can delete the node from filesystem
2518
  if remove_node(user_data['filesystem'], item_id)[0]:
2519
+ try:
2520
+ save_data(data)
2521
+ flash(f'{node_type.capitalize()} deleted.')
2522
+ except Exception as e:
2523
+ flash(f'DB update failed after deletion: {e}', 'error')
2524
  else:
2525
+ flash('Failed to remove item from filesystem.', 'error')
2526
+
 
2527
  return redirect(url_for('admin_user_files', tma_user_id_str=tma_user_id_str, folder_id=current_folder_id))
2528
 
2529
  @app.route('/admhosto/user/<tma_user_id_str>/delete_reminder/<reminder_id>', methods=['POST'])
 
2566
  threading.Thread(target=check_reminders, daemon=True).start()
2567
 
2568
  app.run(debug=False, host='0.0.0.0', port=7860)
2569
+