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:
co-authored by
Claude Opus 5.5
parent
7bb46ba82f
commit
797a52105e
+8
-2
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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`
|
||||
|
||||
@@ -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. 写新测试
|
||||
|
||||
@@ -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()
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user