e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
68 lines
2.2 KiB
Python
68 lines
2.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
import threading
|
|
import time
|
|
import uuid
|
|
|
|
from deeptutor.learning.models import LearningProgress
|
|
from deeptutor.services.path_service import get_path_service
|
|
|
|
# Module-level lock so CAS semantics hold across all store instances.
|
|
_cas_lock = threading.Lock()
|
|
|
|
|
|
def _atomic_write_text(path: Path, text: str) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(path.suffix + f".tmp.{uuid.uuid4().hex}")
|
|
try:
|
|
tmp.write_text(text, encoding="utf-8")
|
|
tmp.replace(path)
|
|
except BaseException:
|
|
# Don't leave an orphaned temp file behind on write/replace failure.
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
class LearningStore:
|
|
def __init__(self, root: Path | None = None) -> None:
|
|
self._root = root or (get_path_service().get_workspace_dir() / "learning")
|
|
self._root.mkdir(parents=True, exist_ok=True)
|
|
|
|
def _path(self, book_id: str) -> Path:
|
|
if "/" in book_id or "\\" in book_id or ".." in book_id or ":" in book_id:
|
|
raise ValueError(f"Invalid book_id: {book_id!r}")
|
|
return self._root / f"{book_id}.json"
|
|
|
|
def save(self, progress: LearningProgress) -> None:
|
|
with _cas_lock:
|
|
progress.updated_at = time.time()
|
|
progress.version += 1
|
|
data = progress.model_dump(mode="json")
|
|
text = json.dumps(data, ensure_ascii=False, indent=2)
|
|
_atomic_write_text(self._path(progress.book_id), text)
|
|
|
|
def load(self, book_id: str) -> LearningProgress | None:
|
|
path = self._path(book_id)
|
|
if not path.exists():
|
|
return None
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
return LearningProgress.model_validate(data)
|
|
|
|
def delete(self, book_id: str) -> None:
|
|
with _cas_lock:
|
|
path = self._path(book_id)
|
|
if path.exists():
|
|
path.unlink()
|
|
|
|
def exists(self, book_id: str) -> bool:
|
|
return self._path(book_id).exists()
|
|
|
|
def list_all(self) -> list[str]:
|
|
"""Return all book_ids that have stored progress."""
|
|
return sorted(p.stem for p in self._root.glob("*.json") if not p.name.startswith("."))
|
|
|
|
|
|
__all__ = ["LearningStore"]
|