From 14e6222cf187a221ac9134ad32f4f25502065530 Mon Sep 17 00:00:00 2001 From: dimitrievgs Date: Wed, 8 Jul 2026 23:37:24 +0300 Subject: [PATCH] Add Time Tool --- faction_turns/faction_utils.py | 154 ++++------------------- time/get_campaign_time.py | 58 +++++++++ time/set_campaign_time.py | 223 +++++++++++++++++++++++++++++++++ time/time_utils.py | 215 +++++++++++++++++++++++++++++++ vault_utils.py | 135 ++++++++++++++++++++ 5 files changed, 657 insertions(+), 128 deletions(-) create mode 100644 time/get_campaign_time.py create mode 100644 time/set_campaign_time.py create mode 100644 time/time_utils.py create mode 100644 vault_utils.py diff --git a/faction_turns/faction_utils.py b/faction_turns/faction_utils.py index 6e487c2..5ae33d1 100644 --- a/faction_turns/faction_utils.py +++ b/faction_turns/faction_utils.py @@ -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 \ No newline at end of file + return fm diff --git a/time/get_campaign_time.py b/time/get_campaign_time.py new file mode 100644 index 0000000..737967c --- /dev/null +++ b/time/get_campaign_time.py @@ -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("тест")) \ No newline at end of file diff --git a/time/set_campaign_time.py b/time/set_campaign_time.py new file mode 100644 index 0000000..47a2c4b --- /dev/null +++ b/time/set_campaign_time.py @@ -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" – день (1–30). При mode="shift" – сдвиг в днях (может быть отрицательным). + hour: При mode="absolute" – час (0–23). При 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) \ No newline at end of file diff --git a/time/time_utils.py b/time/time_utils.py new file mode 100644 index 0000000..d4d0b86 --- /dev/null +++ b/time/time_utils.py @@ -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)}") \ No newline at end of file diff --git a/vault_utils.py b/vault_utils.py new file mode 100644 index 0000000..0c00204 --- /dev/null +++ b/vault_utils.py @@ -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 \ No newline at end of file