mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
fix: restore backup import after skills migration (#2980)
This commit is contained in:
+56
-12
@@ -101,24 +101,68 @@ def setup_backup_routes(memory_manager, preset_manager, skills_manager) -> APIRo
|
|||||||
# ── Skills ──
|
# ── Skills ──
|
||||||
if "skills" in body and isinstance(body["skills"], list):
|
if "skills" in body and isinstance(body["skills"], list):
|
||||||
existing = skills_manager.load_all()
|
existing = skills_manager.load_all()
|
||||||
existing_ids = {s.get("id") for s in existing}
|
existing_names = {s.get("name") for s in existing if s.get("name")}
|
||||||
existing_titles = {s.get("title", "").strip().lower() for s in existing}
|
existing_ids = {s.get("id") for s in existing if s.get("id")}
|
||||||
|
existing_titles = {
|
||||||
|
(s.get("title") or s.get("description") or "").strip().lower()
|
||||||
|
for s in existing
|
||||||
|
}
|
||||||
added = 0
|
added = 0
|
||||||
for skill in body["skills"]:
|
for skill in body["skills"]:
|
||||||
if not isinstance(skill, dict) or not skill.get("title"):
|
if not isinstance(skill, dict):
|
||||||
continue
|
continue
|
||||||
# Skip if same id or same title already exists
|
title = (
|
||||||
if skill.get("id") in existing_ids:
|
skill.get("title") or skill.get("description")
|
||||||
|
or skill.get("name") or ""
|
||||||
|
).strip()
|
||||||
|
if not title:
|
||||||
continue
|
continue
|
||||||
if skill["title"].strip().lower() in existing_titles:
|
sid = skill.get("id") or skill.get("name")
|
||||||
|
if sid and sid in existing_ids:
|
||||||
continue
|
continue
|
||||||
if user and not skill.get("owner"):
|
nm = skill.get("name")
|
||||||
skill["owner"] = user
|
if nm and nm in existing_names:
|
||||||
existing.append(skill)
|
continue
|
||||||
existing_ids.add(skill.get("id"))
|
if title.lower() in existing_titles:
|
||||||
existing_titles.add(skill["title"].strip().lower())
|
continue
|
||||||
|
owner = skill.get("owner")
|
||||||
|
if user and not owner:
|
||||||
|
owner = user
|
||||||
|
# Skills live on disk as SKILL.md files; the old JSON-era
|
||||||
|
# skills_manager.save() no longer exists. Write each new skill
|
||||||
|
# via add_skill (source="user" skips auto-dedup — this is an
|
||||||
|
# explicit backup restore).
|
||||||
|
result = skills_manager.add_skill(
|
||||||
|
title=title,
|
||||||
|
name=skill.get("name"),
|
||||||
|
description=skill.get("description"),
|
||||||
|
problem=skill.get("problem", ""),
|
||||||
|
solution=skill.get("solution", ""),
|
||||||
|
steps=skill.get("steps"),
|
||||||
|
tags=skill.get("tags"),
|
||||||
|
source="user",
|
||||||
|
teacher_model=skill.get("teacher_model"),
|
||||||
|
confidence=skill.get("confidence", 0.8),
|
||||||
|
owner=owner,
|
||||||
|
category=skill.get("category", "general"),
|
||||||
|
when_to_use=skill.get("when_to_use"),
|
||||||
|
procedure=skill.get("procedure"),
|
||||||
|
pitfalls=skill.get("pitfalls"),
|
||||||
|
verification=skill.get("verification"),
|
||||||
|
platforms=skill.get("platforms"),
|
||||||
|
requires_toolsets=skill.get("requires_toolsets"),
|
||||||
|
fallback_for_toolsets=skill.get("fallback_for_toolsets"),
|
||||||
|
status=skill.get("status", "draft"),
|
||||||
|
version=skill.get("version", "1.0.0"),
|
||||||
|
)
|
||||||
|
if result.get("_deduped"):
|
||||||
|
continue
|
||||||
|
if result.get("name"):
|
||||||
|
existing_names.add(result["name"])
|
||||||
|
if result.get("id"):
|
||||||
|
existing_ids.add(result["id"])
|
||||||
|
existing_titles.add(title.lower())
|
||||||
added += 1
|
added += 1
|
||||||
skills_manager.save(existing)
|
|
||||||
imported.append(f"{added} skills")
|
imported.append(f"{added} skills")
|
||||||
|
|
||||||
# ── Presets ──
|
# ── Presets ──
|
||||||
|
|||||||
+11
-3
@@ -2120,14 +2120,22 @@ function initBackup() {
|
|||||||
const btn = el('adm-importDataBtn');
|
const btn = el('adm-importDataBtn');
|
||||||
btn.disabled = true; btn.textContent = 'Importing...'; msg.textContent = '';
|
btn.disabled = true; btn.textContent = 'Importing...'; msg.textContent = '';
|
||||||
try {
|
try {
|
||||||
const text = await file.text();
|
const text = (await file.text()).replace(/^\uFEFF/, '').trim();
|
||||||
const data = JSON.parse(text);
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('Invalid backup file: ' + e.message);
|
||||||
|
}
|
||||||
const res = await fetch('/api/import', {
|
const res = await fetch('/api/import', {
|
||||||
method: 'POST', credentials: 'same-origin',
|
method: 'POST', credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json().catch(() => null);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error(`Import failed: server returned ${res.status}`);
|
||||||
|
}
|
||||||
if (res.ok && result.ok) {
|
if (res.ok && result.ok) {
|
||||||
msg.textContent = result.message || 'Import successful.'; msg.className = 'admin-success';
|
msg.textContent = result.message || 'Import successful.'; msg.className = 'admin-success';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Backup import must not call the removed skills_manager.save().
|
||||||
|
|
||||||
|
Skills migrated from data/skills.json to on-disk SKILL.md files; save() was
|
||||||
|
removed from SkillsManager. Import still always sees a ``skills`` key in
|
||||||
|
exported backups (often ``[]``), so calling save() raised AttributeError,
|
||||||
|
returned a 500 HTML page, and the UI reported a misleading JSON.parse error
|
||||||
|
from res.json().
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import routes.backup_routes as br
|
||||||
|
|
||||||
|
|
||||||
|
class _Req:
|
||||||
|
def __init__(self, body):
|
||||||
|
self._body = body
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(monkeypatch, skills_manager):
|
||||||
|
monkeypatch.setattr(br, "require_admin", lambda request: None)
|
||||||
|
monkeypatch.setattr(br, "get_current_user", lambda request: "alice")
|
||||||
|
|
||||||
|
mem = MagicMock()
|
||||||
|
mem.load_all.return_value = []
|
||||||
|
mem.save.return_value = None
|
||||||
|
|
||||||
|
presets = MagicMock()
|
||||||
|
presets.get_all.return_value = {}
|
||||||
|
presets.save.return_value = True
|
||||||
|
|
||||||
|
router = br.setup_backup_routes(mem, presets, skills_manager)
|
||||||
|
endpoint = None
|
||||||
|
for r in router.routes:
|
||||||
|
if r.path == "/api/import" and "POST" in getattr(r, "methods", set()):
|
||||||
|
endpoint = r.endpoint
|
||||||
|
assert endpoint is not None
|
||||||
|
return endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_with_empty_skills_list_does_not_call_save(monkeypatch):
|
||||||
|
skills = MagicMock(spec=["load_all", "add_skill"])
|
||||||
|
skills.load_all.return_value = []
|
||||||
|
endpoint = _setup(monkeypatch, skills)
|
||||||
|
|
||||||
|
body = {"settings": {"foo": "bar"}, "skills": []}
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
m.setattr(br, "load_settings", lambda: {})
|
||||||
|
m.setattr(br, "save_settings", lambda s: None)
|
||||||
|
result = asyncio.run(endpoint(_Req(body)))
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
skills.add_skill.assert_not_called()
|
||||||
|
assert not hasattr(skills, "save") or not getattr(skills, "save", MagicMock()).called
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_adds_new_skill_via_add_skill(monkeypatch):
|
||||||
|
skills = MagicMock(spec=["load_all", "add_skill"])
|
||||||
|
skills.load_all.return_value = []
|
||||||
|
skills.add_skill.return_value = {
|
||||||
|
"id": "buy-milk",
|
||||||
|
"name": "buy-milk",
|
||||||
|
"title": "Buy milk",
|
||||||
|
}
|
||||||
|
endpoint = _setup(monkeypatch, skills)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"skills": [{"name": "buy-milk", "title": "Buy milk", "description": "Buy milk"}],
|
||||||
|
"preferences": {"theme": "dark"},
|
||||||
|
}
|
||||||
|
with monkeypatch.context() as m:
|
||||||
|
m.setattr(br, "load_settings", lambda: {})
|
||||||
|
m.setattr(br, "save_settings", lambda s: None)
|
||||||
|
m.setattr(br, "load_features", lambda: {})
|
||||||
|
m.setattr(br, "save_features", lambda f: None)
|
||||||
|
m.setattr(
|
||||||
|
"routes.prefs_routes._load_for_user",
|
||||||
|
lambda user: {},
|
||||||
|
)
|
||||||
|
m.setattr(
|
||||||
|
"routes.prefs_routes._save_for_user",
|
||||||
|
lambda user, prefs: None,
|
||||||
|
)
|
||||||
|
result = asyncio.run(endpoint(_Req(body)))
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
skills.add_skill.assert_called_once()
|
||||||
|
assert skills.add_skill.call_args.kwargs.get("source") == "user"
|
||||||
Reference in New Issue
Block a user