Add Time Tool
This commit is contained in:
parent
ae9b31a23e
commit
14e6222cf1
|
|
@ -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
58
time/get_campaign_time.py
Normal 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
223
time/set_campaign_time.py
Normal 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" – день (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)
|
||||
215
time/time_utils.py
Normal file
215
time/time_utils.py
Normal 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
135
vault_utils.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user