From ce55b9ca9ad8d0896b3f1b557dc8fcd08241b377 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Wed, 29 Jul 2026 16:13:00 +0300 Subject: [PATCH] refactor: API migration to direct Obsidian REST and test stabilization --- Readme.md | 32 +- TASKS.md | 24 ++ faction_turns/faction_utils.py | 31 +- faction_turns/tests/check_search_syntax.py | 25 ++ faction_turns/tests/debug_search.py | 20 ++ .../tests/test_faction_integration.py | 65 ++++ faction_turns/tests/test_faction_utils.py | 34 +++ faction_turns/tests/test_vault_utils.py | 79 +++++ faction_turns/tests/test_wwn_mechanics.py | 34 +++ faction_turns/tests/verify_factions.py | 18 ++ obsidian_rest_api.md | 5 +- test_jsonlogic.py | 36 +++ time/get_campaign_time.py | 3 + time/set_campaign_time.py | 3 + time/time_utils.py | 4 +- vault_utils.py | 278 +++++++++++------- 16 files changed, 541 insertions(+), 150 deletions(-) create mode 100644 TASKS.md create mode 100644 faction_turns/tests/check_search_syntax.py create mode 100644 faction_turns/tests/debug_search.py create mode 100644 faction_turns/tests/test_faction_integration.py create mode 100644 faction_turns/tests/test_faction_utils.py create mode 100644 faction_turns/tests/test_vault_utils.py create mode 100644 faction_turns/tests/test_wwn_mechanics.py create mode 100644 faction_turns/tests/verify_factions.py create mode 100644 test_jsonlogic.py diff --git a/Readme.md b/Readme.md index fc2fb18..bd533a4 100644 --- a/Readme.md +++ b/Readme.md @@ -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}`), иначе запросы к локальному хосту перенаправляются на внешние прокси-порты и падают с ошибкой подключения. +Данный файл README.md необходимо поддерживать в актуальном состоянии. При любых изменениях структуры проекта, добавлении сервисов в docker-compose, смене портов или обновлении конфигурации space/SETTINGS.md обязательно обновите этот файл README.md. --> \ No newline at end of file diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..85a1156 --- /dev/null +++ b/TASKS.md @@ -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`). diff --git a/faction_turns/faction_utils.py b/faction_turns/faction_utils.py index 790da7d..6059a02 100644 --- a/faction_turns/faction_utils.py +++ b/faction_turns/faction_utils.py @@ -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) # --------------------------------------------------------------------------- diff --git a/faction_turns/tests/check_search_syntax.py b/faction_turns/tests/check_search_syntax.py new file mode 100644 index 0000000..3b23dfd --- /dev/null +++ b/faction_turns/tests/check_search_syntax.py @@ -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) diff --git a/faction_turns/tests/debug_search.py b/faction_turns/tests/debug_search.py new file mode 100644 index 0000000..7c23d2e --- /dev/null +++ b/faction_turns/tests/debug_search.py @@ -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) \ No newline at end of file diff --git a/faction_turns/tests/test_faction_integration.py b/faction_turns/tests/test_faction_integration.py new file mode 100644 index 0000000..24a81fa --- /dev/null +++ b/faction_turns/tests/test_faction_integration.py @@ -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 diff --git a/faction_turns/tests/test_faction_utils.py b/faction_turns/tests/test_faction_utils.py new file mode 100644 index 0000000..ee2577c --- /dev/null +++ b/faction_turns/tests/test_faction_utils.py @@ -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("") == "" diff --git a/faction_turns/tests/test_vault_utils.py b/faction_turns/tests/test_vault_utils.py new file mode 100644 index 0000000..776f099 --- /dev/null +++ b/faction_turns/tests/test_vault_utils.py @@ -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 diff --git a/faction_turns/tests/test_wwn_mechanics.py b/faction_turns/tests/test_wwn_mechanics.py new file mode 100644 index 0000000..6387e0a --- /dev/null +++ b/faction_turns/tests/test_wwn_mechanics.py @@ -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 diff --git a/faction_turns/tests/verify_factions.py b/faction_turns/tests/verify_factions.py new file mode 100644 index 0000000..372be93 --- /dev/null +++ b/faction_turns/tests/verify_factions.py @@ -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, результат - это список словарей) diff --git a/obsidian_rest_api.md b/obsidian_rest_api.md index ed3dd78..c024d60 100644 --- a/obsidian_rest_api.md +++ b/obsidian_rest_api.md @@ -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, пока не стал добавлять \ No newline at end of file diff --git a/test_jsonlogic.py b/test_jsonlogic.py new file mode 100644 index 0000000..e10bd1c --- /dev/null +++ b/test_jsonlogic.py @@ -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() diff --git a/time/get_campaign_time.py b/time/get_campaign_time.py index 737967c..02c7804 100644 --- a/time/get_campaign_time.py +++ b/time/get_campaign_time.py @@ -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("тест")) \ No newline at end of file diff --git a/time/set_campaign_time.py b/time/set_campaign_time.py index 47a2c4b..bf78e82 100644 --- a/time/set_campaign_time.py +++ b/time/set_campaign_time.py @@ -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}, diff --git a/time/time_utils.py b/time/time_utils.py index d4d0b86..46c3665 100644 --- a/time/time_utils.py +++ b/time/time_utils.py @@ -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 diff --git a/vault_utils.py b/vault_utils.py index 0c00204..20f9e1b 100644 --- a/vault_utils.py +++ b/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 \ No newline at end of file + 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())}") \ No newline at end of file