#!/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'{content}' 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'(?' 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()