feat: Settings -> Backup, export and import the user's data

Export packs the stores (recipes, combos, materials, parameters, and
optionally ComfyUI's saved workflows), the plugin settings without the
Civitai key, and each model's user layer and own cover into one .zip to
keep on a cloud drive. Import lists what is new, the same or different
and the models it finds again (by SHA-256, else folder, path and size),
adds only what is missing by default, replaces differing files only on
request with this computer's going to the Recycle Bin first, merges a
model's name and notes field by field and puts the user's cover in
place of a Civitai one. Unsafe or foreign zips are refused. Both are
activity entries. tests/test_backup.py covers the rules; testing.md
names a single test file the way unittest finds it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DemonGatanjieu
2026-10-06 18:12:45 +08:00
co-authored by Claude Opus 5.5
parent 7bb46ba82f
commit 797a52105e
14 changed files with 1196 additions and 7 deletions
+8 -2
View File
@@ -111,6 +111,12 @@ covered by the `styles.css` manifest.
ComfyUI's websocket and waits for its answer; `web/modules/mcp_bridge.js` is the page
side, doing it with Anomalous's canvas code (one Ctrl+Z step each, logged as the AI's).
User guide: `docs/guides/mcp.md`.
- `api/backup.py` is Settings → Backup (`/anomalous/backup/*`): one .zip of the user's stores
(recipes, combos, materials, parameters, optionally ComfyUI's saved workflows), the plugin
settings without the Civitai key, and each model's user layer and own cover; putting it
back adds what is missing, replaces differing files only on request (Recycle Bin first)
and finds models again by SHA-256, else by folder, path and size. `ui_backup.js` is its
export and import dialogs.
- `api/lora_info.py` reads a LoRA's safetensors header to tell whether it trains the text
encoder (`/anomalous/lora_info`), so inserting it wires the CLIP line only then.
- `api/model_download.py` downloads the models a workflow misses (Model Check's
@@ -343,8 +349,8 @@ covered by the `styles.css` manifest.
`ui_settings_hub.js` owns the display preferences (view mode, scale, atmosphere, window
layout) and their application, the gear and language redraws; `ui_settings_page.js`
is the settings page the gear opens (a tool page with Back): look and language, model
cards and memory with the card image cache, folders, workflow fingerprints, opening
mode and window, help; `ui_feedback_dialog.js` is the feedback window (Home and the
cards and memory with the card image cache, folders, workflow fingerprints, downloads,
backup (`ui_backup.js`), opening mode and window, help; `ui_feedback_dialog.js` is the feedback window (Home and the
settings page open it): one text box, then `feedback.js` opens a GitHub issue with it in
the browser's language, the environment folded at the end when attached (versions and
hardware only, never a path or ComfyUI's command line), or copies the environment;
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
### 💾 Backup and restore (备份与恢复)
- **Settings → Backup → Export** packs your Workflow Recipes, combos, materials and saved prompts, parameter sets, the plugin settings and the names, notes and covers you gave your models into one .zip, optionally with ComfyUI's own saved workflows. Keep it on a cloud drive or a USB stick. The Civitai API key is never included.
- **Import** first lists what is in the backup next to what this computer has (new, the same, different), and the models it finds again by file hash, even after they were renamed or moved. By default it only adds what is missing; "take the backup's" replaces differing files and puts this computer's in the Recycle Bin. A model's name and notes are filled in where empty, and your own cover takes the place of the Civitai one. Nothing is deleted.
### 🧪 Tests in the repository (测试公开)
- The plugin's tests are now part of the repository: `tests/` (Python tests for the backend, `.mjs` tests for the frontend rules), `node tools/run_tests.mjs` runs them all with the structure check, and [docs/guides/testing.md](docs/guides/testing.md) says how to run and write them. They need no network or GPU and only touch temporary folders. Developer notes no longer point at tests that were removed earlier.
+2 -2
View File
@@ -108,7 +108,7 @@ Open the browser with **Ctrl + Shift + M**, the floating button on the canvas, o
The first time you open it, start on **Home**: **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
> [!WARNING]
> **Keep a copy of your own data:** Workflow Recipes, combos, saved prompts and node values, and parameter presets live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder. A copy of them before updating the plugin is a good habit.
> **Keep a copy of your own data:** **Settings → Backup → Export** packs Workflow Recipes, combos, saved prompts and node values, parameter presets, and the names, notes and covers you gave your models into one .zip; keep it on a cloud drive or a USB stick, and **Import** puts it back, also on another computer. (They live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder.)
---
@@ -206,7 +206,7 @@ The first time you open it, start on **Home**: **Scan model folders** fills in c
第一次打开时会停在 **主页**:点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
> [!WARNING]
> **备份自己的数据:** 工作流配方、搭配、存下的提示词和节点参数、参数预设存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters` 文件夹。更新插件前顺手备份一份,更安心。
> **备份自己的数据:** **设置 → 备份 → 导出**,把工作流配方、搭配、存下的提示词和节点参数、参数预设,以及你给模型改的名字、备注和封面打成一个压缩包,放到云端硬盘或 U 盘里;换电脑或重装后用 **导入** 恢复。(它们存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters`。)
---
+2 -1
View File
@@ -1,7 +1,7 @@
from . import (
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
model_media, model_metadata, model_resolution, model_type_listing, kept_images, node_material, recipe_packages, recipes, scan_report, scan_summary,
lora_info, mcp_server, model_download, translation_routes, version_manager,
lora_info, mcp_server, model_download, translation_routes, version_manager, backup,
)
from .scanner import *
from .config import *
@@ -125,3 +125,4 @@ def setup_routes(app):
activity_log.register_routes(app)
mcp_server.register_routes(app)
model_download.register_routes(app)
backup.register_routes(app)
+520
View File
@@ -0,0 +1,520 @@
"""Backups of the user's own data (Settings -> Backup): one .zip to keep anywhere (a cloud
drive, a USB stick), and putting it back on this or another computer.
A backup holds the plugin's stores (Workflow Recipes, combos, materials and saved prompts,
parameter sets), optionally ComfyUI's own saved workflows, the plugin settings without the
Civitai key, and what the user set on models: <model>.anomalous.json (name, notes, link) and
the user's cover (any cover that is not a copy of Civitai's <model>.civitai_bak.*). Models are
found again by SHA-256, else by models folder, path and size, so they match after a move.
Putting back never erases: a file missing here is added; a file that differs stays unless
"replace" is chosen, and then the one here goes to the Recycle Bin first. A model's user file
is merged field by field (empty fields are filled; "replace" takes the backup's values), and
the backup's cover takes the place of a Civitai cover (whose copy stays as civitai_bak).
"""
import asyncio
import hashlib
import json
import os
import shutil
import tempfile
import time
import uuid
import zipfile
import folder_paths
from aiohttp import web
from .activity_log import add_entry
from .folder_types import config_path
from .material_store import get_materials_dir
from .metadata import USER_FIELDS, clear_metadata_cache, get_metadata
from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_SUFFIXES, RESOLVABLE_MODEL_TYPES, is_model_file
from .model_download import settings_path as download_settings_path
from .notebooks import get_notebooks_dir
from .parameters import _invalidate_parameter_notebooks_cache, get_parameters_dir
from .path_utils import atomic_write_json, require_filename, resolve_within
from .recipe_store import get_recipes_dir
from .trash import TrashUnavailable, move_to_trash, trash_failure
from . import version_manager
try:
from ..model_identity import USER_INFO_SUFFIX, read_json
except ImportError:
from model_identity import USER_INFO_SUFFIX, read_json
FORMAT = 1
APP = "Anomalous Model Browser"
ACTIVE_COVER_SUFFIXES = PREVIEW_SUFFIXES + MEDIA_EXTENSIONS # as scraper.py: <model>.preview.png or <model>.png
SECRET_KEYS = {"CIVITAI_API_KEY"} # never leaves this computer
SKIP_FILES = {".legacy_imported.json"} # this computer's own migration marker
MAX_BACKUP_BYTES = 4 * 1024 ** 3 # unpacked size of a backup that is still read
MAX_MEMBERS = 200_000
KEEP_EXPORTS = 3 # finished exports kept in the temp folder for downloading
CHUNK = 1024 * 1024
PART_ORDER = ("recipes", "combos", "materials", "parameters", "comfy_workflows")
def _comfy_workflows_dir():
return os.path.join(folder_paths.get_user_directory(), "default", "workflows")
def part_dirs():
"""Each kept store and where it lives on this computer."""
return {
"recipes": get_recipes_dir(),
"combos": get_notebooks_dir(),
"materials": get_materials_dir(),
"parameters": get_parameters_dir(),
"comfy_workflows": _comfy_workflows_dir(),
}
def work_dir():
path = os.path.join(folder_paths.get_temp_directory(), "anomalous_backups")
os.makedirs(path, exist_ok=True)
return path
def _sha256_file(path):
digest = hashlib.sha256()
with open(path, "rb") as handle:
for block in iter(lambda: handle.read(CHUNK), b""):
digest.update(block)
return digest.hexdigest()
def _same_bytes(a, b):
try:
return os.path.getsize(a) == os.path.getsize(b) and _sha256_file(a) == _sha256_file(b)
except OSError:
return False
def _store_files(directory):
"""(path, '/'-relative path) of every file kept in a store."""
if not os.path.isdir(directory):
return
for root, dirs, files in os.walk(directory):
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
for name in sorted(files):
if name in SKIP_FILES or name.endswith(".tmp"):
continue
path = os.path.join(root, name)
yield path, os.path.relpath(path, directory).replace(os.sep, "/")
def _model_roots():
"""(models folder type, folder) once per folder: clip and text_encoders often share one."""
seen = set()
for folder_type in RESOLVABLE_MODEL_TYPES:
try:
roots = folder_paths.get_folder_paths(folder_type) or []
except Exception: # noqa: BLE001 - a type this ComfyUI does not know
continue
for root in roots:
key = os.path.normcase(os.path.abspath(root))
if key in seen or not os.path.isdir(root):
continue
seen.add(key)
yield folder_type, root
def _models():
"""(type, root, path, '/'-relative path) of every model file."""
for folder_type, root in _model_roots():
for walk_root, dirs, files in os.walk(root):
dirs.sort()
for name in sorted(files):
if is_model_file(name):
path = os.path.join(walk_root, name)
yield folder_type, root, path, os.path.relpath(path, root).replace(os.sep, "/")
def cover_owners(base):
"""(Civitai's, the user's) covers of a model: Civitai's are copies of <model>.civitai_bak.*."""
backups = [base + suffix for suffix in CIVITAI_BACKUP_SUFFIXES if os.path.isfile(base + suffix)]
covers = [base + suffix for suffix in ACTIVE_COVER_SUFFIXES if os.path.isfile(base + suffix)]
civitai = [cover for cover in covers if any(_same_bytes(cover, backup) for backup in backups)]
return civitai, [cover for cover in covers if cover not in civitai]
def _model_hash(path):
try:
return str((get_metadata(path) or {}).get("hash") or "").lower()
except Exception: # noqa: BLE001 - an unreadable sidecar only means "match by path"
return ""
def _settings_for_backup():
data = read_json(config_path()) or {}
return {key: value for key, value in data.items() if key not in SECRET_KEYS}
def _version():
try:
return version_manager.current_state().get("label") or ""
except Exception: # noqa: BLE001 - a copy that is no git checkout
return ""
# ---------- export ----------
def build_backup(include_models=True, include_comfy=True):
"""Writes the backup .zip into the temp folder: (path, manifest)."""
stamp = time.strftime("%Y%m%d-%H%M%S")
path = os.path.join(work_dir(), f"AMB-backup-{stamp}.zip")
manifest = {"format": FORMAT, "app": APP, "version": _version(), "created": time.time(), "parts": {}, "settings": False, "models": []}
parts = [part for part in PART_ORDER if include_comfy or part != "comfy_workflows"]
dirs = part_dirs()
temporary = path + ".tmp"
try:
with zipfile.ZipFile(temporary, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
for part in parts:
count = 0
for file_path, rel in _store_files(dirs[part]):
archive.write(file_path, f"stores/{part}/{rel}")
count += 1
manifest["parts"][part] = count
archive.writestr("settings/config.json", json.dumps(_settings_for_backup(), ensure_ascii=False))
if os.path.isfile(download_settings_path()):
archive.write(download_settings_path(), "settings/download_settings.json")
manifest["settings"] = True
if include_models:
for folder_type, _root, model_path, rel in _models():
base = os.path.splitext(model_path)[0]
user_file = base + USER_INFO_SUFFIX
user_data = read_json(user_file) if os.path.isfile(user_file) else None
covers = cover_owners(base)[1]
if not user_data and not covers:
continue
entry = {"id": len(manifest["models"]), "type": folder_type, "rel": rel,
"size": os.path.getsize(model_path), "sha256": _model_hash(model_path), "user": bool(user_data), "cover": ""}
if user_data:
archive.writestr(f"models/{entry['id']}/user.json", json.dumps(user_data, ensure_ascii=False))
if covers:
entry["cover"] = covers[0][len(base):]
archive.write(covers[0], f"models/{entry['id']}/cover{entry['cover']}")
manifest["models"].append(entry)
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=1))
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.remove(temporary)
_prune_exports()
return path, manifest
def _prune_exports():
"""Keeps the newest finished exports; older ones are this plugin's own temp files."""
exports = sorted((name for name in os.listdir(work_dir()) if name.startswith("AMB-backup-") and name.endswith(".zip")), reverse=True)
for name in exports[KEEP_EXPORTS:]:
try:
os.remove(os.path.join(work_dir(), name))
except OSError:
pass
# ---------- reading a backup ----------
class BackupError(ValueError):
"""A file that is no backup of this plugin, or one that cannot be read safely."""
def _safe_rel(rel):
parts = rel.split("/")
if not rel or rel.startswith("/") or "\\" in rel or ":" in rel or any(part in ("", ".", "..") for part in parts):
raise BackupError(f"unsafe path in the backup: {rel}")
return parts
def open_backup(path):
"""The zip and its manifest, after checking it is a backup and safe to unpack."""
try:
archive = zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError) as error:
raise BackupError("not a zip file") from error
try:
infos = archive.infolist()
if len(infos) > MAX_MEMBERS or sum(info.file_size for info in infos) > MAX_BACKUP_BYTES:
raise BackupError("the backup is too large")
try:
manifest = json.loads(archive.read("manifest.json"))
except (KeyError, ValueError) as error:
raise BackupError("no manifest: not a backup of this plugin") from error
if not isinstance(manifest, dict) or manifest.get("app") != APP or manifest.get("format") != FORMAT:
raise BackupError("not a backup of this plugin, or one from a newer version")
for info in infos:
if not info.is_dir():
_safe_rel(info.filename)
return archive, manifest
except Exception:
archive.close()
raise
def _store_members(archive):
"""(part, '/'-relative path, ZipInfo) of each kept store file in the backup."""
for info in archive.infolist():
name = info.filename
if info.is_dir() or not name.startswith("stores/"):
continue
bits = name.split("/", 2)
if len(bits) == 3 and bits[1] in PART_ORDER:
_, part, rel = bits
_safe_rel(rel)
yield part, rel, info
def _member_sha256(archive, info):
digest = hashlib.sha256()
with archive.open(info) as handle:
for block in iter(lambda: handle.read(CHUNK), b""):
digest.update(block)
return digest.hexdigest()
def _file_state(archive, info, target):
if not os.path.isfile(target):
return "new"
if os.path.getsize(target) == info.file_size and _sha256_file(target) == _member_sha256(archive, info):
return "same"
return "differs"
def _local_models():
"""This computer's models: by SHA-256 and by (type, path) -> (path, size)."""
by_hash, by_rel = {}, {}
for folder_type, _root, path, rel in _models():
size = os.path.getsize(path)
digest = _model_hash(path)
if digest:
by_hash.setdefault(digest, path)
by_rel[(folder_type, rel.lower())] = (path, size)
return by_hash, by_rel
def _match(entry, by_hash, by_rel):
digest = str(entry.get("sha256") or "").lower()
if digest and digest in by_hash:
return by_hash[digest]
found = by_rel.get((entry.get("type"), str(entry.get("rel") or "").lower()))
return found[0] if found and found[1] == entry.get("size") else None
def inspect_backup(path):
"""What putting the backup back would do, before anything is written."""
archive, manifest = open_backup(path)
with archive:
dirs = part_dirs()
parts = {}
for part, rel, info in _store_members(archive):
counts = parts.setdefault(part, {"new": 0, "same": 0, "differs": 0})
counts[_file_state(archive, info, resolve_within(dirs[part], *rel.split("/")))] += 1
models = {"total": len(manifest.get("models") or []), "matched": 0, "missing": []}
if models["total"]:
by_hash, by_rel = _local_models()
for entry in manifest["models"]:
if _match(entry, by_hash, by_rel):
models["matched"] += 1
else:
models["missing"].append(entry.get("rel") or "")
return {"created": manifest.get("created"), "version": manifest.get("version") or "",
"parts": {part: parts[part] for part in PART_ORDER if part in parts},
"settings": bool(manifest.get("settings")), "models": models}
# ---------- putting back ----------
def _write_member(archive, info, target):
os.makedirs(os.path.dirname(target), exist_ok=True)
fd, temporary = tempfile.mkstemp(prefix=".restore-", suffix=".tmp", dir=os.path.dirname(target))
try:
with os.fdopen(fd, "wb") as output, archive.open(info) as source:
shutil.copyfileobj(source, output, CHUNK)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.remove(temporary)
def _restore_stores(archive, wanted, replace, result):
dirs = part_dirs()
for part, rel, info in _store_members(archive):
if part not in wanted:
continue
target = resolve_within(dirs[part], *rel.split("/"))
state = _file_state(archive, info, target)
if state == "same" or (state == "differs" and not replace):
result["skipped"] += 1
continue
if state == "differs":
try:
move_to_trash(target)
except (TrashUnavailable, OSError) as error:
result["failed"].append(f"{part}/{rel}: {trash_failure(error)}")
continue
result["replaced"] += 1
else:
result["added"] += 1
_write_member(archive, info, target)
def _restore_model(archive, entry, model_path, replace, result):
base = os.path.splitext(model_path)[0]
folder = f"models/{int(entry['id'])}/"
if entry.get("user"):
backup = json.loads(archive.read(folder + "user.json"))
user_file = base + USER_INFO_SUFFIX
local = read_json(user_file) if os.path.isfile(user_file) else None
merged = dict(local or {"format": 1})
for key in USER_FIELDS:
value = str(backup.get(key) or "").strip()
if value and (replace or not str(merged.get(key) or "").strip()):
merged[key] = backup[key]
if merged != (local or {"format": 1}):
if local and replace:
move_to_trash(user_file) # the old values stay recoverable
atomic_write_json(user_file, merged)
result["model_notes"] += 1
suffix = str(entry.get("cover") or "")
if suffix:
if suffix not in ACTIVE_COVER_SUFFIXES:
raise BackupError(f"unknown cover name: {suffix}")
info = archive.getinfo(folder + "cover" + suffix)
civitai, mine = cover_owners(base)
member_hash = _member_sha256(archive, info)
if any(_sha256_file(cover) == member_hash for cover in mine):
return
if mine and not replace:
result["covers_kept"] += 1
return
for cover in mine + civitai: # Civitai's own copy stays as <model>.civitai_bak.*
move_to_trash(cover)
_write_member(archive, info, base + suffix)
result["model_covers"] += 1
def _restore_settings(archive, result):
names = set(archive.namelist())
if "settings/config.json" in names:
backup = json.loads(archive.read("settings/config.json"))
local = read_json(config_path()) or {}
merged = {**{key: value for key, value in backup.items() if key not in SECRET_KEYS},
**{key: local[key] for key in SECRET_KEYS if key in local}}
atomic_write_json(config_path(), merged)
result["settings"] = True
if "settings/download_settings.json" in names:
os.makedirs(os.path.dirname(download_settings_path()), exist_ok=True)
atomic_write_json(download_settings_path(), json.loads(archive.read("settings/download_settings.json")))
def apply_backup(path, parts=PART_ORDER, models=True, settings=False, replace=False):
"""Puts the backup back as chosen; returns the counts for the user and the activity log."""
archive, manifest = open_backup(path)
result = {"added": 0, "replaced": 0, "skipped": 0, "model_notes": 0, "model_covers": 0,
"covers_kept": 0, "models_missing": 0, "settings": False, "failed": []}
with archive:
_restore_stores(archive, set(parts) & set(PART_ORDER), replace, result)
if models and manifest.get("models"):
by_hash, by_rel = _local_models()
for entry in manifest["models"]:
model_path = _match(entry, by_hash, by_rel)
if not model_path:
result["models_missing"] += 1
continue
try:
_restore_model(archive, entry, model_path, replace, result)
except (TrashUnavailable, OSError, KeyError, ValueError) as error:
result["failed"].append(f"{entry.get('rel')}: {trash_failure(error) if isinstance(error, (TrashUnavailable, OSError)) else error}")
clear_metadata_cache()
if settings and manifest.get("settings"):
_restore_settings(archive, result)
_invalidate_parameter_notebooks_cache()
return result
# ---------- routes ----------
def _error(message, status=400):
return web.json_response({"status": "error", "error": str(message)}, status=status)
async def api_export(request):
try:
body = await request.json()
except Exception: # noqa: BLE001 - no body: the defaults
body = {}
path, manifest = await asyncio.to_thread(build_backup, body.get("models", True) is not False, body.get("comfy_workflows", True) is not False)
counts = {**manifest["parts"], "models": len(manifest["models"])}
await asyncio.to_thread(add_entry, "file", "backup_export", os.path.basename(path), {"backup": counts})
return web.json_response({"status": "success", "name": os.path.basename(path), "size": os.path.getsize(path), "counts": counts})
async def api_file(request):
try:
name = require_filename(request.query.get("name", ""))
if not name.startswith("AMB-backup-") or not name.endswith(".zip"):
raise ValueError("not a backup")
path = resolve_within(work_dir(), name)
except ValueError as error:
return _error(error)
if not os.path.isfile(path):
return _error("This backup is gone; export it again.", 404)
return web.FileResponse(path, headers={"Content-Disposition": f'attachment; filename="{name}"'})
async def api_inspect(request):
"""Receives the chosen .zip, keeps it in the temp folder and says what putting it back would do."""
reader = await request.multipart()
field = await reader.next()
if field is None or field.name != "file":
return _error("no file")
token = uuid.uuid4().hex
path = os.path.join(work_dir(), f"incoming-{token}.zip")
size = 0
with open(path, "wb") as output:
while chunk := await field.read_chunk(CHUNK):
size += len(chunk)
if size > MAX_BACKUP_BYTES:
output.close()
os.remove(path)
return _error("the backup is too large")
output.write(chunk)
try:
summary = await asyncio.to_thread(inspect_backup, path)
except BackupError as error:
os.remove(path)
return _error(error)
return web.json_response({"status": "success", "token": token, **summary})
async def api_apply(request):
try:
body = await request.json()
token = str(body.get("token") or "")
if not token.isalnum():
raise ValueError("bad token")
path = resolve_within(work_dir(), f"incoming-{token}.zip")
except Exception as error: # noqa: BLE001
return _error(error)
if not os.path.isfile(path):
return _error("This backup is gone; choose the file again.", 404)
parts = [part for part in body.get("parts") or [] if part in PART_ORDER]
try:
result = await asyncio.to_thread(apply_backup, path, parts, body.get("models") is not False,
body.get("settings") is True, body.get("replace") is True)
except BackupError as error:
return _error(error)
os.remove(path)
await asyncio.to_thread(add_entry, "file", "backup_import", str(body.get("name") or ""), {"backup": {**result, "failed": result["failed"][:20]}})
return web.json_response({"status": "success", **result})
def register_routes(app):
app.router.add_post("/anomalous/backup/export", api_export)
app.router.add_get("/anomalous/backup/file", api_file)
app.router.add_post("/anomalous/backup/inspect", api_inspect)
app.router.add_post("/anomalous/backup/apply", api_apply)
+24
View File
@@ -177,6 +177,30 @@ server's own `/anomalous/scan_all`, `/anomalous/download/status`, `/anomalous_tt
minutes for the file). Downloads go through the page (`model_download.js` downloadMissing /
downloadFrom), so the placement rules and the put-in after a download are the UI's own. Canvas changes are posted to the activity log with `via: "mcp"`.
## Backups
`api/backup.py` packs the user's data into one .zip (`POST /anomalous/backup/export`, then
`GET /anomalous/backup/file`) in the temp folder (the newest three are kept): the stores
under `stores/<part>/` (recipes, combos, materials, parameters, and `comfy_workflows` =
`user/default/workflows` when chosen; the notebooks' legacy-migration marker stays out),
`settings/config.json` without `CIVITAI_API_KEY` plus `download_settings.json`, and for each
model with a user layer or a user cover `models/<id>/user.json` and `cover<suffix>`, listed
in `manifest.json` with type, relative path, size and SHA-256 (from its sidecars; none is
computed). A cover is the user's when it is not byte-identical to `<model>.civitai_bak.*`,
as in the scanner.
`POST /anomalous/backup/inspect` receives the .zip into the temp folder, refuses anything
that is not this plugin's backup, too large, or has an unsafe member path, and reports per
store how many files are new, the same or different, and how many models it finds here.
`POST /anomalous/backup/apply` then writes only the chosen parts: new files are added, the
same skipped, different ones replaced only with `replace` (the file here goes to the
Recycle Bin first). Models are found by SHA-256, else by models folder, path and equal
size. Their user layer is merged field by field (empty fields filled; `replace` takes the
backup's, the old file to the Recycle Bin); the backup's cover replaces a Civitai cover
(whose `civitai_bak` copy stays) but not another user cover unless `replace`. Settings
are written only when chosen, keeping this computer's Civitai key. Export and import are
activity entries (`backup_export`, `backup_import`).
## Model downloads
`api/model_download.py` serves Model Check's downloads. `POST /anomalous/download/lookup`
+1 -1
View File
@@ -41,7 +41,7 @@ node tests/combo_slots.mjs
../../../python_embeded/python.exe -B -m unittest discover -s tests -p "test_*.py"
# 一个 Python 测试文件
../../../python_embeded/python.exe -B -m unittest tests.test_model_download
../../../python_embeded/python.exe -B -m unittest discover -s tests -p "test_model_download.py"
```
## 4. 写新测试
+213
View File
@@ -0,0 +1,213 @@
"""Backups (Settings -> Backup): exporting the user's data and putting it back on another
computer. Two fake computers in temporary folders; nothing outside them is touched."""
import json
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
from contextlib import ExitStack
from pathlib import Path
from unittest import mock
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
from api import backup # noqa: E402
PARTS = ("recipes", "combos", "materials", "parameters", "comfy_workflows")
class Computer:
"""A user folder, a models folder and settings of one computer; `hashes` stands for the scan."""
def __init__(self, root):
self.root = root
self.stores = {part: os.path.join(root, "user", part) for part in PARTS}
self.models = os.path.join(root, "models", "loras")
self.config = os.path.join(root, "config.json")
self.download_settings = os.path.join(root, "download_settings.json")
self.temp = os.path.join(root, "temp")
self.trash = os.path.join(root, "trash")
for path in [*self.stores.values(), self.models, self.temp, self.trash]:
os.makedirs(path, exist_ok=True)
self.hashes = {}
def write(self, path, data):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb" if isinstance(data, bytes) else "w", **({} if isinstance(data, bytes) else {"encoding": "utf-8"})) as handle:
handle.write(data)
return path
def model(self, rel, data=b"weights", sha=""):
path = self.write(os.path.join(self.models, rel), data)
if sha:
self.hashes[os.path.realpath(path)] = sha
return os.path.splitext(path)[0]
def trashed(self):
return sorted(os.listdir(self.trash))
def patches(self):
def to_trash(path):
shutil.move(path, os.path.join(self.trash, os.path.basename(path)))
stack = ExitStack()
stack.enter_context(mock.patch.object(backup, "part_dirs", return_value=self.stores))
stack.enter_context(mock.patch.object(backup, "work_dir", return_value=self.temp))
stack.enter_context(mock.patch.object(backup, "config_path", return_value=self.config))
stack.enter_context(mock.patch.object(backup, "download_settings_path", return_value=self.download_settings))
stack.enter_context(mock.patch.object(backup.folder_paths, "get_folder_paths", side_effect=lambda t: [self.models] if t == "loras" else []))
stack.enter_context(mock.patch.object(backup, "get_metadata", side_effect=lambda p: {"hash": self.hashes.get(os.path.realpath(p), "")}))
stack.enter_context(mock.patch.object(backup, "move_to_trash", side_effect=to_trash))
stack.enter_context(mock.patch.object(backup, "add_entry"))
stack.enter_context(mock.patch.object(backup, "_version", return_value="v-test"))
return stack
class BackupTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Computer(os.path.join(self.tmp.name, "a"))
self.b = Computer(os.path.join(self.tmp.name, "b"))
a = self.a
a.write(os.path.join(a.stores["recipes"], "recipe_1.json"), '{"name": "portrait"}')
a.write(os.path.join(a.stores["materials"], ".assets", "m1", "preview.webp"), b"img")
a.write(os.path.join(a.stores["combos"], "Flux.json"), '{"name": "Flux"}')
a.write(os.path.join(a.stores["combos"], ".legacy_imported.json"), "{}")
a.write(os.path.join(a.stores["comfy_workflows"], "mine.json"), "{}")
a.write(a.config, json.dumps({"CIVITAI_API_KEY": "secret-a", "folder_view_mode": "abstract"}))
base = a.model("Style/cute.safetensors", sha="ab" * 32)
a.write(base + ".anomalous.json", json.dumps({"format": 1, "custom_name": "Cute", "custom_notes": "trigger: cute"}))
a.write(base + ".preview.png", b"my own cover")
civitai = a.model("plain.safetensors", sha="cd" * 32)
a.write(civitai + ".preview.png", b"civitai image")
a.write(civitai + ".civitai_bak.png", b"civitai image")
def tearDown(self):
self.tmp.cleanup()
def export(self, **options):
with self.a.patches():
path, manifest = backup.build_backup(**options)
return path, manifest
def put_back(self, path, **options):
with self.b.patches():
summary = backup.inspect_backup(path)
result = backup.apply_backup(path, **{"parts": PARTS, **options})
return summary, result
def test_export_keeps_stores_and_the_users_own_model_data(self):
path, manifest = self.export()
names = zipfile.ZipFile(path).namelist()
self.assertIn("stores/recipes/recipe_1.json", names)
self.assertIn("stores/materials/.assets/m1/preview.webp", names)
self.assertNotIn("stores/combos/.legacy_imported.json", names) # this computer's own marker
self.assertEqual(manifest["parts"]["comfy_workflows"], 1)
# Only the model with the user's name, notes and cover; a Civitai cover is no user data.
self.assertEqual([m["rel"] for m in manifest["models"]], ["Style/cute.safetensors"])
self.assertEqual(manifest["models"][0]["cover"], ".preview.png")
settings = json.loads(zipfile.ZipFile(path).read("settings/config.json"))
self.assertNotIn("CIVITAI_API_KEY", settings) # the key never leaves the computer
def test_comfy_workflows_can_be_left_out(self):
_path, manifest = self.export(include_comfy=False)
self.assertNotIn("comfy_workflows", manifest["parts"])
def test_a_new_computer_gets_everything_and_models_are_found_by_hash(self):
path, _ = self.export()
b = self.b
base = b.model("Moved/elsewhere/cute_renamed.safetensors", sha="ab" * 32) # moved and renamed
b.write(b.config, json.dumps({"CIVITAI_API_KEY": "secret-b"}))
summary, result = self.put_back(path, settings=True)
self.assertEqual(summary["parts"]["recipes"], {"new": 1, "same": 0, "differs": 0})
self.assertEqual(summary["models"]["matched"], 1)
self.assertEqual(result["added"], 4)
self.assertTrue(os.path.isfile(os.path.join(b.stores["materials"], ".assets", "m1", "preview.webp")))
user = json.loads(Path(base + ".anomalous.json").read_text(encoding="utf-8"))
self.assertEqual(user["custom_name"], "Cute")
self.assertEqual(Path(base + ".preview.png").read_bytes(), b"my own cover")
config = json.loads(Path(b.config).read_text(encoding="utf-8"))
self.assertEqual(config["CIVITAI_API_KEY"], "secret-b") # this computer's key stays
self.assertEqual(config["folder_view_mode"], "abstract")
def test_differing_files_stay_unless_replace_and_then_go_to_the_recycle_bin(self):
path, _ = self.export()
b = self.b
mine = b.write(os.path.join(b.stores["recipes"], "recipe_1.json"), '{"name": "changed here"}')
_summary, kept = self.put_back(path)
self.assertEqual(Path(mine).read_text(encoding="utf-8"), '{"name": "changed here"}')
self.assertEqual(kept["skipped"], 1)
_summary, replaced = self.put_back(path, replace=True)
self.assertEqual(replaced["replaced"], 1)
self.assertEqual(Path(mine).read_text(encoding="utf-8"), '{"name": "portrait"}')
self.assertIn("recipe_1.json", b.trashed())
_summary, again = self.put_back(path, replace=True)
self.assertEqual((again["added"], again["replaced"]), (0, 0)) # all the same now
def test_user_fields_are_filled_where_empty_and_replaced_only_on_request(self):
path, _ = self.export()
base = self.b.model("cute.safetensors", sha="ab" * 32)
self.b.write(base + ".anomalous.json", json.dumps({"format": 1, "custom_name": "Mine"}))
self.put_back(path)
user = json.loads(Path(base + ".anomalous.json").read_text(encoding="utf-8"))
self.assertEqual((user["custom_name"], user["custom_notes"]), ("Mine", "trigger: cute"))
self.put_back(path, replace=True)
user = json.loads(Path(base + ".anomalous.json").read_text(encoding="utf-8"))
self.assertEqual(user["custom_name"], "Cute")
self.assertIn("cute.anomalous.json", self.b.trashed())
def test_the_users_cover_replaces_civitais_but_not_another_user_cover(self):
path, _ = self.export()
b = self.b
base = b.model("cute.safetensors", sha="ab" * 32)
b.write(base + ".png", b"civitai here")
b.write(base + ".civitai_bak.png", b"civitai here")
self.put_back(path)
self.assertEqual(Path(base + ".preview.png").read_bytes(), b"my own cover")
self.assertFalse(os.path.exists(base + ".png"))
self.assertTrue(os.path.exists(base + ".civitai_bak.png")) # Civitai's copy stays
b.write(base + ".preview.png", b"another of mine")
_summary, result = self.put_back(path)
self.assertEqual(result["covers_kept"], 1)
self.assertEqual(Path(base + ".preview.png").read_bytes(), b"another of mine")
def test_without_a_hash_a_model_needs_the_same_path_and_size(self):
path, _ = self.export()
with zipfile.ZipFile(path) as archive:
manifest = json.loads(archive.read("manifest.json"))
manifest["models"][0]["sha256"] = ""
rewritten = os.path.join(self.b.temp, "nohash.zip")
with zipfile.ZipFile(path) as source, zipfile.ZipFile(rewritten, "w") as target:
for info in source.infolist():
data = json.dumps(manifest) if info.filename == "manifest.json" else source.read(info)
target.writestr(info.filename, data)
self.b.model("Style/cute.safetensors", data=b"other size!")
summary, result = self.put_back(rewritten)
self.assertEqual((summary["models"]["matched"], result["models_missing"]), (0, 1))
self.b.model("Style/cute.safetensors", data=b"weights")
summary, _ = self.put_back(rewritten)
self.assertEqual(summary["models"]["matched"], 1)
def test_unsafe_or_foreign_files_are_refused(self):
evil = os.path.join(self.b.temp, "evil.zip")
with zipfile.ZipFile(evil, "w") as archive:
archive.writestr("manifest.json", json.dumps({"app": backup.APP, "format": backup.FORMAT}))
archive.writestr("stores/recipes/../../escape.json", "{}")
with self.b.patches(), self.assertRaises(backup.BackupError):
backup.inspect_backup(evil)
foreign = os.path.join(self.b.temp, "foreign.zip")
with zipfile.ZipFile(foreign, "w") as archive:
archive.writestr("readme.txt", "hello")
with self.b.patches(), self.assertRaises(backup.BackupError):
backup.inspect_backup(foreign)
text = os.path.join(self.b.temp, "text.zip")
Path(text).write_text("not a zip", encoding="utf-8")
with self.b.patches(), self.assertRaises(backup.BackupError):
backup.inspect_backup(text)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -35,6 +35,8 @@ const ACTION_KEYS = {
voice_settings: 'activityVoiceSettings',
voice_storage: 'activityVoiceStorage',
model_download: 'activityModelDownload',
backup_export: 'activityBackupExport',
backup_import: 'activityBackupImport',
};
async function request(url, options) {
+86
View File
@@ -1085,6 +1085,49 @@ export const i18n = {
settingsCheckOnOpen: '打开工作流时检查缺失模型',
settingsCheckOnOpenHelp: '打开或拖进工作流时,缺模型就在画布上方提示,本机有同一个文件的可以一键换上。关掉后,要检查就点左侧的模型检查。',
settingsDownloads: '模型下载',
settingsBackup: '备份',
settingsBackupExport: '导出备份',
settingsBackupExportHelp: '把工作流配方、搭配、素材和存下的提示词、参数方案,以及你给模型改的名字、备注和封面,打成一个压缩包。放到云端硬盘或 U 盘里,换电脑、重装时导回来。',
settingsBackupExportButton: '导出',
settingsBackupImport: '导入备份',
settingsBackupImportHelp: '从导出的压缩包恢复。先列出包里有什么;默认只添加这台电脑上没有的,不会删掉任何东西。',
settingsBackupImportButton: '选择文件',
backupExportTitle: '导出备份',
backupExportIntro: '工作流配方、搭配、素材和存下的提示词、参数方案都会带上,还有插件设置。',
backupExportModels: '模型的名字、备注和自选封面',
backupExportModelsHint: '换了电脑也能按文件哈希对回模型。扫描得来的 C 站信息不用带,重新扫描就有。',
backupExportComfyHint: 'ComfyUI 工作流菜单里保存的那些(user/default/workflows)。',
backupExportKey: 'C 站 API 密钥不会放进备份。',
backupExportStart: '导出',
backupExporting: '正在打包…',
backupExported: '已经下载:{name}({size})。把它放到云端硬盘或 U 盘里保存。',
backupFailed: '没有成功:{error}',
backupImportTitle: '导入备份',
backupReading: '正在读取 {name}…',
backupFrom: '{name}:做于 {date},插件版本 {version}。选要导入的部分:',
backupPart_recipes: '工作流配方',
backupPart_combos: '搭配',
backupPart_materials: '素材和存下的提示词',
backupPart_parameters: '参数方案',
backupPart_comfy_workflows: 'ComfyUI 自己保存的工作流',
backupCounts: '新的 {new} 个 · 相同 {same} 个 · 不同 {differs} 个',
backupModels: '模型的名字、备注和封面:这台电脑上找到 {matched} / {total} 个对应的模型',
backupModelsMissing: '没找到的 {count} 个(可能还没下载):{names}',
backupSettings: '插件设置',
backupSettingsHint: '显示哪些模型文件夹、下载位置等。会换掉这台电脑现在的设置;C 站密钥保留这台电脑的。',
backupNothing: '这份备份里没有可以导入的东西。',
backupModeTitle: '遇到这台电脑上也有、但内容不同的文件时',
backupModeKeep: '保留这台电脑上的(推荐)',
backupModeReplace: '换成备份里的,这台电脑上的放进回收站',
backupImportStart: '导入',
backupImporting: '正在导入…',
backupDone: '添加了 {added} 个文件,换掉 {replaced} 个,跳过 {skipped} 个(相同的,或保留了这台电脑上的)。',
backupDoneModels: '模型:写回 {model_notes} 个模型的名字和备注、{model_covers} 张封面;保留这台电脑上自选封面 {covers_kept} 张;{models_missing} 个模型这台电脑上没有。',
backupDoneSettings: '插件设置已经换成备份里的。',
backupFailedItems: '这些没有处理:',
backupDoneHint: '重新打开对应的页面就能看到。换掉的文件在回收站里。',
activityBackupExport: '导出备份',
activityBackupImport: '导入备份',
settingsDownloadPlace: '缺失的模型下载到',
settingsDownloadPlaceHelp: '“工作流写的位置”:按工作流里记的子文件夹放,下完节点不用改;工作流没写子文件夹的,放进下面这个文件夹。“固定文件夹”:总是放进下面这个文件夹,下完自动把节点改到新位置(可以 Ctrl+Z)。每次下载前,确认框里还能改。',
settingsDownloadPlaceWorkflow: '工作流写的位置',
@@ -3143,6 +3186,49 @@ export const i18n = {
settingsCheckOnOpen: 'Check for missing models when a workflow opens',
settingsCheckOnOpenHelp: 'When you open or drop in a workflow, a bar over the canvas says which models are missing and puts in the ones this computer has under another name in one press. Turned off, run Model Check from the rail when you want it.',
settingsDownloads: 'Model downloads',
settingsBackup: 'Backup',
settingsBackupExport: 'Export a backup',
settingsBackupExportHelp: 'Workflow Recipes, combos, materials and saved prompts, parameter sets, and the names, notes and covers you gave your models, in one .zip. Keep it on a cloud drive or a USB stick and bring it back on a new computer.',
settingsBackupExportButton: 'Export',
settingsBackupImport: 'Import a backup',
settingsBackupImportHelp: 'Puts an exported .zip back. It first lists what is inside; by default only what this computer lacks is added, and nothing is deleted.',
settingsBackupImportButton: 'Choose file',
backupExportTitle: 'Export a backup',
backupExportIntro: 'Workflow Recipes, combos, materials and saved prompts, parameter sets and the plugin settings are included.',
backupExportModels: "Your models' names, notes and own covers",
backupExportModelsHint: 'Found again by file hash on another computer. What a scan fetched from Civitai is not needed: scan again.',
backupExportComfyHint: "The workflows saved in ComfyUI's workflow menu (user/default/workflows).",
backupExportKey: 'The Civitai API key is never put in a backup.',
backupExportStart: 'Export',
backupExporting: 'Packing…',
backupExported: 'Downloaded: {name} ({size}). Keep it on a cloud drive or a USB stick.',
backupFailed: 'That did not work: {error}',
backupImportTitle: 'Import a backup',
backupReading: 'Reading {name}…',
backupFrom: '{name}: made {date}, plugin version {version}. Choose what to import:',
backupPart_recipes: 'Workflow Recipes',
backupPart_combos: 'Combos',
backupPart_materials: 'Materials and saved prompts',
backupPart_parameters: 'Parameter sets',
backupPart_comfy_workflows: "ComfyUI's own saved workflows",
backupCounts: '{new} new · {same} the same · {differs} different',
backupModels: "Models' names, notes and covers: {matched} of {total} models found on this computer",
backupModelsMissing: '{count} not found (maybe not downloaded yet): {names}',
backupSettings: 'Plugin settings',
backupSettingsHint: "Which model folders are shown, where downloads go and so on. Replaces this computer's settings; this computer's Civitai key stays.",
backupNothing: 'There is nothing to import in this backup.',
backupModeTitle: 'When this computer has a different file of the same name',
backupModeKeep: "Keep this computer's (recommended)",
backupModeReplace: "Take the backup's; this computer's goes to the Recycle Bin",
backupImportStart: 'Import',
backupImporting: 'Importing…',
backupDone: '{added} files added, {replaced} replaced, {skipped} skipped (the same, or this computer\'s kept).',
backupDoneModels: "Models: {model_notes} names and notes and {model_covers} covers put back; {covers_kept} of this computer's own covers kept; {models_missing} models are not on this computer.",
backupDoneSettings: "The plugin settings are now the backup's.",
backupFailedItems: 'Not done:',
backupDoneHint: 'Open the pages again to see them. Replaced files are in the Recycle Bin.',
activityBackupExport: 'Exported a backup',
activityBackupImport: 'Imported a backup',
settingsDownloadPlace: 'Download missing models to',
settingsDownloadPlaceHelp: '"Where the workflow says": into the subfolder the workflow names, so the node needs no change; a model without one goes into the folder below. "A set folder": always into the folder below, and the node is changed to the new place afterwards (Ctrl+Z undoes it). The dialog before each download can still change it.',
settingsDownloadPlaceWorkflow: 'Where the workflow says',
+217
View File
@@ -0,0 +1,217 @@
/**
* Settings → Backup (api/backup.py): exporting the user's data as one .zip, and putting a
* backup back. Export asks what to include and downloads the file. Import uploads the chosen
* .zip, shows what is in it next to what this computer has (per store, the models found by
* hash), lets the user pick the parts and whether differing files are replaced, then puts it
* back and says what happened. Nothing is erased: replaced files go to the Recycle Bin.
*/
import { translate as t } from './locales.js';
import { formatSize } from './ui_model_download.js';
const PARTS = ['recipes', 'combos', 'materials', 'parameters', 'comfy_workflows'];
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className, label, onClick) {
const node = el('button', className, label);
node.type = 'button';
node.onclick = onClick;
return node;
}
function checkbox(label, checked, hint = '') {
const line = el('label', 'anomalous-backup-choice');
const box = el('input');
box.type = 'checkbox';
box.checked = checked;
const copy = el('span', 'anomalous-backup-choice-copy');
copy.append(el('span', '', label));
if (hint) copy.append(el('small', 'anomalous-backup-note', hint));
line.append(box, copy);
return { line, box };
}
/** A dialog over the page; Esc or a click beside it closes it. Returns { dialog, footer, close }. */
function openDialog(title) {
const overlay = el('div', 'anomalous-dialog-overlay anomalous-backup-overlay');
const dialog = el('div', 'anomalous-backup-dialog');
dialog.setAttribute('role', 'dialog');
const body = el('div', 'anomalous-backup-body');
const footer = el('div', 'anomalous-backup-footer');
dialog.append(el('h3', 'anomalous-backup-title', title), body, footer);
overlay.append(dialog);
const onKey = (event) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
close();
};
const close = () => {
overlay.remove();
document.removeEventListener('keydown', onKey, true);
};
overlay.onclick = (event) => { if (event.target === overlay) close(); };
document.addEventListener('keydown', onKey, true);
document.body.append(overlay);
return { dialog: body, footer, close };
}
async function postJson(url, body) {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await response.json().catch(() => ({}));
if (!response.ok || data.status === 'error') throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
function download(name) {
const link = el('a');
link.href = `/anomalous/backup/file?name=${encodeURIComponent(name)}`;
link.download = name;
document.body.append(link);
link.click();
link.remove();
}
/** Export: what to include, then the .zip is packed and downloaded. */
export function exportBackup() {
const { dialog, footer, close } = openDialog(t('backupExportTitle'));
const models = checkbox(t('backupExportModels'), true, t('backupExportModelsHint'));
const comfy = checkbox(t('backupPart_comfy_workflows'), true, t('backupExportComfyHint'));
const status = el('p', 'anomalous-backup-note');
dialog.append(el('p', 'anomalous-backup-text', t('backupExportIntro')), models.line, comfy.line,
el('p', 'anomalous-backup-note', t('backupExportKey')), status);
const go = button('anomalous-scan-primary', t('backupExportStart'), async () => {
go.disabled = true;
status.classList.remove('is-bad');
status.textContent = t('backupExporting');
try {
const data = await postJson('/anomalous/backup/export', { models: models.box.checked, comfy_workflows: comfy.box.checked });
download(data.name);
status.textContent = t('backupExported', { name: data.name, size: formatSize(data.size) });
go.remove();
cancel.textContent = t('dialogOk');
} catch (error) {
status.textContent = t('backupFailed', { error: error.message });
status.classList.add('is-bad');
go.disabled = false;
}
});
const cancel = button('anomalous-scan-secondary', t('dialogCancel'), close);
footer.append(cancel, go);
go.focus();
}
/** Import: pick the .zip, see what is in it, choose, put it back. */
export function importBackup(owner) {
const input = el('input');
input.type = 'file';
input.accept = '.zip,application/zip';
input.onchange = () => {
const file = input.files?.[0];
if (file) void inspect(owner, file);
};
input.click();
}
async function inspect(owner, file) {
const { dialog, footer, close } = openDialog(t('backupImportTitle'));
const status = el('p', 'anomalous-backup-note', t('backupReading', { name: file.name }));
dialog.append(status);
footer.append(button('anomalous-scan-secondary', t('dialogCancel'), close));
let summary;
try {
const form = new FormData();
form.append('file', file, file.name);
const response = await fetch('/anomalous/backup/inspect', { method: 'POST', body: form });
summary = await response.json().catch(() => ({}));
if (!response.ok || summary.status === 'error') throw new Error(summary.error || `HTTP ${response.status}`);
} catch (error) {
status.textContent = t('backupFailed', { error: error.message });
status.classList.add('is-bad');
return;
}
showChoices(owner, file.name, summary, { dialog, footer, close });
}
function showChoices(owner, name, summary, { dialog, footer, close }) {
const created = summary.created ? new Date(summary.created * 1000).toLocaleString() : '?';
dialog.replaceChildren(el('p', 'anomalous-backup-text', t('backupFrom', { name, date: created, version: summary.version || '?' })));
const parts = PARTS.filter(part => summary.parts?.[part]).map((part) => {
const counts = summary.parts[part];
const choice = checkbox(t(`backupPart_${part}`), counts.new + counts.differs > 0, t('backupCounts', counts));
dialog.append(choice.line);
return { part, box: choice.box };
});
let models = null;
if (summary.models?.total) {
const { matched, total, missing } = summary.models;
const hint = missing.length
? t('backupModelsMissing', { names: missing.slice(0, 5).map(rel => rel.split('/').pop()).join('、'), count: missing.length })
: '';
models = checkbox(t('backupModels', { matched, total }), matched > 0, hint);
dialog.append(models.line);
}
const settings = summary.settings ? checkbox(t('backupSettings'), false, t('backupSettingsHint')) : null;
if (settings) dialog.append(settings.line);
if (!parts.length && !models && !settings) dialog.append(el('p', 'anomalous-backup-note', t('backupNothing')));
const mode = el('div', 'anomalous-backup-mode');
const radio = (value, label, checked) => {
const line = el('label', 'anomalous-backup-choice');
const input = el('input');
input.type = 'radio';
input.name = 'anomalous-backup-mode';
input.value = value;
input.checked = checked;
line.append(input, el('span', '', label));
mode.append(line);
return input;
};
radio('keep', t('backupModeKeep'), true);
const replace = radio('replace', t('backupModeReplace'), false);
const status = el('p', 'anomalous-backup-note');
dialog.append(el('h4', 'anomalous-backup-subtitle', t('backupModeTitle')), mode, status);
footer.replaceChildren();
const go = button('anomalous-scan-primary', t('backupImportStart'), async () => {
go.disabled = true;
status.classList.remove('is-bad');
status.textContent = t('backupImporting');
try {
const result = await postJson('/anomalous/backup/apply', {
token: summary.token, name,
parts: parts.filter(item => item.box.checked).map(item => item.part),
models: Boolean(models?.box.checked), settings: Boolean(settings?.box.checked), replace: replace.checked,
});
showResult(dialog, result);
go.remove();
cancel.textContent = t('dialogOk');
if (result.model_notes || result.model_covers || result.settings) owner?.loadModels?.();
} catch (error) {
status.textContent = t('backupFailed', { error: error.message });
status.classList.add('is-bad');
go.disabled = false;
}
});
const cancel = button('anomalous-scan-secondary', t('dialogCancel'), close);
footer.append(cancel, go);
}
function showResult(dialog, result) {
const lines = [t('backupDone', result)];
if (result.model_notes || result.model_covers || result.covers_kept || result.models_missing) lines.push(t('backupDoneModels', result));
if (result.settings) lines.push(t('backupDoneSettings'));
dialog.replaceChildren(...lines.map(line => el('p', 'anomalous-backup-text', line)));
if (result.failed?.length) {
dialog.append(el('p', 'anomalous-backup-note is-bad', t('backupFailedItems')));
const list = el('ul', 'anomalous-backup-failed');
list.append(...result.failed.slice(0, 20).map(item => el('li', '', item)));
dialog.append(list);
}
dialog.append(el('p', 'anomalous-backup-note', t('backupDoneHint')));
}
+11 -1
View File
@@ -2,7 +2,7 @@
* The settings page (the rail's gear): one card per subject — how the browser looks,
* model cards and what they cost in memory (with the card image cache), model folders
* (a view of its own, ui_folder_manager.js, `owner.settingsView === 'folders'`),
* workflows, where Model Check's downloads go, how the browser opens, and help. A tool page like the scan page, with a
* workflows, where Model Check's downloads go, backups (ui_backup.js), how the browser opens, and help. A tool page like the scan page, with a
* way back to the page you came from. Display preferences go through `owner.displayPrefs`
* (ui_settings_hub.js); language, theme and opening mode are ComfyUI settings.
*/
@@ -19,6 +19,7 @@ import { openFeedbackDialog } from './ui_feedback_dialog.js';
import { checkOnOpen, setCheckOnOpen } from './ui_doctor_banner.js';
import { fetchDownloadSettings, hfMirrorOn, saveDownloadSettings } from './model_download.js';
import { saveApiKey } from './ui_scan_page.js';
import { exportBackup, importBackup } from './ui_backup.js';
const t = (key, params) => translate(key, params);
// The full written guide: the README, at its Chinese half for Chinese.
@@ -224,6 +225,14 @@ function downloadGroup(owner, redraw) {
return box;
}
/** One .zip of the user's data to keep anywhere, and putting one back (ui_backup.js). */
function backupGroup(owner) {
const small = (label, onClick) => button('anomalous-scan-secondary anomalous-scan-small-btn', label, onClick);
return group('settingsBackup',
row('settingsBackupExport', 'settingsBackupExportHelp', small(t('settingsBackupExportButton'), () => exportBackup())),
row('settingsBackupImport', 'settingsBackupImportHelp', small(t('settingsBackupImportButton'), () => importBackup(owner))));
}
function openingGroup(owner, redraw) {
const mode = comfySetting(ENTRY_MODE_SETTING_ID) || 'floating';
return group('settingsOpening',
@@ -284,6 +293,7 @@ function render(owner) {
foldersGroup(owner),
workflowGroup(owner),
downloadGroup(owner, redraw),
backupGroup(owner),
openingGroup(owner, redraw),
helpGroup(owner),
);
+1
View File
@@ -22,3 +22,4 @@
@import url("./styles/21-prompt-studio.css?v=20261004-draftx-1");
@import url("./styles/22-model-download.css?v=20261006-download-1");
@import url("./styles/23-combo-structure.css?v=20261006-combo-1");
@import url("./styles/24-backup.css?v=20261006-backup-1");
+105
View File
@@ -0,0 +1,105 @@
/* Settings -> Backup: the export and import dialogs (ui_backup.js). */
.anomalous-backup-overlay {
position: fixed;
inset: 0;
z-index: 1000001;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
.anomalous-backup-dialog {
display: flex;
flex-direction: column;
gap: 14px;
width: min(560px, 100%);
max-height: 90vh;
padding: 22px 24px;
box-sizing: border-box;
border: 1px solid var(--amb-border-strong);
border-radius: 16px;
background: var(--amb-bg-panel, #24252b);
color: var(--amb-text-main);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55);
}
.anomalous-backup-title {
margin: 0;
font-size: 1.1rem;
}
.anomalous-backup-subtitle {
margin: 4px 0 0;
color: var(--amb-text-muted);
font-size: 12.5px;
font-weight: 600;
}
.anomalous-backup-body {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
overflow-y: auto;
}
.anomalous-backup-text {
margin: 0;
font-size: 13px;
line-height: 1.55;
}
.anomalous-backup-note {
margin: 0;
color: var(--amb-text-muted);
font-size: 12px;
line-height: 1.5;
}
.anomalous-backup-note.is-bad { color: #fca5a5; }
.anomalous-backup-choice {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: 13px;
cursor: pointer;
}
.anomalous-backup-choice input { margin-top: 3px; }
.anomalous-backup-choice-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.anomalous-backup-mode {
display: flex;
flex-direction: column;
gap: 6px;
}
.anomalous-backup-failed {
margin: 0;
padding-left: 18px;
color: var(--amb-text-muted);
font-size: 12px;
word-break: break-all;
}
.anomalous-backup-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
@media (max-width: 560px) {
.anomalous-backup-dialog { padding: 16px; }
}