refactor: API migration to direct Obsidian REST and test stabilization
This commit is contained in:
parent
3c3a1eb492
commit
ce55b9ca9a
30
Readme.md
30
Readme.md
|
|
@ -2,20 +2,24 @@
|
|||
|
||||
В данной директории содержатся скрипты и утилиты для автоматизации и генерации контента в TTRPG-кампании.
|
||||
|
||||
## Поддиректории
|
||||
- **dice_roller/** — скрипты для бросков кубиков.
|
||||
- **faction_turns/** — система ходов фракций по правилам Worlds Without Number (WWN).
|
||||
- **generate_encounter/** — генераторы боевых и случайных столкновений.
|
||||
- **generate_npc/** — генерация NPC (характеристики, био, классы).
|
||||
- **generate_trap/** — генерация ловушек.
|
||||
- **generate_treasure/** — генерация сокровищ и лута.
|
||||
- **time/** — управление временем в кампании.
|
||||
- **weather/** — генерация погодных условий.
|
||||
## Дерево основных файлов и папок
|
||||
|
||||
## Основные файлы
|
||||
- `vault_utils.py` — базовые утилиты для взаимодействия с Obsidian (чтение/запись файлов, поиск).
|
||||
- `game_utils.py` — общие игровые утилиты.
|
||||
- `obsidian_rest_api.md` — документация по работе с Obsidian Local REST API.
|
||||
```text
|
||||
scripts/
|
||||
├── dice_roller/ # Скрипты для бросков кубиков
|
||||
├── faction_turns/ # Система ходов фракций (WWN)
|
||||
├── generate_encounter/ # Генераторы столкновений и энкаунтеров
|
||||
├── generate_npc/ # Генерация NPC (био, классы, характеристики)
|
||||
├── generate_trap/ # Генерация ловушек
|
||||
├── generate_treasure/ # Генерация лута и сокровищ
|
||||
├── time/ # Управление временем кампании
|
||||
├── weather/ # Генерация погодных условий
|
||||
├── game_utils.py # Общие игровые утилиты
|
||||
├── obsidian_rest_api.md # Документация по работе с Obsidian REST API
|
||||
├── Readme.md # Описание структуры проекта (этот файл)
|
||||
├── TASKS.md # План работ и задачи
|
||||
└── vault_utils.py # Утилиты для работы с Obsidian REST API
|
||||
```
|
||||
|
||||
## Тестирование и сетевые настройки
|
||||
- При запуске интеграционных тестов к Obsidian Local REST API (`127.0.0.1`) системный прокси должен быть отключен (`proxies={"http": None, "https": None}`), иначе запросы к локальному хосту перенаправляются на внешние прокси-порты и падают с ошибкой подключения.
|
||||
|
|
|
|||
24
TASKS.md
Normal file
24
TASKS.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# План работ по рефакторингу API и расширению тестов
|
||||
|
||||
## 1. Рефакторинг vault_utils.py
|
||||
- [x] Заменить единый `vault_query` с хардкодом строк на экспортируемые прямые функции.
|
||||
- [x] Обновить XML-doc (docstrings) для всех методов.
|
||||
- [x] Добавить универсальную функцию `get_recent_files` (на базе JsonLogic).
|
||||
|
||||
## 2. Рефакторинг faction_utils.py
|
||||
- [x] Перевести вызовы с `vault_query(...)` на прямые вызовы API.
|
||||
- [x] Переписать `get_all_factions` и `get_recent_events` как обёртки вокруг `get_recent_files`.
|
||||
|
||||
## 3. Рефакторинг time_utils.py
|
||||
- [x] Обновить импорты из `vault_utils`.
|
||||
- [x] Заменить `vault_query` на прямые методы (`get_file`, `write_file`).
|
||||
|
||||
## 4. Исследование Obsidian Local REST API
|
||||
- [x] Добавить примеры JSONLogic-запросов и PowerShell-скрипт с сортировкой.
|
||||
- [x] Очистить документацию от сбоев форматирования Markdown ссылок.
|
||||
|
||||
## 5. Расширение тестов фракций
|
||||
- [ ] Дописать интеграционные тесты для `get_recent_events`, `get_location` и работы с активами.
|
||||
- [ ] Расширить тесты логики ходов фракций в `test_wwn_mechanics.py`.
|
||||
- [x] Добавлены setup-тесты для перезаписи тестовых файлов фракций (frontmatter и контент) для детерминированности среды.
|
||||
- [x] Убедиться, что текущий набор pytest проходит без ошибок (`pytest -v`).
|
||||
|
|
@ -39,7 +39,11 @@ if str(scripts_path) not in sys.path:
|
|||
sys.path.insert(0, str(scripts_path))
|
||||
|
||||
from vault_utils import (
|
||||
vault_query,
|
||||
get_file,
|
||||
search_simple,
|
||||
search_jsonlogic,
|
||||
list_directory,
|
||||
get_recent_files,
|
||||
write_file as write_faction, # алиас для обратной совместимости
|
||||
create_file as create_event_file, # алиас для обратной совместимости
|
||||
normalize_wikilink,
|
||||
|
|
@ -73,17 +77,7 @@ def get_all_factions(
|
|||
"frontmatter": { ... }
|
||||
}
|
||||
"""
|
||||
result = vault_query("get_all_factions", {
|
||||
"folder": factions_folder,
|
||||
"tag_filter": "faction"
|
||||
})
|
||||
if isinstance(result, list):
|
||||
factions = result
|
||||
elif isinstance(result, dict):
|
||||
factions = result.get("data", [])
|
||||
else:
|
||||
factions = []
|
||||
|
||||
factions = get_recent_files(factions_folder, tags=["faction"])
|
||||
for f in factions:
|
||||
f["frontmatter"] = normalize_faction_frontmatter(f.get("frontmatter", {}))
|
||||
return factions
|
||||
|
|
@ -101,7 +95,7 @@ def get_faction(file_path: str) -> dict:
|
|||
"body": "# Лор фракции..."
|
||||
}
|
||||
"""
|
||||
return vault_query("get_frontmatter", {"path": file_path})
|
||||
return get_file(file_path)
|
||||
|
||||
|
||||
def get_active_factions(
|
||||
|
|
@ -201,7 +195,7 @@ def get_location(wikilink_or_path: str) -> dict:
|
|||
else:
|
||||
path = wikilink_or_path
|
||||
|
||||
return vault_query("get_frontmatter", {"path": path})
|
||||
return get_file(path)
|
||||
|
||||
|
||||
def get_travel_distance(
|
||||
|
|
@ -306,14 +300,7 @@ def get_recent_events(
|
|||
"frontmatter": { ... }
|
||||
}
|
||||
"""
|
||||
result = vault_query("get_recent_files", {
|
||||
"folder": events_folder,
|
||||
"limit": max_count,
|
||||
"tags": tags_filter or []
|
||||
})
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
return []
|
||||
return get_recent_files(events_folder, tags=tags_filter, max_count=max_count)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
25
faction_turns/tests/check_search_syntax.py
Normal file
25
faction_turns/tests/check_search_syntax.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import requests
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
from vault_utils import BASE_URL, API_KEY
|
||||
|
||||
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
|
||||
proxies = {"http": None, "https": None}
|
||||
|
||||
queries = ["path:Фракции/Tests", "path:Tests", "Фракции", "Tests"]
|
||||
|
||||
for q in queries:
|
||||
print(f"--- Testing query: {q} ---")
|
||||
try:
|
||||
resp = requests.post(f"{BASE_URL}/search/simple/", headers=headers, params={"query": q}, verify=False, proxies=proxies)
|
||||
print("Status:", resp.status_code)
|
||||
data = resp.json()
|
||||
print("Results count:", len(data))
|
||||
if data:
|
||||
print("First result:", data[0].get('filename'))
|
||||
except Exception as e:
|
||||
print("Error:", e)
|
||||
20
faction_turns/tests/debug_search.py
Normal file
20
faction_turns/tests/debug_search.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import requests
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
from vault_utils import BASE_URL, API_KEY
|
||||
|
||||
headers = {"Authorization": f"Bearer {API_KEY}"}
|
||||
proxies = {"http": None, "https": None}
|
||||
|
||||
queries = ["Фракции/Tests", "Фракции", "Tests", "Test_Faction"]
|
||||
|
||||
for q in queries:
|
||||
print(f"--- Testing query: {q} ---")
|
||||
resp = requests.post(f"{BASE_URL}/search/simple/", headers=headers, params={"query": q}, verify=False, proxies=proxies)
|
||||
print("Status:", resp.status_code)
|
||||
if resp.status_code == 200:
|
||||
results = resp.json()
|
||||
print("Found files:", [r.get('filename') for r in results[:5]])
|
||||
else:
|
||||
print("Error:", resp.text)
|
||||
65
faction_turns/tests/test_faction_integration.py
Normal file
65
faction_turns/tests/test_faction_integration.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import pytest
|
||||
import sys
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from faction_utils import get_faction, get_all_factions
|
||||
from vault_utils import BASE_URL, API_KEY, write_file
|
||||
|
||||
# ----------------------------------------------- Faction Integration Tests ------------------------------------------------------------
|
||||
|
||||
def test_obsidian_connection():
|
||||
"""Проверяет наличие связи с плагином Obsidian Local REST API."""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
resp = requests.get(BASE_URL + "/", headers=headers, verify=False, proxies={"http": None, "https": None})
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}. Response: {resp.text}"
|
||||
|
||||
def test_setup_test_factions():
|
||||
"""Подготавливает тестовые файлы фракций, перезаписывая их содержимое для предсказуемости тестов."""
|
||||
fm_a = {
|
||||
"name": "Test_Faction_A",
|
||||
"tags": ["faction"],
|
||||
"force": 4,
|
||||
"treasure": 10,
|
||||
"assets": [{"name": "Asset1"}, {"name": "Asset2"}]
|
||||
}
|
||||
assert write_file("Фракции/Tests/Test_Faction_A.md", fm_a, "# Test Faction A\nФайл для автоматических тестов.") is True
|
||||
|
||||
fm_b = {
|
||||
"name": "Test_Faction_B",
|
||||
"tags": ["faction"],
|
||||
"wealth": 4,
|
||||
"cunning": 2
|
||||
}
|
||||
assert write_file("Фракции/Tests/Test_Faction_B.md", fm_b, "# Test Faction B\nФайл для автоматических тестов.") is True
|
||||
|
||||
def test_get_faction_integration():
|
||||
"""Проверяет чтение конкретной тестовой фракции через vault_utils API."""
|
||||
faction = get_faction("Фракции/Tests/Test_Faction_A.md")
|
||||
assert faction is not None
|
||||
assert faction.get("name") == "Test_Faction_A"
|
||||
fm = faction.get("frontmatter", {})
|
||||
assert fm.get("force") == 4
|
||||
assert fm.get("treasure") == 10
|
||||
assert len(fm.get("assets", [])) == 2
|
||||
|
||||
def test_get_all_factions_integration():
|
||||
"""Проверяет загрузку списка тестовых фракций из директории Tests."""
|
||||
factions = get_all_factions("Фракции/Tests")
|
||||
assert len(factions) >= 2
|
||||
names = [f.get("name") for f in factions]
|
||||
assert "Test_Faction_A" in names
|
||||
assert "Test_Faction_B" in names
|
||||
|
||||
faction_b = next(f for f in factions if f.get("name") == "Test_Faction_B")
|
||||
fm_b = faction_b.get("frontmatter", {})
|
||||
assert fm_b.get("wealth") == 4
|
||||
assert fm_b.get("cunning") == 2
|
||||
34
faction_turns/tests/test_faction_utils.py
Normal file
34
faction_turns/tests/test_faction_utils.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path для импорта модулей
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from faction_utils import roll_dice, normalize_wikilink
|
||||
|
||||
# ----------------------------------------------- Dice Tests ------------------------------------------------------------
|
||||
|
||||
def test_roll_dice_static():
|
||||
"""Проверяет парсинг нотации кубиков и вычисление статических бросков."""
|
||||
assert roll_dice("1d1+5") == 6
|
||||
assert roll_dice("2d1-1") == 1
|
||||
assert roll_dice("10d1+0") == 10
|
||||
|
||||
def test_roll_dice_random():
|
||||
"""Проверяет, что результаты случайных бросков кубиков находятся в ожидаемых границах."""
|
||||
result = roll_dice("1d20")
|
||||
assert 1 <= result <= 20
|
||||
|
||||
result = roll_dice("2d6+2")
|
||||
assert 4 <= result <= 14
|
||||
|
||||
# ----------------------------------------------- String Parsing Tests ------------------------------------------------------------
|
||||
|
||||
def test_normalize_wikilink():
|
||||
"""Проверяет нормализацию синтаксиса Obsidian wikilink в чистую строку."""
|
||||
assert normalize_wikilink("[[Waterdeep]]") == "waterdeep"
|
||||
assert normalize_wikilink("[[Waterdeep|City]]") == "waterdeep"
|
||||
assert normalize_wikilink("Waterdeep") == "waterdeep"
|
||||
assert normalize_wikilink("[[Neverwinter|The Jewel of the North]]") == "neverwinter"
|
||||
assert normalize_wikilink("") == ""
|
||||
79
faction_turns/tests/test_vault_utils.py
Normal file
79
faction_turns/tests/test_vault_utils.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Интеграционные тесты для vault_utils.py.
|
||||
Проверяют взаимодействие с Obsidian Local REST API по всем реализованным кейсам.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Добавляем родительскую директорию scripts в sys.path
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
|
||||
from vault_utils import (
|
||||
get_file, write_file, create_file, patch_frontmatter, search_simple, list_directory,
|
||||
normalize_wikilink, resolve_wikilink
|
||||
)
|
||||
|
||||
# ----------------------------------------------- Test Constants ------------------------------------------------------------
|
||||
TEST_FOLDER = "Фракции/Tests"
|
||||
TEST_FILE_NAME_NO_EXT = "Test_Vault_Utils"
|
||||
TEST_FILE_NAME = TEST_FILE_NAME_NO_EXT + ".md"
|
||||
TEST_FILE_PATH = f"{TEST_FOLDER}/{TEST_FILE_NAME}"
|
||||
|
||||
# ----------------------------------------------- Tests ------------------------------------------------------------
|
||||
def test_create_file():
|
||||
"""Проверяет создание нового файла (PUT)."""
|
||||
content = "# Test Vault Utils\nТестовый контент."
|
||||
success = create_file(TEST_FILE_PATH, content)
|
||||
assert success is True
|
||||
|
||||
def test_list_directory():
|
||||
"""Проверяет получение списка файлов в директории (GET)."""
|
||||
result = list_directory(TEST_FOLDER)
|
||||
assert "files" in result
|
||||
assert any(TEST_FILE_NAME in f for f in result["files"])
|
||||
|
||||
def test_search_simple():
|
||||
"""Проверяет простой поиск по имени файла (POST)."""
|
||||
result = search_simple("Test_Vault_Utils")
|
||||
assert len(result) > 0
|
||||
assert result[0]["filename"] == TEST_FILE_PATH
|
||||
|
||||
def test_get_file():
|
||||
"""Проверяет чтение контента и парсинг frontmatter (GET)."""
|
||||
result = get_file(TEST_FILE_PATH)
|
||||
assert result["path"] == TEST_FILE_PATH
|
||||
assert "Test Vault Utils" in result["body"]
|
||||
|
||||
def test_write_file():
|
||||
"""Проверяет перезапись файла с frontmatter (PUT)."""
|
||||
fm = {"test_field": "test_value"}
|
||||
body = "# Updated Title\nНовый текст."
|
||||
success = write_file(TEST_FILE_PATH, fm, body)
|
||||
assert success is True
|
||||
|
||||
# Проверяем, что изменения применились
|
||||
result = get_file(TEST_FILE_PATH)
|
||||
assert result["frontmatter"].get("test_field") == "test_value"
|
||||
|
||||
def test_patch_frontmatter():
|
||||
"""Проверяет точечное изменение frontmatter (PATCH)."""
|
||||
success = patch_frontmatter(
|
||||
TEST_FILE_PATH,
|
||||
target="patched_field",
|
||||
value=42
|
||||
)
|
||||
assert success is True
|
||||
|
||||
def test_normalize_wikilink():
|
||||
"""Проверяет нормализацию вики-ссылок."""
|
||||
assert normalize_wikilink("[[Waterdeep]]") == "waterdeep"
|
||||
assert normalize_wikilink("[[Waterdeep|City]]") == "waterdeep"
|
||||
assert normalize_wikilink("Waterdeep") == "waterdeep"
|
||||
assert normalize_wikilink("") == ""
|
||||
|
||||
def test_resolve_wikilink():
|
||||
"""Проверяет разрешение пути по имени вики-ссылки."""
|
||||
resolved = resolve_wikilink(f"[[{TEST_FILE_NAME_NO_EXT}]]")
|
||||
assert resolved == TEST_FILE_PATH
|
||||
34
faction_turns/tests/test_wwn_mechanics.py
Normal file
34
faction_turns/tests/test_wwn_mechanics.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в sys.path для импорта модулей
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from wwn_mechanics import calc_hp_max, calc_income, calc_xp_cost_to_raise
|
||||
|
||||
# ----------------------------------------------- HP Tests ------------------------------------------------------------
|
||||
|
||||
def test_calc_hp_max():
|
||||
"""Проверяет корректность расчета максимального HP фракции на основе атрибутов."""
|
||||
assert calc_hp_max(2, 4, 5) == 17
|
||||
assert calc_hp_max(3, 3, 3) == 12
|
||||
assert calc_hp_max(1, 1, 1) == 3
|
||||
assert calc_hp_max(8, 8, 8) == 60
|
||||
|
||||
# ----------------------------------------------- Income Tests ------------------------------------------------------------
|
||||
|
||||
def test_calc_income():
|
||||
"""Проверяет правильность расчета дохода фракции (Treasure income) за один ход."""
|
||||
assert calc_income(4, 2, 5) == 4
|
||||
assert calc_income(2, 1, 1) == 2
|
||||
assert calc_income(8, 7, 6) == 8
|
||||
|
||||
# ----------------------------------------------- XP Tests ------------------------------------------------------------
|
||||
|
||||
def test_calc_xp_cost_to_raise():
|
||||
"""Проверяет получение стоимости в XP для повышения значения атрибута."""
|
||||
assert calc_xp_cost_to_raise(1) == 2
|
||||
assert calc_xp_cost_to_raise(4) == 9
|
||||
assert calc_xp_cost_to_raise(7) == 20
|
||||
assert calc_xp_cost_to_raise(8) is None
|
||||
18
faction_turns/tests/verify_factions.py
Normal file
18
faction_turns/tests/verify_factions.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем путь к scripts, чтобы импортировать vault_utils
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
from vault_utils import vault_query
|
||||
|
||||
# Проверяем работу get_all_factions
|
||||
folder = "Фракции/Tests"
|
||||
print(f"--- Проверка загрузки для папки: {folder} ---")
|
||||
|
||||
# Прямой вызов get_all_factions через vault_query
|
||||
result = vault_query("get_all_factions", {"folder": folder})
|
||||
|
||||
print("Результат:", result)
|
||||
|
||||
# Дополнительно: выведем сырой список, если это возможно, чтобы увидеть, что пришло из поиска
|
||||
# (исходя из логики vault_utils, результат - это список словарей)
|
||||
|
|
@ -8,7 +8,7 @@ Here is the transcribed text from the provided images, with the values updated t
|
|||
|
||||
You can access the Obsidian local REST API & MCP server via the following URL:
|
||||
|
||||
* **Encrypted (HTTPS) API URL:** `[https://127.0.0.1:27124/](https://127.0.0.1:27124/)`
|
||||
* **Encrypted (HTTPS) API URL:** `https://127.0.0.1:27124/`
|
||||
|
||||
*Requires that this certificate be configured as a trusted certificate authority for your browser.*
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ Your API key should be passed as a bearer token via the `Authorization` header:
|
|||
|
||||
You can connect to the MCP server via the following endpoint:
|
||||
|
||||
* **Encrypted (HTTPS) MCP Endpoint:** `[https://127.0.0.1:27124/mcp/](https://127.0.0.1:27124/mcp/)`
|
||||
* **Encrypted (HTTPS) MCP Endpoint:** `https://127.0.0.1:27124/mcp/`
|
||||
|
||||
*Requires that this certificate be configured as a trusted certificate authority.*
|
||||
|
||||
|
|
@ -255,3 +255,4 @@ $body = [System.Text.Encoding]::UTF8.GetBytes("# New Faction`r`nКонтент
|
|||
Invoke-RestMethod -Method Put -Uri $uri -Headers $headers -Body $body
|
||||
```
|
||||
|
||||
8) Примеры для вытягивания последних N записей довольны сложны даже через powershell, пока не стал добавлять
|
||||
36
test_jsonlogic.py
Normal file
36
test_jsonlogic.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import requests
|
||||
import json
|
||||
import urllib3
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
API_KEY = "31c4ee60e78cea8503d0ac076298a0da6f5cbc3992f4e4cdc97d84fcbf27248e"
|
||||
BASE_URL = "https://127.0.0.1:27124"
|
||||
HEADERS = {
|
||||
"Authorization": f"Bearer {API_KEY}",
|
||||
"Content-Type": "application/vnd.olrapi.jsonlogic+json"
|
||||
}
|
||||
|
||||
def test_search():
|
||||
query = {
|
||||
"glob": ["*/*.md", {"var": ""}]
|
||||
}
|
||||
print("Sending request...")
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/search/",
|
||||
headers=HEADERS,
|
||||
json=query,
|
||||
verify=False,
|
||||
proxies={"http": None, "https": None}
|
||||
)
|
||||
print("Status:", resp.status_code)
|
||||
if resp.status_code == 200:
|
||||
results = resp.json()
|
||||
print(f"Found {len(results)} results.")
|
||||
if results:
|
||||
print("First result sample:", json.dumps(results[0], indent=2))
|
||||
else:
|
||||
print("Error:", resp.text)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_search()
|
||||
|
|
@ -55,4 +55,7 @@ def get_campaign_time(debug_description: str) -> str:
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
print(get_campaign_time("тест"))
|
||||
|
|
@ -212,6 +212,9 @@ def set_campaign_time(debug_description: str, requests: list[TimeChangeRequest])
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
result = set_campaign_time("тест", [
|
||||
{"mode": "shift", "day": 1, "hour": 5},
|
||||
{"mode": "absolute", "year": 1492, "month": 1, "day": 1, "hour": 0},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ scripts_path = Path(__file__).resolve().parent.parent
|
|||
if str(scripts_path) not in sys.path:
|
||||
sys.path.insert(0, str(scripts_path))
|
||||
|
||||
from vault_utils import vault_query, write_file, create_file
|
||||
from vault_utils import get_file, write_file, create_file
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# НАЗВАНИЯ МЕСЯЦЕВ (только русские)
|
||||
|
|
@ -133,7 +133,7 @@ def _normalize_time(year: int, month: int, day: int, hour: int) -> tuple[int, in
|
|||
|
||||
def get_campaign_time() -> dict:
|
||||
try:
|
||||
data = vault_query("get_frontmatter", {"path": CAMPAIGN_TIME_PATH})
|
||||
data = get_file(CAMPAIGN_TIME_PATH)
|
||||
except RuntimeError:
|
||||
data = None
|
||||
|
||||
|
|
|
|||
278
vault_utils.py
278
vault_utils.py
|
|
@ -1,135 +1,193 @@
|
|||
"""
|
||||
vault_utils.py — универсальные утилиты для работы с Obsidian vault через HTTP-прокси.
|
||||
|
||||
Содержит функции, переиспользуемые любыми модулями (фракции, время, события и т.д.).
|
||||
|
||||
Экспортирует:
|
||||
vault_query(query_type, payload) — базовый вызов к бэкенду
|
||||
write_file(path, frontmatter, body) — запись frontmatter и тела файла
|
||||
create_file(path, content) — создание файла с содержимым
|
||||
normalize_wikilink(s) — приведение wikilink к нормализованному виду
|
||||
resolve_wikilink(name) — поиск пути к файлу по имени/алиасу
|
||||
vault_utils.py — контексто-независимые утилиты для работы с Obsidian REST API.
|
||||
Универсальный модуль без привязки к игровой специфике (фракциям, событиям и т.д.).
|
||||
"""
|
||||
|
||||
import re
|
||||
import requests as http_requests
|
||||
from typing import Optional
|
||||
import yaml
|
||||
import requests
|
||||
import urllib.parse
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Настройки
|
||||
# ---------------------------------------------------------------------------
|
||||
# ----------------------------------------------- Configuration ------------------------------------------------------------
|
||||
REST_API_PORT = 27124
|
||||
API_KEY = "31c4ee60e78cea8503d0ac076298a0da6f5cbc3992f4e4cdc97d84fcbf27248e"
|
||||
BASE_URL = f"https://127.0.0.1:{REST_API_PORT}"
|
||||
|
||||
BACKEND_URL = "http://localhost:5000/api"
|
||||
VAULT_QUERY_TIMEOUT = 12 # секунд
|
||||
def _get_headers() -> Dict[str, str]:
|
||||
"""Возвращает базовые заголовки для запросов к REST API."""
|
||||
return {"Authorization": f"Bearer {API_KEY}", "Content-Type": "text/markdown"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Базовый клиент
|
||||
# ---------------------------------------------------------------------------
|
||||
def _get_req_kwargs() -> Dict[str, Any]:
|
||||
"""Возвращает параметры для отключения SSL и прокси."""
|
||||
return {"verify": False, "proxies": {"http": None, "https": None}}
|
||||
|
||||
def vault_query(query_type: str, payload: dict) -> any:
|
||||
# ----------------------------------------------- Parsers ------------------------------------------------------------
|
||||
def _parse_obsidian_content(content: str, path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Отправляет запрос к Obsidian vault через бэкенд-прокси.
|
||||
|
||||
Блокирующий вызов: ждёт пока Obsidian выполнит операцию.
|
||||
Бросает RuntimeError при недоступности бэкенда или timeout.
|
||||
|
||||
Поля:
|
||||
query_type: тип операции
|
||||
payload: параметры операции
|
||||
Парсит контент из Markdown в структуру с Frontmatter.
|
||||
"""
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{BACKEND_URL}/vault/query",
|
||||
json={"type": query_type, "payload": payload},
|
||||
timeout=VAULT_QUERY_TIMEOUT,
|
||||
proxies={"http": None, "https": None}
|
||||
)
|
||||
except http_requests.exceptions.ConnectionError as e:
|
||||
raise RuntimeError(
|
||||
f"Бэкенд недоступен ({BACKEND_URL}). "
|
||||
"Убедитесь что Flask-сервер запущен."
|
||||
)
|
||||
except http_requests.exceptions.Timeout:
|
||||
raise RuntimeError(
|
||||
f"vault_query timeout после {VAULT_QUERY_TIMEOUT}с. "
|
||||
"Возможно Obsidian не подключён к WebSocket."
|
||||
)
|
||||
if content.startswith("---"):
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
return {
|
||||
"path": path,
|
||||
"frontmatter": yaml.safe_load(parts[1]) or {},
|
||||
"body": parts[2].strip(),
|
||||
"name": path.split('/')[-1].replace('.md', '')
|
||||
}
|
||||
return {"path": path, "frontmatter": {}, "body": content, "name": path.split('/')[-1]}
|
||||
|
||||
if resp.status_code == 504:
|
||||
raise RuntimeError(
|
||||
"Obsidian не ответил на запрос vault за 10 секунд. "
|
||||
"Проверьте что плагин LLM Agent запущен и подключён."
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
try:
|
||||
err = resp.json().get("error", resp.text)
|
||||
except Exception:
|
||||
err = resp.text
|
||||
raise RuntimeError(f"vault_query ошибка {resp.status_code}: {err}")
|
||||
|
||||
return resp.json()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Запись данных (универсальные)
|
||||
# ---------------------------------------------------------------------------
|
||||
# ----------------------------------------------- Core API ------------------------------------------------------------
|
||||
def get_file(path: str) -> Dict[str, Any]:
|
||||
"""Получает файл из Obsidian и возвращает распарсенный контент."""
|
||||
encoded_path = urllib.parse.quote(path)
|
||||
resp = requests.get(f"{BASE_URL}/vault/{encoded_path}", headers=_get_headers(), **_get_req_kwargs())
|
||||
if resp.status_code == 200:
|
||||
return _parse_obsidian_content(resp.text, path)
|
||||
raise RuntimeError(f"Файл не найден: {path}")
|
||||
|
||||
def write_file(file_path: str, frontmatter: dict, body: str) -> bool:
|
||||
"""
|
||||
Сохраняет frontmatter и тело файла в Obsidian vault.
|
||||
|
||||
file_path — путь внутри vault, например 'Фракции/Cult_of_the_Dragon.md'
|
||||
frontmatter — словарь с данными (поля YAML)
|
||||
body — содержимое файла после frontmatter
|
||||
"""
|
||||
result = vault_query("write_frontmatter", {
|
||||
"path": file_path,
|
||||
"frontmatter": frontmatter,
|
||||
"body": body
|
||||
})
|
||||
if isinstance(result, dict):
|
||||
return result.get("success", False)
|
||||
return False
|
||||
"""Сохраняет frontmatter и тело файла в Obsidian vault."""
|
||||
encoded_path = urllib.parse.quote(file_path)
|
||||
fm = yaml.dump(frontmatter, allow_unicode=True)
|
||||
content = f"---\n{fm}---\n\n{body}"
|
||||
resp = requests.put(
|
||||
f"{BASE_URL}/vault/{encoded_path}",
|
||||
headers=_get_headers(),
|
||||
data=content.encode('utf-8'),
|
||||
**_get_req_kwargs()
|
||||
)
|
||||
return resp.status_code in [200, 201, 204]
|
||||
|
||||
def create_file(file_path: str, content: str) -> bool:
|
||||
"""
|
||||
Создаёт файл в Obsidian vault (перезаписывает, если существует).
|
||||
Промежуточные папки создаются автоматически.
|
||||
"""
|
||||
result = vault_query("create_file", {
|
||||
"path": file_path,
|
||||
"content": content
|
||||
"""Создаёт новый файл в Obsidian vault с указанным контентом."""
|
||||
encoded_path = urllib.parse.quote(file_path)
|
||||
resp = requests.put(
|
||||
f"{BASE_URL}/vault/{encoded_path}",
|
||||
headers=_get_headers(),
|
||||
data=content.encode('utf-8'),
|
||||
**_get_req_kwargs()
|
||||
)
|
||||
return resp.status_code in [200, 201, 204]
|
||||
|
||||
def patch_frontmatter(path: str, target: str, value: Any, operation: str = "replace") -> bool:
|
||||
"""Обновляет или добавляет поле во frontmatter файла."""
|
||||
encoded_path = urllib.parse.quote(path)
|
||||
headers = _get_headers()
|
||||
headers.update({
|
||||
"Operation": operation,
|
||||
"Target-Type": "frontmatter",
|
||||
"Target": target,
|
||||
"Create-Target-If-Missing": "true",
|
||||
"Content-Type": "application/json"
|
||||
})
|
||||
if isinstance(result, dict):
|
||||
return result.get("success", False)
|
||||
return False
|
||||
val = str(value)
|
||||
resp = requests.patch(f"{BASE_URL}/vault/{encoded_path}", headers=headers, data=val.encode('utf-8'), **_get_req_kwargs())
|
||||
return resp.status_code in [200, 204]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Нормализация и резолвинг wikilink
|
||||
# ---------------------------------------------------------------------------
|
||||
def search_simple(query: str) -> list[dict]:
|
||||
"""Выполняет простой текстовый поиск в Obsidian."""
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/search/simple/",
|
||||
headers=_get_headers(),
|
||||
params={"query": query},
|
||||
**_get_req_kwargs()
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return []
|
||||
|
||||
def search_jsonlogic(query: dict) -> list[dict]:
|
||||
"""Выполняет поиск по правилам JsonLogic в Obsidian."""
|
||||
headers = _get_headers()
|
||||
headers["Content-Type"] = "application/vnd.olrapi.jsonlogic+json"
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/search/",
|
||||
headers=headers,
|
||||
json=query,
|
||||
**_get_req_kwargs()
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return []
|
||||
|
||||
def list_directory(path: str) -> dict:
|
||||
"""Возвращает список файлов и папок в указанной директории."""
|
||||
encoded_path = urllib.parse.quote(path) if path else ""
|
||||
resp = requests.get(f"{BASE_URL}/vault/{encoded_path}", headers=_get_headers(), **_get_req_kwargs())
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
return {"files": []}
|
||||
|
||||
# ----------------------------------------------- Utilities ------------------------------------------------------------
|
||||
def normalize_wikilink(s: str) -> str:
|
||||
"""
|
||||
Приводит wikilink к нижнему регистру без скобок и алиасов.
|
||||
|
||||
Примеры:
|
||||
'[[Waterdeep]]' → 'waterdeep'
|
||||
'[[Waterdeep|City]]' → 'waterdeep'
|
||||
'Waterdeep' → 'waterdeep'
|
||||
'' → ''
|
||||
"""
|
||||
"""Приводит wikilink к нижнему регистру без скобок и алиасов."""
|
||||
if not s:
|
||||
return ''
|
||||
return re.sub(r'^\[\[|\]\]$', '', s).split('|')[0].strip().lower()
|
||||
|
||||
def resolve_wikilink(name: str) -> Optional[str]:
|
||||
"""
|
||||
Резолвит wikilink в путь к файлу через Obsidian metadataCache.
|
||||
Знает об aliases.
|
||||
|
||||
Принимает '[[Waterdeep]]', '[[Waterdeep|City]]' или просто 'Waterdeep'.
|
||||
Возвращает путь вида 'TTRPG/Locations/Waterdeep.md' или None.
|
||||
"""
|
||||
"""Ищет путь к файлу по имени wikilink через простой поиск."""
|
||||
original_clean = re.sub(r'^\[\[|\]\]$', '', name).split('|')[0].strip()
|
||||
result = vault_query("resolve_wikilink", {"name": original_clean})
|
||||
return result if isinstance(result, str) else None
|
||||
results = search_simple(original_clean)
|
||||
return results[0].get("filename") if results else None
|
||||
|
||||
|
||||
def get_recent_files(
|
||||
folder: str,
|
||||
tags: Optional[list[str]] = None,
|
||||
max_count: Optional[int] = None
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Ищет файлы в папке, фильтрует по тегам и возвращает отсортированными по убыванию даты изменения (mtime).
|
||||
Использует пакетную загрузку JsonLogic для минимизации запросов к API.
|
||||
"""
|
||||
conditions = [{"glob": [f"{folder}/*.md", {"var": "path"}]}]
|
||||
|
||||
if tags:
|
||||
tag_conditions = [{"in": [tag.lstrip('#'), {"var": "tags"}]} for tag in tags]
|
||||
conditions.append({"or": tag_conditions})
|
||||
|
||||
query = {
|
||||
"if": [
|
||||
{"and": conditions},
|
||||
{"var": ""},
|
||||
False
|
||||
]
|
||||
}
|
||||
|
||||
search_results = search_jsonlogic(query)
|
||||
valid_results = [r for r in search_results if isinstance(r.get("result"), dict)]
|
||||
|
||||
valid_results.sort(
|
||||
key=lambda x: x.get("result", {}).get("stat", {}).get("mtime", 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if max_count is not None:
|
||||
valid_results = valid_results[:max_count]
|
||||
|
||||
files_data = []
|
||||
for res in valid_results:
|
||||
note_json = res.get("result", {})
|
||||
file_path = res.get("filename", "")
|
||||
files_data.append({
|
||||
"path": file_path,
|
||||
"name": file_path.split('/')[-1].replace('.md', ''),
|
||||
"frontmatter": note_json.get("frontmatter", {}) or {},
|
||||
"body": note_json.get("content", "")
|
||||
})
|
||||
|
||||
return files_data
|
||||
|
||||
# ----------------------------------------------- Main ------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
print("Проверка get_recent_files...")
|
||||
# Ищем файлы в папке Фракции с тегом faction
|
||||
files = get_recent_files("Фракции", tags=["faction"], max_count=3)
|
||||
print(f"Найдено файлов: {len(files)}")
|
||||
for f in files:
|
||||
print(f"Путь: {f['path']}")
|
||||
print(f"Фронтматтер keys: {list(f['frontmatter'].keys())}")
|
||||
Loading…
Reference in New Issue
Block a user