Add Time Tool

This commit is contained in:
dimitrievgs 2026-07-08 23:37:24 +03:00
parent ae9b31a23e
commit 14e6222cf1
5 changed files with 657 additions and 128 deletions

View File

@ -32,6 +32,20 @@ from typing import Optional
from wwn_mechanics import calc_hp_max
import sys
from pathlib import Path
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 as write_faction, # алиас для обратной совместимости
create_file as create_event_file, # алиас для обратной совместимости
normalize_wikilink,
resolve_wikilink,
)
# ---------------------------------------------------------------------------
# Настройки
# ---------------------------------------------------------------------------
@ -42,89 +56,6 @@ VAULT_QUERY_TIMEOUT = 12 # секунд
# По WWN: один faction turn ≈ 1 месяц ≈ 100 миль перемещения
WWN_MILES_PER_TURN = 100
# ---------------------------------------------------------------------------
# Базовый клиент
# ---------------------------------------------------------------------------
def vault_query(query_type: str, payload: dict) -> any:
"""
Отправляет запрос к Obsidian vault через бэкенд-прокси.
Блокирующий вызов: ждёт пока Obsidian выполнит операцию.
Бросает RuntimeError при недоступности бэкенда или timeout.
Поля:
query_type: тип операции
payload: параметры операции
"""
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 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()
# ---------------------------------------------------------------------------
# Нормализация wikilinks
# ---------------------------------------------------------------------------
def normalize_wikilink(s: str) -> str:
"""
Приводит wikilink к нижнему регистру без скобок и алиасов.
Примеры:
'[[Waterdeep]]' 'waterdeep'
'[[Waterdeep|City]]' 'waterdeep'
'Waterdeep' 'waterdeep'
'' ''
"""
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.
"""
original_clean = re.sub(r'^\[\[|\]\]$', '', name).split('|')[0].strip()
result = vault_query("resolve_wikilink", {"name": original_clean})
# vault_query возвращает строку-путь или None напрямую
return result if isinstance(result, str) else None
# ---------------------------------------------------------------------------
# Фракции
# ---------------------------------------------------------------------------
@ -203,22 +134,22 @@ def get_factions_in_location(all_factions: list[dict], locations_wikilinks: list
result = []
for faction in all_factions:
fm = faction.get("frontmatter", {})
# Проверка списка locations фракции
locs = fm.get("locations", [])
if isinstance(locs, str): locs = [locs]
normalized_locs = [normalize_wikilink(l) for l in locs if isinstance(l, str)]
# Проверка локаций активов
asset_locs = [
normalize_wikilink(a.get("location", ""))
normalize_wikilink(a.get("location", ""))
for a in fm.get("assets", []) if isinstance(a, dict)
]
# Если фракция есть хотя бы в одной из целевых локаций
if any(t in normalized_locs or t in asset_locs for t in targets):
result.append(faction)
return result
def get_assets_in_location(
@ -231,38 +162,20 @@ def get_assets_in_location(
"""
fm = faction_data.get("frontmatter", faction_data)
assets = fm.get("assets", [])
if not locations_wikilinks:
return [a for a in assets if isinstance(a, dict)]
targets = [normalize_wikilink(loc) for loc in locations_wikilinks if loc]
if not targets:
return [a for a in assets if isinstance(a, dict)]
return [
a for a in assets
a for a in assets
if isinstance(a, dict) and normalize_wikilink(a.get("location", "")) in targets
]
def write_faction(file_path: str, frontmatter: dict, body: str) -> bool:
"""
Сохраняет изменённый frontmatter фракции обратно в Obsidian.
file_path путь вида 'Фракции/Cult_of_the_Dragon.md'
frontmatter словарь с обновлёнными данными
body тело файла (лор-текст), берётся из get_faction() без изменений
"""
result = vault_query("write_frontmatter", {
"path": file_path,
"frontmatter": frontmatter,
"body": body
})
if isinstance(result, dict):
return result.get("success", False)
return False
# ---------------------------------------------------------------------------
# Локации и расстояния
# ---------------------------------------------------------------------------
@ -403,22 +316,6 @@ def get_recent_events(
return []
def create_event_file(file_path: str, content: str) -> bool:
"""
Создаёт файл события в Obsidian vault.
Если файл уже существует перезаписывает.
Промежуточные папки создаются автоматически.
"""
result = vault_query("create_file", {
"path": file_path,
"content": content
})
if isinstance(result, dict):
return result.get("success", False)
return False
# ---------------------------------------------------------------------------
# Кубики (WWN-механика)
# ---------------------------------------------------------------------------
@ -619,11 +516,12 @@ def normalize_faction_frontmatter(fm: dict) -> dict:
# Скалярные дефолты
for key, default in FACTION_DEFAULTS.items():
if fm.get(key) is None:
fm[key] = default.copy() if isinstance(default, (list, dict)) else default
fm[key] = default.copy() if isinstance(default,
(list, dict)) else default
# Списочные поля (нарративные) — чистим от пустых заглушек
for list_field in ("locations", "motivations", "headquarters",
"leaders", "enemies"):
for list_field in ("locations", "motivations", "headquarters", "leaders",
"enemies"):
fm[list_field] = _clean_list_field(fm.get(list_field))
# tags_listen / faction_tags тоже чистим
@ -649,4 +547,4 @@ def normalize_faction_frontmatter(fm: dict) -> dict:
fm["enemies"] = [str(x).strip() for x in fm["enemies"] if x]
fm["allies"] = [str(x).strip() for x in fm["allies"] if x]
return fm
return fm

58
time/get_campaign_time.py Normal file
View File

@ -0,0 +1,58 @@
"""
get_campaign_time.py получение текущего игрового времени.
Экспортирует функцию get_campaign_time(debug_description: str) -> str,
которую вызывает тул с фронта без параметров.
Возвращает строку в формате Markdown с текущей датой, часом и списком праздников на неделю.
"""
from time_utils import (
get_campaign_time as get_time,
MONTH_NAMES_RU,
get_week_holidays,
get_holiday,
)
def _format_time(year: int, month: int, day: int, hour: int) -> str:
month_name = MONTH_NAMES_RU.get(month, str(month))
return f"{day} {month_name} {year} DR, {hour:02d}:00"
def _format_holidays_section(year: int, month: int, day: int) -> str:
holidays = get_week_holidays(year, month, day)
if not holidays:
return "📅 **Ближайшие праздники:** нет"
lines = ["📅 **Ближайшие праздники:**"]
for m, d, desc, when in holidays:
month_name = MONTH_NAMES_RU.get(m, str(m))
lines.append(f"- **{when}** ({d} {month_name}): {desc}")
return "\n".join(lines)
def get_campaign_time(debug_description: str) -> str:
current = get_time()
time_str = _format_time(current["year"], current["month"], current["day"], current["hour"])
today_holiday = get_holiday(current["month"], current["day"], current["year"])
today_line = f"🎉 **Сегодня:** {today_holiday}" if today_holiday else ""
holidays_section = _format_holidays_section(
current["year"],
current["month"],
current["day"]
)
result = (
f"### 🕰️ Текущее время\n"
f"**Дата:** {time_str}\n"
)
if today_line:
result += f"{today_line}\n"
result += f"\n{holidays_section}"
return result
if __name__ == "__main__":
print(get_campaign_time("тест"))

223
time/set_campaign_time.py Normal file
View File

@ -0,0 +1,223 @@
"""
set_campaign_time.py изменение игрового времени.
Экспортирует функцию set_campaign_time(debug_description, requests), которую вызывает тул с фронта.
requests список объектов вида:
{
"mode": "absolute" | "shift", # режим: absolute установка, shift сдвиг (default "shift")
"year": 1492, # при absolute год, при shift сдвиг лет
"month": 1, # при absolute месяц (число 1-12 или русское название), при shift сдвиг месяцев (число)
"day": 1, # при absolute день, при shift сдвиг дней
"hour": 0, # при absolute час, при shift сдвиг часов
}
При mode="absolute" можно передать только часть полей, остальные останутся без изменений.
Поле month при mode="absolute" принимает:
- целое число от 1 до 12
- строку, содержащую число (например "3")
- русское название месяца (регистр не важен):
1: Хаммер
2: Алтуриак
3: Чес
4: Тарсак
5: Миртул
6: Киторн
7: Флеймрул
8: Элейнт (допускается "Элизис")
9: Элесиас
10: Марпенот
11: Уктар (допускается "Укта")
12: Найтол
При mode="shift" month должен быть числом (целым, может быть отрицательным).
Возвращает строку в формате Markdown с результатом изменения и списком праздников на неделю.
"""
from typing import TypedDict
try:
from typing_extensions import TypedDict
except ImportError:
from typing import TypedDict
from time_utils import (
get_campaign_time as get_time,
set_campaign_time as set_time_absolute,
shift_campaign_time,
MONTH_NAMES_RU,
parse_month,
get_week_holidays,
get_holiday,
is_leap_year,
)
class TimeChangeRequest(TypedDict, total=False):
"""
Запрос на изменение игрового времени.
Поля:
mode: Режим операции: "absolute" установить абсолютное время (используются year, month, day, hour как новые значения);
"shift" сдвинуть текущее время на указанные значения (year, month, day, hour интерпретируются как дельты).
По умолчанию: "shift".
year: При mode="absolute" год (целое >0). При mode="shift" сдвиг в годах (может быть отрицательным).
month: При mode="absolute" месяц (число 1-12 или русское название). При mode="shift" сдвиг в месяцах (целое, может быть отрицательным).
Допустимые русские названия (регистр не важен):
1: Хаммер
2: Алтуриак
3: Чес
4: Тарсак
5: Миртул
6: Киторн
7: Флеймрул
8: Элейнт (также принимается "Элизис")
9: Элесиас
10: Марпенот
11: Уктар (также принимается "Укта")
12: Найтол
day: При mode="absolute" день (130). При mode="shift" сдвиг в днях (может быть отрицательным).
hour: При mode="absolute" час (023). При mode="shift" сдвиг в часах (может быть отрицательным).
"""
mode: str # "absolute" | "shift", default "shift"
year: int
month: int | str
day: int
hour: int
def _format_time(year: int, month: int, day: int, hour: int) -> str:
month_name = MONTH_NAMES_RU.get(month, str(month))
return f"{day} {month_name} {year} DR, {hour:02d}:00"
def _format_holidays_section(year: int, month: int, day: int) -> str:
holidays = get_week_holidays(year, month, day)
if not holidays:
return "📅 **Ближайшие праздники:** нет"
lines = ["📅 **Ближайшие праздники:**"]
for m, d, desc, when in holidays:
month_name = MONTH_NAMES_RU.get(m, str(m))
lines.append(f"- **{when}** ({d} {month_name}): {desc}")
return "\n".join(lines)
def set_campaign_time(debug_description: str, requests: list[TimeChangeRequest]) -> str:
if not requests:
requests = [{}]
sections = []
for idx, req in enumerate(requests, start=1):
mode = req.get("mode", "shift")
old_time = get_time()
old_str = _format_time(old_time["year"], old_time["month"], old_time["day"], old_time["hour"])
if mode == "absolute":
new_year = old_time["year"]
new_month = old_time["month"]
new_day = old_time["day"]
new_hour = old_time["hour"]
if "year" in req:
new_year = req["year"]
if "month" in req:
try:
new_month = parse_month(req["month"])
except (ValueError, TypeError) as e:
sections.append(f"### ❌ Ошибка в запросе #{idx}\n{e}")
continue
if "day" in req:
new_day = req["day"]
if "hour" in req:
new_hour = req["hour"]
if new_year <= 0:
sections.append(f"### ❌ Ошибка в запросе #{idx}\nГод должен быть > 0, получено {new_year}.")
continue
if not (1 <= new_month <= 12):
sections.append(f"### ❌ Ошибка в запросе #{idx}\nМесяц должен быть 1-12, получено {new_month}.")
continue
if not (1 <= new_day <= 30):
sections.append(f"### ❌ Ошибка в запросе #{idx}\nДень должен быть 1-30, получено {new_day}.")
continue
if not (0 <= new_hour <= 23):
sections.append(f"### ❌ Ошибка в запросе #{idx}\nЧас должен быть 0-23, получено {new_hour}.")
continue
success = set_time_absolute(new_year, new_month, new_day, new_hour)
if success:
new_time = get_time()
new_str = _format_time(new_time["year"], new_time["month"], new_time["day"], new_time["hour"])
sections.append(
f"### 📅 Установка времени #{idx}\n"
f"- Старое: {old_str}\n"
f"- **Новое:** {new_str}"
)
else:
sections.append(f"### ❌ Ошибка записи времени #{idx}")
else: # shift
delta_year = req.get("year", 0)
delta_month = req.get("month", 0)
delta_day = req.get("day", 0)
delta_hour = req.get("hour", 0)
if "month" in req:
try:
delta_month = int(req["month"])
except (ValueError, TypeError):
sections.append(f"### ❌ Ошибка в запросе #{idx}\nПри mode='shift' month должен быть числом, получено {req['month']}.")
continue
if delta_year == 0 and delta_month == 0 and delta_day == 0 and delta_hour == 0:
current = get_time()
current_str = _format_time(current["year"], current["month"], current["day"], current["hour"])
sections.append(
f"### 🕰️ Текущее время #{idx}\n"
f"**Время:** {current_str}"
)
else:
new_time = shift_campaign_time(delta_year, delta_month, delta_day, delta_hour)
new_str = _format_time(new_time["year"], new_time["month"], new_time["day"], new_time["hour"])
parts = []
if delta_year:
parts.append(f"{delta_year:+d} лет")
if delta_month:
parts.append(f"{delta_month:+d} месяцев")
if delta_day:
parts.append(f"{delta_day:+d} дней")
if delta_hour:
parts.append(f"{delta_hour:+d} часов")
shift_desc = ", ".join(parts) if parts else "0"
sections.append(
f"### ⏳ Сдвиг времени #{idx}\n"
f"- Сдвиг: {shift_desc}\n"
f"- Старое: {old_str}\n"
f"- **Новое:** {new_str}"
)
final_time = get_time()
holidays_section = _format_holidays_section(
final_time["year"],
final_time["month"],
final_time["day"]
)
result = "\n\n---\n\n".join(sections)
result += f"\n\n---\n\n{holidays_section}"
return result
if __name__ == "__main__":
result = set_campaign_time("тест", [
{"mode": "shift", "day": 1, "hour": 5},
{"mode": "absolute", "year": 1492, "month": 1, "day": 1, "hour": 0},
{"mode": "absolute", "hour": 12},
{"mode": "absolute", "month": "Миртул", "day": 15},
{"mode": "absolute", "month": "Укта"},
{},
])
print(result)

215
time/time_utils.py Normal file
View File

@ -0,0 +1,215 @@
"""
time_utils.py утилиты для работы с игровым временем в Obsidian vault.
Хранит время в файле Campaign_Time.md в корне хранилища, в frontmatter.
Поля: year, month, day, hour.
Месяц = 30 дней, год = 360 дней (упрощение для Forgotten Realms).
Экспортирует:
get_campaign_time() -> dict
set_campaign_time(y,m,d,h) -> bool
shift_campaign_time(...) -> dict
get_week_holidays() -> list[tuple[ int, int, str, str ]]
MONTH_NAMES_RU -> dict[int, str]
MONTH_NAME_TO_NUM -> dict[str, int] # только русские названия
HOLIDAYS -> dict[tuple[int, int], str]
"""
import sys
from pathlib import Path
from typing import Optional, List, Tuple
# --- КОРРЕКЦИЯ ПУТЕЙ ---
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
# -------------------------------------------------------------------
# НАЗВАНИЯ МЕСЯЦЕВ (только русские)
# -------------------------------------------------------------------
MONTH_NAMES_RU = {
1: "Хаммер",
2: "Алтуриак",
3: "Чес",
4: "Тарсак",
5: "Миртул",
6: "Киторн",
7: "Флеймрул",
8: "Элейнт",
9: "Элесиас",
10: "Марпенот",
11: "Уктар",
12: "Найтол",
}
# Обратный словарь: русское название → номер
# Добавляем варианты: "Укта" (без "р") и "Элизис" (синоним Элейнта)
MONTH_NAME_TO_NUM = {}
for num, name in MONTH_NAMES_RU.items():
MONTH_NAME_TO_NUM[name.lower()] = num
# Дополнительные варианты, принятые в сообществе
MONTH_NAME_TO_NUM["укта"] = 11 # вариант без "р"
MONTH_NAME_TO_NUM["элизис"] = 8 # альтернативное написание для Элейнта
# -------------------------------------------------------------------
# ПРАЗДНИКИ И ОСОБЫЕ ДНИ
# -------------------------------------------------------------------
HOLIDAYS = {
# Межкалендарные дни (31-й день месяца)
(1, 31): "🌨️ Середина зимы (Midwinter) — праздник середины зимы",
(4, 31): "🌿 Зеленотравье (Greengrass) — праздник прихода весны",
(7, 31): "☀️ Середина лета (Midsummer) — летнее солнцестояние, праздник огней",
(8, 31): "🌾 Высокая жатва (Highharvesttide) — праздник урожая",
(11, 31): "🌙 Праздник луны (Feast of the Moon) — день поминовения предков",
# Астрономические даты
(3, 19): "🌸 Весеннее равноденствие",
(6, 20): "☀️ Летнее солнцестояние",
(9, 21): "🍂 Осеннее равноденствие",
(12, 20): "❄️ Зимнее солнцестояние",
}
SHIELDMET_DESC = "🛡️ Щитовой сход (Shieldmeet) — високосный день, праздник обновления клятв"
def is_leap_year(year: int) -> bool:
return year % 4 == 0
def get_holiday(month: int, day: int, year: int) -> Optional[str]:
key = (month, day)
if key in HOLIDAYS:
return HOLIDAYS[key]
if month == 6 and day == 31 and is_leap_year(year):
return SHIELDMET_DESC
return None
def get_week_holidays(year: int, month: int, day: int) -> List[Tuple[int, int, str, str]]:
result = []
for offset in range(7):
y, m, d = year, month, day + offset
while d > 30:
d -= 30
m += 1
if m > 12:
m = 1
y += 1
holiday_desc = get_holiday(m, d, y)
if holiday_desc:
if offset == 0:
day_str = "сегодня"
elif offset == 1:
day_str = "завтра"
else:
day_str = f"через {offset} дней"
result.append((m, d, holiday_desc, day_str))
return result
# -------------------------------------------------------------------
# ОСНОВНЫЕ ФУНКЦИИ РАБОТЫ С ВРЕМЕНЕМ
# -------------------------------------------------------------------
CAMPAIGN_TIME_PATH = "Campaign_Time.md"
DEFAULT_TIME = (1492, 1, 1, 0)
def _normalize_time(year: int, month: int, day: int, hour: int) -> tuple[int, int, int, int]:
extra_days, hour = divmod(hour, 24)
day += extra_days
extra_months, day = divmod(day - 1, 30)
day += 1
month += extra_months
extra_years, month = divmod(month - 1, 12)
month += 1
year += extra_years
if year < 1:
year = 1
return year, month, day, hour
def get_campaign_time() -> dict:
try:
data = vault_query("get_frontmatter", {"path": CAMPAIGN_TIME_PATH})
except RuntimeError:
data = None
if data and "frontmatter" in data:
fm = data["frontmatter"]
year = fm.get("year")
month = fm.get("month")
day = fm.get("day")
hour = fm.get("hour")
if all(v is not None for v in (year, month, day, hour)):
return {"year": year, "month": month, "day": day, "hour": hour}
y, m, d, h = DEFAULT_TIME
set_campaign_time(y, m, d, h)
return {"year": y, "month": m, "day": d, "hour": h}
def set_campaign_time(year: int, month: int, day: int, hour: int) -> bool:
if not (1 <= month <= 12) or not (1 <= day <= 30) or not (0 <= hour <= 23):
return False
frontmatter = {"year": year, "month": month, "day": day, "hour": hour}
return write_file(CAMPAIGN_TIME_PATH, frontmatter, "")
def shift_campaign_time(
delta_years: int = 0,
delta_months: int = 0,
delta_days: int = 0,
delta_hours: int = 0
) -> dict:
current = get_campaign_time()
y = current["year"] + delta_years
m = current["month"] + delta_months
d = current["day"] + delta_days
h = current["hour"] + delta_hours
y, m, d, h = _normalize_time(y, m, d, h)
set_campaign_time(y, m, d, h)
return {"year": y, "month": m, "day": d, "hour": h}
def parse_month(month_input) -> int:
"""
Преобразует месяц из числа или строки в номер (1-12).
Поддерживает:
- целые числа 1-12
- строки-числа "1" "12"
- русские названия (регистр не важен):
1: Хаммер
2: Алтуриак
3: Чес
4: Тарсак
5: Миртул
6: Киторн
7: Флеймрул
8: Элейнт
9: Элесиас
10: Марпенот
11: Уктар (допускается "Укта")
12: Найтол
"""
if isinstance(month_input, int):
if 1 <= month_input <= 12:
return month_input
raise ValueError(f"Номер месяца должен быть от 1 до 12, получено {month_input}")
if isinstance(month_input, str):
try:
num = int(month_input)
if 1 <= num <= 12:
return num
raise ValueError(f"Номер месяца должен быть от 1 до 12, получено {num}")
except ValueError:
key = month_input.lower().strip()
if key in MONTH_NAME_TO_NUM:
return MONTH_NAME_TO_NUM[key]
raise ValueError(f"Неизвестное название месяца: '{month_input}'")
raise TypeError(f"month должен быть int или str, получено {type(month_input)}")

135
vault_utils.py Normal file
View File

@ -0,0 +1,135 @@
"""
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) поиск пути к файлу по имени/алиасу
"""
import re
import requests as http_requests
from typing import Optional
# ---------------------------------------------------------------------------
# Настройки
# ---------------------------------------------------------------------------
BACKEND_URL = "http://localhost:5000/api"
VAULT_QUERY_TIMEOUT = 12 # секунд
# ---------------------------------------------------------------------------
# Базовый клиент
# ---------------------------------------------------------------------------
def vault_query(query_type: str, payload: dict) -> any:
"""
Отправляет запрос к Obsidian vault через бэкенд-прокси.
Блокирующий вызов: ждёт пока Obsidian выполнит операцию.
Бросает RuntimeError при недоступности бэкенда или timeout.
Поля:
query_type: тип операции
payload: параметры операции
"""
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 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()
# ---------------------------------------------------------------------------
# Запись данных (универсальные)
# ---------------------------------------------------------------------------
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
def create_file(file_path: str, content: str) -> bool:
"""
Создаёт файл в Obsidian vault (перезаписывает, если существует).
Промежуточные папки создаются автоматически.
"""
result = vault_query("create_file", {
"path": file_path,
"content": content
})
if isinstance(result, dict):
return result.get("success", False)
return False
# ---------------------------------------------------------------------------
# Нормализация и резолвинг wikilink
# ---------------------------------------------------------------------------
def normalize_wikilink(s: str) -> str:
"""
Приводит wikilink к нижнему регистру без скобок и алиасов.
Примеры:
'[[Waterdeep]]' 'waterdeep'
'[[Waterdeep|City]]' 'waterdeep'
'Waterdeep' 'waterdeep'
'' ''
"""
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.
"""
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