Sync infra config: HA/Zabbix relocation, Immich storage move, new services

Accumulated uncommitted infra changes:
- Caddyfile: repoint HA/Zabbix to 192.168.1.4/.3, add ~20 new site routes
- Immich: move media to /mnt/smsg, enable CUDA ML, mem limits, rewrite backup.sh
- Add service stacks: agap-mcp, anki, family, freshrss, iperf3, kanboard,
  linkwarden, qbittorrent, radicale, syncthing, vikunja, windows
- openwebui: enable API keys; ollama: drop CPU fallback
- seafile/zabbix: extra_hosts entries; matrix: add user juris
- Remove pihole stack and stale wiki/migrate.py
- Ignore marketplace-mcp (standalone repo) and linkwarden runtime data

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
Alvis
2026-07-04 13:28:04 +00:00
parent 4363130163
commit b8efe4732d
51 changed files with 2813 additions and 374 deletions

458
family/migrate.py Normal file
View File

@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""OtterWiki → MediaWiki migration script."""
import argparse
import re
import subprocess
from pathlib import Path
import requests
REPO = Path('/mnt/ssd/dbs/otter/app-data/repository')
API = 'http://localhost:8099/api.php'
_FN_DEF = re.compile(r'^\[(\^[^\]]+)\]:\s*(.*)')
# Cached pandoc availability (None = not yet checked)
_PANDOC_AVAILABLE: bool | None = None
def _cap(s: str) -> str:
"""Title-case: capitalize first letter of each word."""
return s.title() if s else s
def _pandoc_available() -> bool:
global _PANDOC_AVAILABLE
if _PANDOC_AVAILABLE is None:
try:
_PANDOC_AVAILABLE = subprocess.run(
['pandoc', '--version'], capture_output=True
).returncode == 0
except FileNotFoundError:
_PANDOC_AVAILABLE = False
return _PANDOC_AVAILABLE
# ---------------------------------------------------------------------------
# MediaWiki session
# ---------------------------------------------------------------------------
def mw_login(user: str, password: str):
s = requests.Session()
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'type': 'login', 'format': 'json'})
token = r.json()['query']['tokens']['logintoken']
s.post(API, data={'action': 'login', 'lgname': user, 'lgpassword': password,
'lgtoken': token, 'format': 'json'})
r = s.get(API, params={'action': 'query', 'meta': 'tokens', 'format': 'json'})
csrf = r.json()['query']['tokens']['csrftoken']
return s, csrf
# ---------------------------------------------------------------------------
# Title determination
# ---------------------------------------------------------------------------
def page_title(md_path: Path) -> str:
parts = md_path.relative_to(REPO).parts
if md_path.name == 'home.md' and len(parts) == 1:
return 'Заглавная страница'
return _cap(md_path.stem)
# ---------------------------------------------------------------------------
# Markdown → wikitext conversion
# ---------------------------------------------------------------------------
def convert_pandoc(text: str) -> str:
return subprocess.run(
['pandoc', '-f', 'markdown', '-t', 'mediawiki'],
input=text, capture_output=True, text=True
).stdout
def convert_python(text: str, skip_info: bool = False) -> str:
lines = text.split('\n')
# Collect footnote definitions in one pass
footnotes: dict[str, str] = {}
for line in lines:
m = _FN_DEF.match(line)
if m:
footnotes[m.group(1)] = m.group(2)
def replace_fn(m):
key = m.group(0)[1:-1] # strip outer [ ] to match footnotes dict keys
content = footnotes.get(key, m.group(0))
content = re.sub(r'\[([^\]^][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', content)
return f'<ref>{content}</ref>'
out = []
for line in lines:
if _FN_DEF.match(line):
continue
m = re.match(r'^(#{1,6})\s+(.*)', line)
if m:
eq = '=' * len(m.group(1))
out.append(f'{eq} {m.group(2)} {eq}')
continue
if re.match(r'^---+$', line.strip()):
out.append('----')
continue
line = re.sub(r'^(\s*)-(\s)', r'\1*\2', line)
line = re.sub(r'\*\*\*(.+?)\*\*\*', r"'''''\1'''''", line)
line = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", line)
line = re.sub(r'\*(.+?)\*', r"''\1''", line)
line = re.sub(r'(?<!!)\[([^\]^!][^\]]*)\]\(([^)]+)\)', r'[\2 \1]', line)
line = re.sub(r'\[\^\S+?\]', replace_fn, line)
out.append(line)
result = convert_tables('\n'.join(out), skip_info=skip_info)
if footnotes:
result += '\n<references />'
return result
def _split_cells(line: str) -> list[str]:
"""Split a markdown table row on | but not inside [[ ]]."""
cells = []
depth = 0
current = []
i = 0
# Strip leading/trailing |
line = line.strip()
if line.startswith('|'):
line = line[1:]
if line.endswith('|'):
line = line[:-1]
while i < len(line):
if line[i:i+2] == '[[':
depth += 1
current.append('[[')
i += 2
elif line[i:i+2] == ']]':
depth -= 1
current.append(']]')
i += 2
elif line[i] == '|' and depth == 0:
cells.append(''.join(current).strip())
current = []
i += 1
else:
current.append(line[i])
i += 1
cells.append(''.join(current).strip())
return cells
def convert_tables(text: str, skip_info: bool = False) -> str:
lines = text.split('\n')
out = []
in_table = False
skip_table = False
for line in lines:
if re.match(r'^\|', line):
cells = _split_cells(line)
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
# Separator row: start or continue table
if not in_table:
header_line = out.pop() if out else ''
hcells = _split_cells(header_line)
# Blank-header table (all-empty header cells) = OtterWiki info table
if skip_info and all(c == '' for c in hcells):
skip_table = True
in_table = True
else:
out += ['{| class="wikitable"', '|-', '! ' + ' !! '.join(hcells)]
in_table = True
skip_table = False
if not skip_table:
out.append('|-')
else:
if in_table:
if not skip_table:
out.append('|-')
out.append('| ' + ' || '.join(cells))
# else: skip info table row
else:
out.append(line)
else:
if in_table:
if not skip_table:
out.append('|}')
in_table = False
skip_table = False
out.append(line)
if in_table and not skip_table:
out.append('|}')
return '\n'.join(out)
_PERSON_FIELD_MAP = {
'родился': 'родился', 'родилась': 'родился',
'умер': 'умер', 'умерла': 'умер',
'отец': 'отец', 'мать': 'мать',
'супруг': 'супруг', 'супруга': 'супруг', 'муж': 'супруг', 'жена': 'супруг',
'дети': 'дети', 'ребёнок': 'дети',
'братья': 'братья', 'брат': 'братья', 'сестра': 'братья',
'сёстры': 'братья', 'сестры': 'братья',
'место рождения': 'место_рождения', 'место_рождения': 'место_рождения',
'прочее': 'прочее',
}
_PERSONA_PARAM_ORDER = ['родился', 'место_рождения', 'умер', 'отец', 'мать', 'супруг', 'дети', 'братья', 'прочее']
_PLACE_FIELD_MAP = {
'тип': 'тип',
'статус': 'статус',
'страна': 'страна',
'регион': 'регион', 'область': 'регион',
'район': 'район', 'расположение': 'район', 'самоуправление': 'район',
'река': 'река',
'основана': 'основана', 'основан': 'основана',
'население': 'население',
'адрес': 'адрес',
'период': 'период', 'годы': 'период',
'жильцы': 'жильцы', 'жильцы/семья': 'жильцы', 'семейное имя': 'прочее',
'латв. название': 'назв_латыш',
'белор. название': 'назв_белор',
'координаты': 'координаты',
'сайт': 'сайт',
'телефон': 'телефон', 'email': 'телефон',
'полное название': 'прочее', 'классы': 'прочее',
'штаб-квартира': 'прочее', 'сотрудников': 'прочее',
}
_PLACE_PARAM_ORDER = ['тип', 'статус', 'страна', 'регион', 'район', 'река', 'основана',
'население', 'адрес', 'период', 'жильцы', 'назв_латыш', 'назв_белор',
'координаты', 'сайт', 'телефон', 'прочее']
def _extract_infobox(text: str, title: str, name_param: str, template: str,
field_map: dict, param_order: list) -> tuple[str, str]:
"""Extract photo + blank-header info table; return ({{Template|...}}, cleaned_text)."""
lines = text.split('\n')
photo = None
fields: dict[str, str] = {}
remove: set[int] = set()
for i, line in enumerate(lines):
if re.match(r'^\|', line):
break # reached info table — stop looking for photo
m = re.match(r'^\s*\[!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(?:\?[^)]*)?\)\]', line)
if m:
photo = m.group(1)
remove.add(i)
break
in_table = False
for i, line in enumerate(lines):
if re.match(r'^\|', line):
cells = _split_cells(line)
if all(re.match(r'^:?-+:?$', c) for c in cells if c):
if not in_table:
prev = i - 1
if prev >= 0 and re.match(r'^\|', lines[prev]):
hcells = _split_cells(lines[prev])
if all(c == '' for c in hcells):
in_table = True
remove.add(prev)
if in_table:
remove.add(i)
elif in_table:
remove.add(i)
if len(cells) >= 2:
key = re.sub(r'\*\*(.+?)\*\*', r'\1', cells[0]).strip().lower()
val = re.sub(r'\*\*(.+?)\*\*', r"'''\1'''", cells[1].strip())
param = field_map.get(key)
if param and param not in fields:
fields[param] = val
elif in_table:
in_table = False
if not photo and not fields:
return '', text
parts = ['{{' + template, f'| {name_param:<16} = {title}']
if photo:
parts.append(f'| фото = {photo}')
for param in param_order:
if param in fields:
parts.append(f'| {param:<16} = {fields[param]}')
infobox = '\n'.join(parts) + '\n}}'
cleaned = '\n'.join(line for i, line in enumerate(lines) if i not in remove)
return infobox, cleaned
def extract_person_infobox(text: str, title: str) -> tuple[str, str]:
return _extract_infobox(text, title, 'имя', 'Персона', _PERSON_FIELD_MAP, _PERSONA_PARAM_ORDER)
def extract_place_infobox(text: str, title: str) -> tuple[str, str]:
return _extract_infobox(text, title, 'название', 'Место', _PLACE_FIELD_MAP, _PLACE_PARAM_ORDER)
def strip_first_heading(text: str) -> str:
"""Remove the first H1 line — MW displays the page title itself."""
return re.sub(r'^#[^#][^\n]*\n?', '', text, count=1)
def convert(text: str, skip_info: bool = False, is_place: bool = False, title: str = '') -> str:
text = strip_first_heading(text)
infobox = ''
if skip_info:
infobox, text = extract_person_infobox(text, title)
elif is_place:
infobox, text = extract_place_infobox(text, title)
result = convert_pandoc(text) if _pandoc_available() else convert_python(text, skip_info=skip_info)
if infobox:
result = infobox + '\n' + result.lstrip('\n')
return result
# ---------------------------------------------------------------------------
# Post-processing
# ---------------------------------------------------------------------------
def fix_links(text: str) -> str:
pattern = (r'\[\[([^\]|]+)\|'
r'(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)'
r'/([^\]]+)\]\]')
def replace_link(m):
display = m.group(1).strip()
page = _cap(m.group(2).strip().lower())
if display.lower() == page.lower():
return f'[[{page}]]'
return f'[[{page}|{display}]]'
text = re.sub(pattern, replace_link, text)
# Also handle bare section paths: [[Section/PageName]] → [[PageName]]
text = re.sub(
r'\[\[(?:Люди|Места|Воспоминания|люди|места|воспоминания|Место)/([^\]|]+)\]\]',
lambda m: f'[[{m.group(1).strip().lower().title()}]]',
text
)
return text
def fix_images(text: str) -> str:
# Handle linked images: [![](./file.jpg?thumbnail=400)](./file.jpg)
# and plain images: ![alt](./file.jpg?thumbnail=400)
pattern = r'(?:\[)?!\[[^\]]*\]\(\./(?:[^/)]+/)?([^)?]+?)(\?[^)]*)?\)(?:\]\([^)]*\))?'
def replace_img(m):
filename = m.group(1)
size_m = re.search(r'thumbnail=(\d+)', m.group(2) or '')
return f'[[File:{filename}|{size_m.group(1)}px]]' if size_m else f'[[File:{filename}]]'
return re.sub(pattern, replace_img, text)
_CATEGORY_MAP = {'люди': 'Люди', 'места': 'Места', 'воспоминания': 'Воспоминания'}
def category_suffix(md_path: Path) -> str:
parts = md_path.relative_to(REPO).parts
if len(parts) == 1:
return '' if md_path.name == 'home.md' else '\n\n[[Category:Статьи]]'
cat = _CATEGORY_MAP.get(parts[0].lower())
return f'\n\n[[Category:{cat}]]' if cat else ''
# ---------------------------------------------------------------------------
# MW operations
# ---------------------------------------------------------------------------
def post_page(session, csrf: str, title: str, text: str, dry_run: bool) -> bool:
if dry_run:
print(f'[DRY] {title}')
return True
data = session.post(API, data={
'action': 'edit', 'title': title, 'text': text,
'token': csrf, 'format': 'json'
}).json()
if 'error' in data:
print(f'[ERR] {title}: {data["error"].get("info", data["error"])}')
return False
print(f'[OK] {title}')
return True
def upload_image(session, csrf: str, image_path: Path, dry_run: bool) -> bool:
basename = image_path.name
if dry_run:
print(f'[DRY] File:{basename}')
return True
with open(image_path, 'rb') as f:
data = session.post(API, data={
'action': 'upload', 'filename': basename,
'token': csrf, 'format': 'json', 'ignorewarnings': '1'
}, files={'file': f}).json()
if 'error' in data:
print(f'[ERR] File:{basename}: {data["error"].get("info", data["error"])}')
return False
if data.get('upload', {}).get('result') == 'Success':
print(f'[OK] File:{basename}')
else:
print(f'[SKIP] File:{basename} (already exists or no change)')
return True
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def collect_images() -> list[Path]:
images = []
for folder in ('люди', 'места'):
p = REPO / folder
if p.exists():
for ext in ('*.jpg', '*.jpeg', '*.JPG', '*.JPEG', '*.png', '*.PNG'):
images.extend(p.rglob(ext))
return images
def main():
parser = argparse.ArgumentParser(description='Migrate OtterWiki to MediaWiki')
parser.add_argument('--user', required=True)
parser.add_argument('--password', required=True)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
session = csrf = None
if not args.dry_run:
session, csrf = mw_login(args.user, args.password)
pages_ok = pages_err = images_ok = images_err = 0
for md_path in sorted(REPO.rglob('*.md')):
title = page_title(md_path)
raw = md_path.read_text(encoding='utf-8')
parts = md_path.relative_to(REPO).parts
is_people = len(parts) > 0 and parts[0].lower() == 'люди'
is_place = len(parts) > 0 and parts[0].lower() == 'места'
wikitext = convert(raw, skip_info=is_people, is_place=is_place, title=title)
wikitext = fix_links(wikitext)
wikitext = fix_images(wikitext)
wikitext += category_suffix(md_path)
if post_page(session, csrf, title, wikitext, args.dry_run):
pages_ok += 1
else:
pages_err += 1
for img in collect_images():
if upload_image(session, csrf, img, args.dry_run):
images_ok += 1
else:
images_err += 1
print(f'\nDone: {pages_ok} pages, {images_ok} images, {pages_err + images_err} errors')
if __name__ == '__main__':
main()