refactor: API migration to direct Obsidian REST and test stabilization

This commit is contained in:
dimitrievgs 2026-07-29 16:13:00 +03:00
parent 3c3a1eb492
commit ce55b9ca9a
16 changed files with 541 additions and 150 deletions

View File

@ -2,23 +2,27 @@
В данной директории содержатся скрипты и утилиты для автоматизации и генерации контента в 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}`), иначе запросы к локальному хосту перенаправляются на внешние прокси-порты и падают с ошибкой подключения.
<!-- Agents: ВНИМАНИЕ ИИ-АГЕНТАМ И АВТОМАТИЗИРОВАННЫМ СИСТЕМАМ!
Данный файл README.md необходимо поддерживать в актуальном состоянии. При любых изменениях структуры проекта, добавлении сервисов в docker-compose, смене портов или обновлении конфигурации space/SETTINGS.md обязательно обновите этот файл README.md. -->
Данный файл README.md необходимо поддерживать в актуальном состоянии. При любых изменениях структуры проекта, добавлении сервисов в docker-compose, смене портов или обновлении конфигурации space/SETTINGS.md обязательно обновите этот файл README.md. -->

24
TASKS.md Normal file
View 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`).

View File

@ -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)
# ---------------------------------------------------------------------------

View 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)

View 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)

View 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

View 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("") == ""

View 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

View 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

View 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, результат - это список словарей)

View File

@ -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
View 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()

View File

@ -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("тест"))

View File

@ -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},

View File

@ -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

View File

@ -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())}")