Экспорт, импорт и копии вне компьютера
Все 40 резервных копий лежали на том же диске, что и оригинал, а фотографии не дублировались нигде: смерть диска уносила и 59 записей, и 69 снимков. Что появилось: - «📦 Скачать архив» — zip с базой и всеми фотографиями (13 МБ на текущих данных). Работает и с телефона, годится для переноса на другой компьютер. - «📥 Загрузить архив» — восстановление. Сначала показывает, что внутри (сколько вещей в архиве против текущих), и только после подтверждения заменяет данные. Прежнее состояние откладывается в data/backups — и база, и папка со старыми фотографиями. - HLAMINGO_BACKUP_DIR — путь, куда сама уходит копия базы и новых фотографий. Не чаще раза в час и только если путь доступен: сетевой диск отваливается регулярно, а терять запись о вещи из-за этого недопустимо. Состояние и кнопка «Скопировать сейчас» — на вкладке настроек. Имена файлов из архива как пути не используются: только базовое имя и расширение из белого списка. Иначе архив со строкой «../../» писал бы куда угодно — тест это проверяет отдельно. Попутно исправлены два дефекта: - предел загрузки был 8 МБ, и архив в 13 МБ не прошёл бы. Разделён на общий предел запроса и отдельный предел для одного снимка; - настройки нельзя было менять по одной: непереданные поля приходили как null и валились на проверке. Теперь null означает «не трогать», а пустая строка — осознанное «очистить». Про защиту копий на сервере. Задумывался .htaccess, но сайты обслуживает nginx, который его игнорирует, — файл создавал бы ложное чувство защиты. Настоящая защита в расположении: папка лежит рядом с корнем сайта, а не внутри, и nginx за пределы своего root не выпускает. Проверено запросами извне: и сама папка, и db.json, и фотографии дают 404, включая попытку выйти через «/../». .htaccess положен рядом как подстраховка на случай переезда на Apache, о чём честно написано внутри самого файла. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,5 +19,10 @@ OLLAMA_HOST=http://localhost:11434
|
||||
# ollama pull gemma3:4b
|
||||
# HLAMINGO_VISION_MODEL=gemma3:4b
|
||||
|
||||
# Куда складывать копию базы и фотографий ВНЕ этого компьютера.
|
||||
# Сетевой диск или папка; пусто — внешние копии выключены.
|
||||
# Локальные копии в data/backups лежат на том же диске и от его смерти не спасают.
|
||||
# HLAMINGO_BACKUP_DIR=T:\hlamingo-backup
|
||||
|
||||
# Порт веб-интерфейса.
|
||||
# HLAMINGO_PORT=5000
|
||||
|
||||
@@ -119,10 +119,12 @@ start.cmd запускает сервер и печатает адреса
|
||||
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
|
||||
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
|
||||
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
|
||||
| `HLAMINGO_BACKUP_DIR` | — | папка для копий вне компьютера; пусто — выключено |
|
||||
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
|
||||
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
|
||||
|
||||
Из браузера правятся только `OLLAMA_HOST` и две модели — по белому списку.
|
||||
Из браузера правятся `OLLAMA_HOST`, две модели и путь для копий — по белому
|
||||
списку.
|
||||
Произвольные ключи в `.env` через веб записать нельзя: этот файл читает
|
||||
приложение при запуске. Адрес проверяется на формат, имена моделей — на
|
||||
недопустимые символы, и если хоть одно значение неверно, не применяется
|
||||
@@ -329,9 +331,10 @@ static\photos\ фотографии (в .gitignore)
|
||||
и та же коробка.
|
||||
- **Резервные копии.** Перед каждой перезаписью прошлое состояние базы уходит
|
||||
в `data\backups\db-ГГГГММДД-ЧЧММ.json`, хранятся последние 40. Откат — просто
|
||||
скопировать нужный файл обратно в `data\db.json`. Полноценный бэкап папки они
|
||||
не заменяют, но спасают от затёртой или испорченной базы: однажды она уже
|
||||
потерялась целиком, и откатываться было некуда.
|
||||
скопировать нужный файл обратно в `data\db.json`. Спасают от затёртой или
|
||||
испорченной базы: однажды она уже потерялась целиком, и откатываться было
|
||||
некуда. **Но лежат они на том же диске**, поэтому есть ещё два способа
|
||||
ниже — архив и внешняя папка.
|
||||
- **Запись базы атомарная** (временный файл + подмена): обрыв на середине
|
||||
не уничтожит всё, что записано про вещи.
|
||||
- **Битая база не роняет приложение** — запускается с пустой, файл не трогает.
|
||||
@@ -375,6 +378,45 @@ venv\Scripts\python.exe tools\make_icons.py
|
||||
из `static/` он ушёл бы как обычный файл, и браузер мог не признать его
|
||||
манифестом.
|
||||
|
||||
## Резервные копии и перенос
|
||||
|
||||
Копии внутри программы лежат на том же диске, что и оригинал. Диск умрёт —
|
||||
пропадут и записи, и фотографии. Поэтому есть два способа держать данные ещё
|
||||
где-то, оба на вкладке **⚙**.
|
||||
|
||||
### Архив
|
||||
|
||||
**«📦 Скачать архив»** — zip с базой и всеми фотографиями (у 59 вещей это
|
||||
около 13 МБ). Работает и с телефона: файл уходит в загрузки. Годится и для
|
||||
переноса на другой компьютер.
|
||||
|
||||
**«📥 Загрузить архив»** — восстановление. Сначала показывает, что внутри
|
||||
(сколько вещей в архиве против текущих), и только после подтверждения заменяет
|
||||
данные. Прежнее состояние при этом откладывается в `data\backups`:
|
||||
`db-before-import-*.json` и папка с прежними фотографиями — вернуться можно
|
||||
всегда.
|
||||
|
||||
Имена файлов из архива как пути не используются: берётся только базовое имя
|
||||
и расширение из белого списка. Иначе архив со строкой `../../` внутри писал бы
|
||||
куда угодно на диске.
|
||||
|
||||
### Внешняя папка
|
||||
|
||||
`HLAMINGO_BACKUP_DIR` — путь, куда само уходит копия базы и новых фотографий:
|
||||
сетевой диск, шара, что угодно. Пусто — выключено.
|
||||
|
||||
Копирование идёт не чаще раза в час и **не мешает работе, если путь
|
||||
недоступен**: сетевой диск отваливается регулярно, а терять запись о вещи
|
||||
из-за этого недопустимо. Состояние (путь, доступен ли, когда была последняя
|
||||
копия) видно на вкладке ⚙, там же кнопка «Скопировать сейчас».
|
||||
|
||||
**Если папка лежит на веб-сервере — проверьте, не отдаётся ли она наружу.**
|
||||
В нашем случае копии кладутся в `sambadata\hlamingo-backup`, рядом с корнем
|
||||
сайта, но не внутри него, поэтому nginx до них не дотягивается — проверено
|
||||
запросами извне, все дают 404. Файл `.htaccess` там тоже лежит, но nginx его
|
||||
игнорирует: это подстраховка на случай переезда на Apache, а не текущая защита.
|
||||
Полагаться на `.htaccess` при nginx нельзя.
|
||||
|
||||
## Тесты
|
||||
|
||||
```
|
||||
|
||||
+87
-2
@@ -5,7 +5,9 @@ import logging
|
||||
import webbrowser
|
||||
import threading
|
||||
|
||||
from flask import Flask, request, jsonify, send_from_directory
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Flask, request, jsonify, send_from_directory, send_file
|
||||
|
||||
from . import store, search, brain, ollama, config
|
||||
from .config import (PROJECT_DIR, TEMPLATES_DIR, PHOTOS_DIR, HOST, PORT,
|
||||
@@ -68,9 +70,84 @@ def api_config():
|
||||
"available": ollama.check(),
|
||||
"models": ollama.models(),
|
||||
"env_file": str(config.ENV_FILE),
|
||||
"backup": store.external_state(),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/export")
|
||||
def api_export():
|
||||
"""Отдаёт архив со всем: базой и фотографиями."""
|
||||
archive = store.export_archive()
|
||||
name = "hlamingo-%s.zip" % datetime.now().strftime("%Y%m%d-%H%M")
|
||||
return send_file(archive, mimetype="application/zip",
|
||||
as_attachment=True, download_name=name)
|
||||
|
||||
|
||||
@app.route("/api/import/check", methods=["POST"])
|
||||
def api_import_check():
|
||||
"""Что в архиве — до того, как что-либо менять.
|
||||
|
||||
Импорт заменяет данные целиком, поэтому человек должен видеть, что
|
||||
он получит взамен: сколько вещей в архиве против того, что есть сейчас.
|
||||
"""
|
||||
upload = request.files.get("archive")
|
||||
if not upload:
|
||||
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
|
||||
|
||||
parsed, error = store.inspect_archive(upload.read())
|
||||
if error:
|
||||
return jsonify({"ok": False, "reply": error}), 400
|
||||
|
||||
db = store.load()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"archive": {"items": len(parsed["db"].get("items", {})),
|
||||
"places": len(parsed["db"].get("places", {})),
|
||||
"containers": len(parsed["db"].get("containers", {})),
|
||||
"photos": len(parsed["photos"])},
|
||||
"current": {"items": len(db["items"]), "places": len(db["places"]),
|
||||
"containers": len(db["containers"])},
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/import", methods=["POST"])
|
||||
def api_import():
|
||||
"""Восстанавливает базу и фотографии из архива."""
|
||||
upload = request.files.get("archive")
|
||||
if not upload:
|
||||
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
|
||||
|
||||
result, error = store.import_archive(upload.read())
|
||||
if error:
|
||||
return jsonify({"ok": False, "reply": error}), 400
|
||||
|
||||
# Векторы могли не приехать (архив со старой моделью эмбеддера) —
|
||||
# досчитываем, иначе поиск по смыслу молча не работал бы.
|
||||
db = store.load()
|
||||
if search.ensure_embeddings(db):
|
||||
store.save(db)
|
||||
|
||||
return jsonify({"ok": True, "result": result,
|
||||
"reply": "Восстановлено: %d вещей, %d фото. "
|
||||
"Прежнее состояние сохранено как %s."
|
||||
% (result["items"], result["photos"], result["rollback"])})
|
||||
|
||||
|
||||
@app.route("/api/backup/now", methods=["POST"])
|
||||
def api_backup_now():
|
||||
"""Скопировать во внешнюю папку прямо сейчас, не дожидаясь расписания."""
|
||||
if not config.BACKUP_DIR:
|
||||
return jsonify({"ok": False, "reply": "Путь для копий не задан."}), 400
|
||||
ok = store.external_backup(force=True)
|
||||
state = store.external_state()
|
||||
if ok:
|
||||
return jsonify({"ok": True, "state": state,
|
||||
"reply": "Скопировано в %s" % config.BACKUP_DIR})
|
||||
return jsonify({"ok": False, "state": state,
|
||||
"reply": "Не удалось скопировать: %s"
|
||||
% (state.get("error") or "путь недоступен")})
|
||||
|
||||
|
||||
@app.route("/api/config/probe", methods=["POST"])
|
||||
def api_config_probe():
|
||||
"""Проверяет адрес, ничего не сохраняя: что там за Ollama и какие модели."""
|
||||
@@ -103,6 +180,7 @@ def api_config_save():
|
||||
"OLLAMA_HOST": data.get("ollama_host"),
|
||||
"HLAMINGO_CHAT_MODEL": data.get("chat_model"),
|
||||
"HLAMINGO_EMBED_MODEL": data.get("embed_model"),
|
||||
"HLAMINGO_BACKUP_DIR": data.get("backup_dir"),
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({"ok": False, "reply": str(e)}), 400
|
||||
@@ -717,9 +795,16 @@ def api_photo():
|
||||
if ext not in ALLOWED_PHOTO_EXT:
|
||||
return jsonify({"ok": False, "reply": "Такой формат не принимаю: %s" % (ext or "?")}), 400
|
||||
|
||||
# Общий лимит запроса поднят ради архива восстановления, поэтому размер
|
||||
# снимка проверяем здесь отдельно.
|
||||
blob = upload.read()
|
||||
if len(blob) > config.MAX_PHOTO_BYTES:
|
||||
return jsonify({"ok": False, "reply": "Слишком большой снимок (%.1f МБ)."
|
||||
% (len(blob) / 1e6)}), 400
|
||||
|
||||
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
filename = uuid.uuid4().hex[:12] + ext
|
||||
upload.save(str(PHOTOS_DIR / filename))
|
||||
(PHOTOS_DIR / filename).write_bytes(blob)
|
||||
|
||||
db = store.load()
|
||||
target = request.form.get("target")
|
||||
|
||||
+46
-5
@@ -48,7 +48,8 @@ ENV_FILE = (_ROOT_OVERRIDE / ".env") if _ROOT_OVERRIDE else (_PROJECT / ".env")
|
||||
# Что разрешено менять из браузера. Белый список, а не любые ключи: в .env
|
||||
# нельзя позволять писать произвольные строки — этот файл читает приложение
|
||||
# при запуске.
|
||||
EDITABLE = ("OLLAMA_HOST", "HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL")
|
||||
EDITABLE = ("OLLAMA_HOST", "HLAMINGO_CHAT_MODEL", "HLAMINGO_EMBED_MODEL",
|
||||
"HLAMINGO_BACKUP_DIR")
|
||||
|
||||
_HOST_RE = None # ленивая компиляция: re нужен только при сохранении
|
||||
|
||||
@@ -62,6 +63,13 @@ def _validate(key, raw):
|
||||
if not _re.match(r"^https?://[\w.\-]+(:\d{1,5})?/?$", value):
|
||||
raise ValueError("Адрес Ollama должен быть вида http://хост:11434")
|
||||
return value.rstrip("/")
|
||||
if key == "HLAMINGO_BACKUP_DIR":
|
||||
# Пустая строка — выключить внешние копии. Иначе путь: диск (T:\...)
|
||||
# или сетевая шара (\\сервер\шара\...). Переводы строк недопустимы —
|
||||
# значение уходит в .env, где строка есть строка.
|
||||
if value and ("\n" in value or "\r" in value):
|
||||
raise ValueError("Путь не может содержать перевод строки.")
|
||||
return value
|
||||
if value and not _re.match(r"^[\w./:\-]+$", value):
|
||||
raise ValueError("Недопустимое имя модели: %s" % value)
|
||||
return value
|
||||
@@ -79,7 +87,13 @@ def save_settings(values):
|
||||
"""
|
||||
checked = {}
|
||||
for key, raw in (values or {}).items():
|
||||
if key in EDITABLE:
|
||||
if key not in EDITABLE:
|
||||
continue
|
||||
# None — «поле не передали, не трогать». Пустая строка — это уже
|
||||
# осознанное «очистить», и она проходит проверку по правилам ключа.
|
||||
# Без этого различия нельзя поменять одну настройку, не прислав все.
|
||||
if raw is None:
|
||||
continue
|
||||
checked[key] = _validate(key, raw)
|
||||
|
||||
changed = [key for key, value in checked.items() if _apply(key, value)]
|
||||
@@ -92,18 +106,22 @@ def current(key):
|
||||
"""Текущее значение настройки по имени переменной окружения."""
|
||||
return {"OLLAMA_HOST": OLLAMA_HOST,
|
||||
"HLAMINGO_CHAT_MODEL": CHAT_MODEL,
|
||||
"HLAMINGO_EMBED_MODEL": EMBED_MODEL}.get(key, "")
|
||||
"HLAMINGO_EMBED_MODEL": EMBED_MODEL,
|
||||
"HLAMINGO_BACKUP_DIR": BACKUP_DIR}.get(key, "")
|
||||
|
||||
|
||||
def _apply(key, value):
|
||||
"""Обновляет значение в этом модуле. True, если оно действительно менялось."""
|
||||
global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL
|
||||
global OLLAMA_HOST, CHAT_MODEL, EMBED_MODEL, BACKUP_DIR
|
||||
if key == "OLLAMA_HOST" and value != OLLAMA_HOST:
|
||||
OLLAMA_HOST = value
|
||||
elif key == "HLAMINGO_CHAT_MODEL" and value and value != CHAT_MODEL:
|
||||
CHAT_MODEL = value
|
||||
elif key == "HLAMINGO_EMBED_MODEL" and value and value != EMBED_MODEL:
|
||||
EMBED_MODEL = value
|
||||
elif key == "HLAMINGO_BACKUP_DIR" and value != BACKUP_DIR:
|
||||
# Пустое значение допустимо: так внешние копии выключают.
|
||||
BACKUP_DIR = value
|
||||
else:
|
||||
return False
|
||||
os.environ[key] = value
|
||||
@@ -212,6 +230,24 @@ PHOTOS_DIR = (_ROOT / "photos") if _ROOT_OVERRIDE else (PROJECT_DIR / "static" /
|
||||
# и отказываются что-либо удалять, если флаг стоит.
|
||||
IS_LIVE_DATA = _ROOT_OVERRIDE is None
|
||||
|
||||
# ============================================================
|
||||
# Внешние резервные копии
|
||||
# ============================================================
|
||||
# Куда складывать копию базы и фотографий ВНЕ этого компьютера. Пусто —
|
||||
# выключено, работают только локальные копии в data/backups.
|
||||
# HLAMINGO_BACKUP_DIR=T:\hlamingo-backup
|
||||
# HLAMINGO_BACKUP_DIR=\\192.168.0.115\rw\hlamingo-backup
|
||||
#
|
||||
# Смысл именно во «вне»: локальные копии лежат на том же диске, что и оригинал,
|
||||
# и от смерти диска не спасают.
|
||||
BACKUP_DIR = os.environ.get("HLAMINGO_BACKUP_DIR", "").strip()
|
||||
# Как часто копировать наружу. Каждое сохранение гонять по сети незачем —
|
||||
# база полтора мегабайта, а пишется она при каждом действии.
|
||||
EXTERNAL_BACKUP_EVERY = 3600 # секунд
|
||||
# Сетевой диск может отвалиться (у T: это регулярно). Ждём недолго и идём
|
||||
# дальше: потерять запись о вещи из-за недоступной шары недопустимо.
|
||||
BACKUP_TIMEOUT = 3.0
|
||||
|
||||
# ============================================================
|
||||
# Веб
|
||||
# ============================================================
|
||||
@@ -220,5 +256,10 @@ try:
|
||||
PORT = int(os.environ.get("HLAMINGO_PORT") or 5000)
|
||||
except ValueError:
|
||||
PORT = 5000
|
||||
MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # фото жмётся на клиенте; 8 МБ — щедрый запас
|
||||
# Общий предел запроса. Считается по архиву при восстановлении: он тем больше,
|
||||
# чем больше фотографий (сейчас ~15 МБ на 69 снимков), поэтому с запасом на рост.
|
||||
MAX_UPLOAD_BYTES = 512 * 1024 * 1024
|
||||
# Предел для одной фотографии — проверяется отдельно. Снимок ужимается
|
||||
# на клиенте до 1280 px, так что 8 МБ хватает с избытком.
|
||||
MAX_PHOTO_BYTES = 8 * 1024 * 1024
|
||||
ALLOWED_PHOTO_EXT = {".jpg", ".jpeg", ".png", ".webp"}
|
||||
|
||||
@@ -6,14 +6,19 @@
|
||||
item — предмет; лежит ЛИБО в ёмкости, ЛИБО прямо в месте (лопата в углу).
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import shutil
|
||||
import zipfile
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import DB_FILE, DATA_DIR
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -98,6 +103,189 @@ def save(db):
|
||||
tmp = DB_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(db, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, DB_FILE)
|
||||
external_backup()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Внешняя копия: база и фотографии вне этого компьютера
|
||||
# ============================================================
|
||||
_last_external = 0.0
|
||||
_external_note = {"at": None, "ok": None, "error": None}
|
||||
|
||||
|
||||
def external_state():
|
||||
"""Что показать в настройках: путь, доступность, когда копировали."""
|
||||
from . import config
|
||||
path = config.BACKUP_DIR
|
||||
state = dict(_external_note)
|
||||
state["path"] = path
|
||||
state["enabled"] = bool(path)
|
||||
state["reachable"] = _reachable(path) if path else None
|
||||
return state
|
||||
|
||||
|
||||
def _reachable(path):
|
||||
"""Доступна ли папка. Коротко: сетевая шара умеет отваливаться надолго."""
|
||||
if not path:
|
||||
return False
|
||||
try:
|
||||
target = Path(path)
|
||||
if target.exists():
|
||||
return os.access(str(target), os.W_OK)
|
||||
# Папки ещё нет — проверяем, доступен ли родитель (диск, шара).
|
||||
parent = target.parent
|
||||
return parent.exists() and os.access(str(parent), os.W_OK)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def external_backup(force=False):
|
||||
"""Копирует базу и новые фотографии в BACKUP_DIR. Ошибки не пробрасывает.
|
||||
|
||||
Недоступность внешнего пути не должна мешать работе: сетевой диск
|
||||
отваливается регулярно, а запись о вещи важнее копии.
|
||||
"""
|
||||
global _last_external
|
||||
from . import config
|
||||
|
||||
path = config.BACKUP_DIR
|
||||
if not path:
|
||||
return False
|
||||
# Не `now` — в модуле уже есть функция now(), и затенять её нельзя.
|
||||
moment = time.time()
|
||||
if not force and moment - _last_external < config.EXTERNAL_BACKUP_EVERY:
|
||||
return False
|
||||
|
||||
try:
|
||||
target = Path(path)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if DB_FILE.exists():
|
||||
tmp = target / "db.json.tmp"
|
||||
shutil.copy2(str(DB_FILE), str(tmp))
|
||||
os.replace(str(tmp), str(target / "db.json"))
|
||||
|
||||
# Фотографии копируем только новые: их десятки мегабайт, и гонять
|
||||
# всё по сети каждый раз незачем.
|
||||
copied = 0
|
||||
photos_src = config.PHOTOS_DIR
|
||||
if photos_src.exists():
|
||||
photos_dst = target / "photos"
|
||||
photos_dst.mkdir(exist_ok=True)
|
||||
for photo in photos_src.iterdir():
|
||||
if not photo.is_file():
|
||||
continue
|
||||
mirror = photos_dst / photo.name
|
||||
if not mirror.exists() or mirror.stat().st_size != photo.stat().st_size:
|
||||
shutil.copy2(str(photo), str(mirror))
|
||||
copied += 1
|
||||
|
||||
_last_external = moment
|
||||
_external_note.update({"at": now(), "ok": True, "error": None})
|
||||
logger.info("Внешняя копия сделана: %s (новых фото: %d)", path, copied)
|
||||
return True
|
||||
except Exception as e:
|
||||
_external_note.update({"at": now(), "ok": False, "error": str(e)})
|
||||
logger.warning("Внешняя копия не удалась (%s): %s", path, e)
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Архив: выгрузить всё одним файлом и вернуть обратно
|
||||
# ============================================================
|
||||
ARCHIVE_DB = "db.json"
|
||||
ARCHIVE_PHOTOS = "photos/"
|
||||
|
||||
|
||||
def export_archive():
|
||||
"""Собирает zip с базой и фотографиями. Возвращает BytesIO.
|
||||
|
||||
Локальные копии из data/backups не кладём: это десятки мегабайт истории,
|
||||
а восстановление от них не зависит.
|
||||
"""
|
||||
from . import config
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
if DB_FILE.exists():
|
||||
zf.write(str(DB_FILE), ARCHIVE_DB)
|
||||
if config.PHOTOS_DIR.exists():
|
||||
for photo in sorted(config.PHOTOS_DIR.iterdir()):
|
||||
if photo.is_file():
|
||||
zf.write(str(photo), ARCHIVE_PHOTOS + photo.name)
|
||||
zf.writestr("hlamingo.txt",
|
||||
"Архив hlamingo от %s\n"
|
||||
"db.json — база (места, ёмкости, вещи)\n"
|
||||
"photos/ — фотографии вещей\n"
|
||||
"Восстановить: кнопка «Загрузить архив» на вкладке настроек.\n"
|
||||
% now())
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
|
||||
def inspect_archive(data):
|
||||
"""Смотрит, что в архиве, ничего не меняя. Возвращает (db, photos) или (None, причина)."""
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except Exception:
|
||||
return None, "Это не zip-архив."
|
||||
|
||||
names = zf.namelist()
|
||||
if ARCHIVE_DB not in names:
|
||||
return None, "В архиве нет db.json — это не архив hlamingo."
|
||||
try:
|
||||
db = json.loads(zf.read(ARCHIVE_DB).decode("utf-8"))
|
||||
except Exception as e:
|
||||
return None, "db.json в архиве не читается: %s" % e
|
||||
if not isinstance(db, dict) or "items" not in db:
|
||||
return None, "db.json в архиве не похож на базу hlamingo."
|
||||
|
||||
photos = [n for n in names if n.startswith(ARCHIVE_PHOTOS) and not n.endswith("/")]
|
||||
return {"db": db, "photos": photos, "zip": zf}, None
|
||||
|
||||
|
||||
def import_archive(data):
|
||||
"""Заменяет базу и фотографии содержимым архива.
|
||||
|
||||
Перед заменой откладывает текущее состояние — откатиться можно всегда.
|
||||
Возвращает (итог, ошибка).
|
||||
"""
|
||||
from . import config
|
||||
|
||||
parsed, error = inspect_archive(data)
|
||||
if error:
|
||||
return None, error
|
||||
zf, db, photos = parsed["zip"], parsed["db"], parsed["photos"]
|
||||
|
||||
# Страховка до единой правки: и база, и фотографии.
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
safety = DATA_DIR / "backups"
|
||||
safety.mkdir(parents=True, exist_ok=True)
|
||||
if DB_FILE.exists():
|
||||
shutil.copy2(str(DB_FILE), str(safety / ("db-before-import-%s.json" % stamp)))
|
||||
if config.PHOTOS_DIR.exists() and any(config.PHOTOS_DIR.iterdir()):
|
||||
keep = safety / ("photos-before-import-%s" % stamp)
|
||||
shutil.copytree(str(config.PHOTOS_DIR), str(keep), dirs_exist_ok=True)
|
||||
|
||||
config.PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
restored = 0
|
||||
for name in photos:
|
||||
# Имя из архива как путь не используем: строка «../../» увела бы
|
||||
# запись куда угодно. Берём только базовое имя и известное расширение.
|
||||
safe = os.path.basename(name.replace("\\", "/"))
|
||||
if not safe or safe.startswith("."):
|
||||
continue
|
||||
if os.path.splitext(safe)[1].lower() not in config.ALLOWED_PHOTO_EXT:
|
||||
continue
|
||||
(config.PHOTOS_DIR / safe).write_bytes(zf.read(name))
|
||||
restored += 1
|
||||
|
||||
save(db)
|
||||
return {"items": len(db.get("items", {})),
|
||||
"places": len(db.get("places", {})),
|
||||
"containers": len(db.get("containers", {})),
|
||||
"photos": restored,
|
||||
"rollback": "db-before-import-%s.json" % stamp}, None
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
||||
+112
-1
@@ -263,6 +263,29 @@
|
||||
|
||||
<div class="mt"><button class="act" id="cfgSave">Сохранить</button></div>
|
||||
<p class="hint" id="cfgWhere"></p>
|
||||
|
||||
<h3 style="margin-top:26px">Резервные копии</h3>
|
||||
<p class="hint">Копии внутри программы лежат на этом же диске. Если он умрёт,
|
||||
пропадут и записи, и фотографии — поэтому копию стоит держать ещё где-то.</p>
|
||||
|
||||
<label>Папка для копий вне компьютера</label>
|
||||
<input type="text" id="cfgBackupDir" placeholder="T:\hlamingo-backup" autocomplete="off">
|
||||
<p class="hint">Сетевой диск или папка. Пусто — внешние копии выключены.
|
||||
Копирование идёт само, не чаще раза в час, и не мешает работе, если
|
||||
диск отвалился.</p>
|
||||
<div id="backupState"></div>
|
||||
<div class="row mt">
|
||||
<button class="act ghost" id="backupNow">Скопировать сейчас</button>
|
||||
</div>
|
||||
|
||||
<label>Архив со всеми данными</label>
|
||||
<div class="row">
|
||||
<button class="act ghost" id="exportBtn">📦 Скачать архив</button>
|
||||
<button class="act ghost" id="importBtn">📥 Загрузить архив</button>
|
||||
</div>
|
||||
<p class="hint">В архиве база и все фотографии. Загрузка ЗАМЕНЯЕТ текущие
|
||||
данные — прежние сохранятся отдельно, чтобы можно было вернуться.</p>
|
||||
<div id="importInfo"></div>
|
||||
</section>
|
||||
|
||||
<!-- ================= Обзор ================= -->
|
||||
@@ -281,6 +304,7 @@
|
||||
<input type="file" id="galInput" accept="image/*" hidden>
|
||||
<input type="file" id="formCamInput" accept="image/*" capture="environment" hidden>
|
||||
<input type="file" id="formGalInput" accept="image/*" hidden>
|
||||
<input type="file" id="archiveInput" accept=".zip,application/zip" hidden>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
@@ -346,8 +370,94 @@ async function loadConfig() {
|
||||
$('#cfgProbeResult').innerHTML =
|
||||
'<div class="card"><div class="addr taken">Ollama по этому адресу не отвечает.</div></div>';
|
||||
}
|
||||
showBackupState(d.backup || {});
|
||||
}
|
||||
|
||||
function showBackupState(b) {
|
||||
$('#cfgBackupDir').value = b.path || '';
|
||||
const box = $('#backupState');
|
||||
if (!b.enabled) {
|
||||
box.innerHTML = '<div class="card"><div class="meta">Внешние копии выключены. '
|
||||
+ 'Укажите папку выше, чтобы данные жили не только на этом диске.</div></div>';
|
||||
return;
|
||||
}
|
||||
const good = b.reachable;
|
||||
let html = '<div class="card"><div class="addr' + (good ? '' : ' taken') + '">'
|
||||
+ (good ? 'Папка доступна' : 'Папка сейчас недоступна') + '</div>'
|
||||
+ '<div class="meta">' + esc(b.path) + '</div>';
|
||||
if (b.at) {
|
||||
html += '<div class="meta">Последняя копия: ' + esc(b.at)
|
||||
+ (b.ok ? '' : ' — с ошибкой') + '</div>';
|
||||
} else {
|
||||
html += '<div class="meta">Копий ещё не было.</div>';
|
||||
}
|
||||
if (b.error) html += '<div class="meta">' + esc(b.error) + '</div>';
|
||||
box.innerHTML = html + '</div>';
|
||||
}
|
||||
|
||||
$('#backupNow').onclick = async () => {
|
||||
const btn = $('#backupNow');
|
||||
btn.disabled = true; btn.textContent = 'Копирую…';
|
||||
try {
|
||||
const d = await api('/api/backup/now', {method: 'POST'});
|
||||
say(d.reply);
|
||||
if (d.state) showBackupState(d.state);
|
||||
} finally { btn.disabled = false; btn.textContent = 'Скопировать сейчас'; }
|
||||
};
|
||||
|
||||
// Экспорт: обычная ссылка на скачивание — так файл уходит в загрузки телефона.
|
||||
$('#exportBtn').onclick = () => { window.location = '/api/export'; };
|
||||
|
||||
$('#importBtn').onclick = () => $('#archiveInput').click();
|
||||
$('#archiveInput').onchange = async () => {
|
||||
const file = $('#archiveInput').files[0];
|
||||
$('#archiveInput').value = '';
|
||||
if (!file) return;
|
||||
|
||||
const box = $('#importInfo');
|
||||
box.innerHTML = '<div class="card"><div class="meta">Смотрю, что в архиве…</div></div>';
|
||||
|
||||
// Сначала показываем, что внутри: импорт заменяет данные целиком,
|
||||
// и человек должен видеть, на что меняет.
|
||||
const fd = new FormData();
|
||||
fd.append('archive', file);
|
||||
const check = await api('/api/import/check', {method: 'POST', body: fd});
|
||||
if (!check.ok) {
|
||||
box.innerHTML = '<div class="card"><div class="addr taken">' + esc(check.reply) + '</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const a = check.archive, c = check.current;
|
||||
box.innerHTML = '<div class="card">'
|
||||
+ '<h3>Заменить данные?</h3>'
|
||||
+ '<div class="meta">Сейчас: ' + c.items + ' вещей, ' + c.places + ' мест, '
|
||||
+ c.containers + ' коробок</div>'
|
||||
+ '<div class="addr">В архиве: ' + a.items + ' вещей, ' + a.places + ' мест, '
|
||||
+ a.containers + ' коробок, ' + a.photos + ' фото</div>'
|
||||
+ '<div class="meta">Текущие данные сохранятся отдельно — вернуться можно.</div>'
|
||||
+ '</div>';
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'acts';
|
||||
const go = document.createElement('button');
|
||||
go.textContent = 'Заменить';
|
||||
go.onclick = async () => {
|
||||
go.disabled = true; go.textContent = 'Восстанавливаю…';
|
||||
const fd2 = new FormData();
|
||||
fd2.append('archive', file);
|
||||
const d = await api('/api/import', {method: 'POST', body: fd2});
|
||||
say(d.reply);
|
||||
box.innerHTML = '<div class="card"><div class="' + (d.ok ? 'addr' : 'addr taken') + '">'
|
||||
+ esc(d.reply) + '</div></div>';
|
||||
if (d.ok) { loadPlaces().then(loadBoxes); loadTree(); }
|
||||
};
|
||||
const no = document.createElement('button');
|
||||
no.textContent = 'Отмена';
|
||||
no.onclick = () => { box.innerHTML = ''; };
|
||||
bar.appendChild(go); bar.appendChild(no);
|
||||
box.firstChild.appendChild(bar);
|
||||
};
|
||||
|
||||
// Модель выбирается из установленных; текущее значение показываем даже если
|
||||
// его нет в списке — иначе выбор молча сбросился бы на чужую модель.
|
||||
function fillModels(models, chat, embed) {
|
||||
@@ -384,7 +494,8 @@ $('#cfgSave').onclick = async () => {
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ollama_host: $('#cfgHost').value.trim(),
|
||||
chat_model: $('#cfgChat').value,
|
||||
embed_model: $('#cfgEmbed').value})});
|
||||
embed_model: $('#cfgEmbed').value,
|
||||
backup_dir: $('#cfgBackupDir').value.trim()})});
|
||||
say(d.reply);
|
||||
if (d.ok) {
|
||||
$('#banner').style.display = d.available ? 'none' : 'block';
|
||||
|
||||
@@ -269,6 +269,90 @@ finally:
|
||||
ollama.reset_breaker()
|
||||
r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True)
|
||||
|
||||
print("\n== Экспорт и импорт ==")
|
||||
import json as _json # noqa: E402
|
||||
import zipfile # noqa: E402
|
||||
|
||||
# Кладём снимок, чтобы в архиве было чему проверяться: предыдущие блоки
|
||||
# свои фотографии за собой убирают.
|
||||
_some = c.get("/api/items").get_json()["items"][0]["id"]
|
||||
upload("item", _some)
|
||||
|
||||
before = c.get("/api/items").get_json()["items"]
|
||||
resp = c.get("/api/export")
|
||||
r.check("архив отдаётся", resp.status_code == 200, resp.status_code)
|
||||
r.check("это zip", resp.data[:2] == b"PK", resp.data[:4])
|
||||
|
||||
archive = zipfile.ZipFile(io.BytesIO(resp.data))
|
||||
names = archive.namelist()
|
||||
r.check("внутри есть база", "db.json" in names, names[:5])
|
||||
r.check("внутри есть фотографии", any(n.startswith("photos/") for n in names), names[:5])
|
||||
r.check("локальные копии в архив не попали",
|
||||
not any("backups" in n for n in names), [n for n in names if "backups" in n])
|
||||
|
||||
d = c.post("/api/import/check", content_type="multipart/form-data",
|
||||
data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json()
|
||||
r.check("проверка архива видит содержимое",
|
||||
d.get("ok") and d["archive"]["items"] == len(before), d)
|
||||
|
||||
# Восстановление в ту же песочницу: данные должны остаться прежними,
|
||||
# а прежнее состояние — отложиться для отката.
|
||||
d = c.post("/api/import", content_type="multipart/form-data",
|
||||
data={"archive": (io.BytesIO(resp.data), "a.zip")}).get_json()
|
||||
r.check("импорт прошёл", d.get("ok"), d)
|
||||
after = c.get("/api/items").get_json()["items"]
|
||||
r.check("вещи на месте после импорта", len(after) == len(before),
|
||||
(len(before), len(after)))
|
||||
rollback = config.DATA_DIR / "backups" / d["result"]["rollback"]
|
||||
r.check("страховка перед импортом создана", rollback.exists(), rollback)
|
||||
|
||||
# Архив с путём наружу: имя из архива не должно превращаться в путь.
|
||||
evil = io.BytesIO()
|
||||
with zipfile.ZipFile(evil, "w") as zf:
|
||||
zf.writestr("db.json", _json.dumps({"version": 1, "items": {}, "places": {},
|
||||
"containers": {}}))
|
||||
zf.writestr("photos/../../evil.png", b"\x89PNG\r\n\x1a\n")
|
||||
evil.seek(0)
|
||||
outside = config.PROJECT_DIR.parent / "evil.png"
|
||||
d = c.post("/api/import", content_type="multipart/form-data",
|
||||
data={"archive": (evil, "evil.zip")}).get_json()
|
||||
r.check("злой архив не пишет за пределы папки", not outside.exists(), outside)
|
||||
if outside.exists():
|
||||
outside.unlink()
|
||||
|
||||
r.check("чужой файл отвергается",
|
||||
c.post("/api/import", content_type="multipart/form-data",
|
||||
data={"archive": (io.BytesIO(b"not a zip"), "x.zip")}).status_code == 400)
|
||||
r.check("импорт без файла — 400",
|
||||
c.post("/api/import", content_type="multipart/form-data", data={}).status_code == 400)
|
||||
|
||||
# Возвращаем данные песочницы: злой архив заменил базу на пустую.
|
||||
c.post("/api/import", content_type="multipart/form-data",
|
||||
data={"archive": (io.BytesIO(resp.data), "a.zip")})
|
||||
|
||||
print("\n== Внешние копии ==")
|
||||
r.check("состояние отдаётся", "backup" in c.get("/api/config").get_json())
|
||||
r.check("без пути — копировать нечего",
|
||||
c.post("/api/backup/now").status_code == 400 if not config.BACKUP_DIR else True)
|
||||
|
||||
dest = SANDBOX / "external"
|
||||
config.save_settings({"HLAMINGO_BACKUP_DIR": str(dest)})
|
||||
r.check("путь принят", config.BACKUP_DIR == str(dest), config.BACKUP_DIR)
|
||||
d = c.post("/api/backup/now").get_json()
|
||||
r.check("копия сделана", d.get("ok"), d)
|
||||
r.check("база скопирована", (dest / "db.json").exists())
|
||||
r.check("фотографии скопированы", (dest / "photos").exists())
|
||||
|
||||
# Недоступный путь не должен мешать записи вещи — сетевой диск отваливается.
|
||||
config.save_settings({"HLAMINGO_BACKUP_DIR": "Z:\\нет-такого-диска\\backup"})
|
||||
import time as _t2 # noqa: E402
|
||||
t0 = _t2.time()
|
||||
d = j("POST", "/api/put", name="вещь при отвалившемся диске")
|
||||
r.check("запись прошла, несмотря на недоступный путь",
|
||||
d.get("ok") and _t2.time() - t0 < 10, "%.1f с" % (_t2.time() - t0))
|
||||
config.save_settings({"HLAMINGO_BACKUP_DIR": ""})
|
||||
r.check("путь можно очистить", config.BACKUP_DIR == "", config.BACKUP_DIR)
|
||||
|
||||
print("\n== Батники: только ASCII ==")
|
||||
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
|
||||
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
|
||||
|
||||
Reference in New Issue
Block a user