Экспорт, импорт и копии вне компьютера

Все 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:
2026-07-31 11:17:08 +03:00
co-authored by Claude Opus 5
parent 99e392d777
commit 1ae98b0b6f
7 changed files with 569 additions and 13 deletions
+5
View File
@@ -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
+46 -4
View File
@@ -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
View File
@@ -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")
+47 -6
View File
@@ -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,8 +87,14 @@ def save_settings(values):
"""
checked = {}
for key, raw in (values or {}).items():
if key in EDITABLE:
checked[key] = _validate(key, raw)
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)]
if changed:
@@ -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"}
+188
View File
@@ -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
View File
@@ -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';
+84
View File
@@ -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 пытается выполнить обрывки