303 lines
11 KiB
Python
303 lines
11 KiB
Python
"""
|
|
Сервис для работы с документами (иерархическая структура: Книга → Глава → Сцена)
|
|
Документы хранятся в папке проекта: data/projects/{project_id}/documents.json
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
from uuid import uuid4
|
|
|
|
from models.document import DocumentNode
|
|
|
|
from services.project_service import project_service
|
|
|
|
|
|
class DocumentService:
|
|
def __init__(self):
|
|
self.project_service = project_service
|
|
|
|
def _get_documents_file(self, project_id: str, user_id: str) -> Path:
|
|
project_dir = self.project_service.get_project_dir(project_id, user_id)
|
|
return project_dir / "documents.json"
|
|
|
|
def _load_documents(self, project_id: str, user_id: str) -> List[DocumentNode]:
|
|
documents_file = self._get_documents_file(project_id, user_id)
|
|
if documents_file.exists():
|
|
with open(documents_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
# Старый формат: dict, где ключи — ID документов
|
|
if isinstance(data, dict):
|
|
flat_list = list(data.values())
|
|
if flat_list and len(flat_list) > 0:
|
|
return self._migrate_flat_to_hierarchical(
|
|
flat_list, project_id, user_id
|
|
)
|
|
return []
|
|
|
|
# Новый формат: list
|
|
if isinstance(data, list) and len(data) > 0:
|
|
# Миграция: если старые данные без parent_id
|
|
first = data[0]
|
|
if isinstance(first, dict) and "parent_id" not in first:
|
|
return self._migrate_flat_to_hierarchical(
|
|
data, project_id, user_id
|
|
)
|
|
return [DocumentNode(**doc) for doc in data]
|
|
return []
|
|
|
|
def _migrate_flat_to_hierarchical(
|
|
self, old_data: List[Dict], project_id: str, user_id: str
|
|
) -> List[DocumentNode]:
|
|
"""Миграция плоских документов в иерархию"""
|
|
book = DocumentNode(
|
|
project_id=project_id,
|
|
title="Книга 1",
|
|
type="book",
|
|
order=0,
|
|
)
|
|
|
|
chapter = DocumentNode(
|
|
project_id=project_id,
|
|
parent_id=book.id,
|
|
title="Глава 1",
|
|
type="chapter",
|
|
order=0,
|
|
)
|
|
|
|
scenes = []
|
|
for i, old_doc in enumerate(old_data):
|
|
scene = DocumentNode(
|
|
id=old_doc.get("id", str(uuid4())),
|
|
project_id=project_id,
|
|
parent_id=chapter.id,
|
|
title=old_doc.get("title", f"Сцена {i + 1}"),
|
|
content=old_doc.get("content", ""),
|
|
type="scene",
|
|
order=i,
|
|
)
|
|
scenes.append(scene)
|
|
|
|
result = [book, chapter] + scenes
|
|
self._save_documents(project_id, result, user_id)
|
|
print(f"Migrated {len(old_data)} flat documents to hierarchical structure")
|
|
return result
|
|
|
|
def _save_documents(
|
|
self, project_id: str, documents: List[DocumentNode], user_id: str
|
|
):
|
|
documents_file = self._get_documents_file(project_id, user_id)
|
|
with open(documents_file, "w", encoding="utf-8") as f:
|
|
json.dump(
|
|
[doc.model_dump() for doc in documents],
|
|
f,
|
|
ensure_ascii=False,
|
|
indent=2,
|
|
)
|
|
|
|
def get_documents_tree(self, project_id: str, user_id: str) -> List[Dict[str, Any]]:
|
|
"""Получить документы в виде дерева"""
|
|
documents = self._load_documents(project_id, user_id)
|
|
return self._build_tree(documents)
|
|
|
|
def _build_tree(self, documents: List[DocumentNode]) -> List[Dict[str, Any]]:
|
|
"""Строит дерево из плоского списка"""
|
|
doc_map = {doc.id: {**doc.model_dump(), "children": []} for doc in documents}
|
|
roots = []
|
|
|
|
for doc in documents:
|
|
node = doc_map[doc.id]
|
|
if doc.parent_id is None:
|
|
roots.append(node)
|
|
elif doc.parent_id in doc_map:
|
|
doc_map[doc.parent_id]["children"].append(node)
|
|
|
|
def sort_tree(nodes):
|
|
nodes.sort(key=lambda x: x["order"])
|
|
for node in nodes:
|
|
sort_tree(node["children"])
|
|
|
|
sort_tree(roots)
|
|
return roots
|
|
|
|
def get_all_documents(self, project_id: str, user_id: str) -> List[DocumentNode]:
|
|
"""Получить все документы (плоский список)"""
|
|
return self._load_documents(project_id, user_id)
|
|
|
|
def get_document(
|
|
self, project_id: str, document_id: str, user_id: str
|
|
) -> Optional[DocumentNode]:
|
|
documents = self._load_documents(project_id, user_id)
|
|
for doc in documents:
|
|
if doc.id == document_id:
|
|
return doc
|
|
return None
|
|
|
|
def create_document(
|
|
self,
|
|
project_id: str,
|
|
title: str,
|
|
doc_type: str,
|
|
user_id: str,
|
|
parent_id: Optional[str] = None,
|
|
content: str = "",
|
|
) -> DocumentNode:
|
|
"""Создать новый документ с валидацией иерархии"""
|
|
documents = self._load_documents(project_id, user_id)
|
|
|
|
# Валидация иерархии
|
|
if doc_type == "chapter" and parent_id:
|
|
parent = next((d for d in documents if d.id == parent_id), None)
|
|
if not parent or parent.type != "book":
|
|
raise ValueError("Глава может быть создана только внутри книги")
|
|
|
|
if doc_type == "scene" and parent_id:
|
|
parent = next((d for d in documents if d.id == parent_id), None)
|
|
if not parent or parent.type != "chapter":
|
|
raise ValueError("Сцена может быть создана только внутри главы")
|
|
|
|
if doc_type == "book" and parent_id:
|
|
raise ValueError("Книга не может иметь родителя")
|
|
|
|
# Вычисляем order (последний среди братьев + 1)
|
|
siblings = [d for d in documents if d.parent_id == parent_id]
|
|
max_order = max([s.order for s in siblings], default=-1)
|
|
|
|
document = DocumentNode(
|
|
project_id=project_id,
|
|
parent_id=parent_id,
|
|
title=title,
|
|
content=content,
|
|
type=doc_type,
|
|
order=max_order + 1,
|
|
)
|
|
|
|
documents.append(document)
|
|
self._save_documents(project_id, documents, user_id)
|
|
return document
|
|
|
|
def update_document(
|
|
self,
|
|
project_id: str,
|
|
document_id: str,
|
|
user_id: str,
|
|
title: Optional[str] = None,
|
|
content: Optional[str] = None,
|
|
parent_id: Optional[str] = None,
|
|
order: Optional[int] = None,
|
|
) -> Optional[DocumentNode]:
|
|
"""Обновить документ (включая перенос)"""
|
|
documents = self._load_documents(project_id, user_id)
|
|
|
|
for i, doc in enumerate(documents):
|
|
if doc.id == document_id:
|
|
# Валидация при переносе
|
|
if parent_id is not None and parent_id != doc.parent_id:
|
|
new_parent = next((d for d in documents if d.id == parent_id), None)
|
|
|
|
if doc.type == "book":
|
|
raise ValueError("Книга не может быть перемещена")
|
|
|
|
if doc.type == "chapter" and (
|
|
not new_parent or new_parent.type != "book"
|
|
):
|
|
raise ValueError("Глава может находиться только внутри книги")
|
|
|
|
if doc.type == "scene" and (
|
|
not new_parent or new_parent.type != "chapter"
|
|
):
|
|
raise ValueError("Сцена может находиться только внутри главы")
|
|
|
|
documents[i].parent_id = parent_id
|
|
|
|
if title is not None:
|
|
documents[i].title = title
|
|
if content is not None:
|
|
documents[i].content = content
|
|
if order is not None:
|
|
documents[i].order = order
|
|
|
|
documents[i].updated_at = datetime.now().isoformat()
|
|
self._save_documents(project_id, documents, user_id)
|
|
return documents[i]
|
|
|
|
return None
|
|
|
|
def delete_document(self, project_id: str, document_id: str, user_id: str) -> bool:
|
|
"""Удалить документ с каскадом (дети тоже удаляются)"""
|
|
documents = self._load_documents(project_id, user_id)
|
|
|
|
target = next((d for d in documents if d.id == document_id), None)
|
|
if not target:
|
|
return False
|
|
|
|
ids_to_delete: set[str] = set()
|
|
|
|
def collect_descendants(parent_id: str):
|
|
ids_to_delete.add(parent_id)
|
|
children = [d for d in documents if d.parent_id == parent_id]
|
|
for child in children:
|
|
collect_descendants(child.id)
|
|
|
|
collect_descendants(document_id)
|
|
|
|
documents = [d for d in documents if d.id not in ids_to_delete]
|
|
self._save_documents(project_id, documents, user_id)
|
|
return True
|
|
|
|
def get_children(
|
|
self, project_id: str, parent_id: str, user_id: str
|
|
) -> List[DocumentNode]:
|
|
"""Получить прямых детей узла"""
|
|
documents = self._load_documents(project_id, user_id)
|
|
children = [d for d in documents if d.parent_id == parent_id]
|
|
children.sort(key=lambda x: x.order)
|
|
return children
|
|
|
|
def initialize_test_document(
|
|
self, project_id: str, user_id: str
|
|
) -> List[DocumentNode]:
|
|
"""
|
|
Создаёт тестовую иерархию документов при первом запуске,
|
|
если файл документов пуст.
|
|
"""
|
|
documents = self._load_documents(project_id, user_id)
|
|
|
|
if documents:
|
|
return documents
|
|
|
|
book = DocumentNode(
|
|
project_id=project_id,
|
|
title="Книга 1",
|
|
type="book",
|
|
order=0,
|
|
)
|
|
|
|
chapter = DocumentNode(
|
|
project_id=project_id,
|
|
parent_id=book.id,
|
|
title="Глава 1",
|
|
type="chapter",
|
|
order=0,
|
|
)
|
|
|
|
scene = DocumentNode(
|
|
project_id=project_id,
|
|
parent_id=chapter.id,
|
|
title="Сцена 1",
|
|
content="Это тестовый документ для разработки Writer IDE. Здесь будет располагаться контент ваших сценариев.",
|
|
type="scene",
|
|
order=0,
|
|
)
|
|
|
|
result = [book, chapter, scene]
|
|
self._save_documents(project_id, result, user_id)
|
|
print(f"Created test hierarchical documents for project: {project_id}")
|
|
return result
|
|
|
|
|
|
# Singleton instance
|
|
document_service = DocumentService()
|