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:
6
family/Dockerfile
Normal file
6
family/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM mediawiki:latest
|
||||
|
||||
RUN apt-get update && apt-get install -y ffmpeg unzip && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -sL "https://extdist.wmflabs.org/dist/extensions/TimedMediaHandler-REL1_44-ef5edcf.tar.gz" \
|
||||
| tar -xz -C /var/www/html/extensions/
|
||||
BIN
family/IMG_0448.JPG
Normal file
BIN
family/IMG_0448.JPG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
165
family/LocalSettings.php
Normal file
165
family/LocalSettings.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
# This file was automatically generated by the MediaWiki 1.45.3
|
||||
# installer. If you make manual changes, please keep track in case you
|
||||
# need to recreate them later.
|
||||
#
|
||||
# See includes/MainConfigSchema.php for all configurable settings
|
||||
# and their default values, but don't forget to make changes in _this_
|
||||
# file, not there.
|
||||
#
|
||||
# Further documentation for configuration settings may be found at:
|
||||
# https://www.mediawiki.org/wiki/Manual:Configuration_settings
|
||||
|
||||
# Protect against web entry
|
||||
if ( !defined( 'MEDIAWIKI' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
## Uncomment this to disable output compression
|
||||
# $wgDisableOutputCompression = true;
|
||||
|
||||
$wgSitename = "РодоВики";
|
||||
|
||||
## The URL base path to the directory containing the wiki;
|
||||
## defaults for all runtime URL paths are based off of this.
|
||||
## For more information on customizing the URLs
|
||||
## (like /w/index.php/Page_title to /wiki/Page_title) please see:
|
||||
## https://www.mediawiki.org/wiki/Manual:Short_URL
|
||||
$wgScriptPath = "";
|
||||
|
||||
## The protocol and server name to use in fully-qualified URLs
|
||||
$wgServer = "https://family.alogins.net";
|
||||
|
||||
## The URL path to static resources (images, scripts, etc.)
|
||||
$wgResourceBasePath = $wgScriptPath;
|
||||
|
||||
## The URL paths to the logo. Make sure you change this from the default,
|
||||
## or else you'll overwrite your logo when you upgrade!
|
||||
$wgLogos = [
|
||||
'1x' => "$wgResourceBasePath/images/logo.jpg",
|
||||
'icon' => "$wgResourceBasePath/images/logo.jpg",
|
||||
];
|
||||
|
||||
## UPO means: this is also a user preference option
|
||||
|
||||
$wgEnableEmail = true;
|
||||
$wgEnableUserEmail = true; # UPO
|
||||
|
||||
$wgEmergencyContact = "";
|
||||
$wgPasswordSender = "";
|
||||
|
||||
$wgEnotifUserTalk = false; # UPO
|
||||
$wgEnotifWatchlist = false; # UPO
|
||||
$wgEmailAuthentication = true;
|
||||
|
||||
## Database settings
|
||||
$wgDBtype = "mysql";
|
||||
$wgDBserver = "db";
|
||||
$wgDBname = "mediawiki";
|
||||
$wgDBuser = "mw_k7px2q";
|
||||
$wgDBpassword = "Vt9#mLqR4wXn8bZ2";
|
||||
|
||||
# MySQL specific settings
|
||||
$wgDBprefix = "fw";
|
||||
$wgDBssl = false;
|
||||
|
||||
# MySQL table options to use during installation or update
|
||||
$wgDBTableOptions = "ENGINE=InnoDB, DEFAULT CHARSET=binary";
|
||||
|
||||
# Shared database table
|
||||
# This has no effect unless $wgSharedDB is also set.
|
||||
$wgSharedTables[] = "actor";
|
||||
|
||||
## Shared memory settings
|
||||
$wgMainCacheType = CACHE_ACCEL;
|
||||
$wgMemCachedServers = [];
|
||||
|
||||
## To enable image uploads, make sure the 'images' directory
|
||||
## is writable, then set this to true:
|
||||
$wgEnableUploads = true;
|
||||
$wgMaxUploadSize = 20 * 1024 * 1024; // 20MB
|
||||
$wgFileExtensions = array_merge( $wgFileExtensions, [
|
||||
'png', 'gif', 'jpg', 'jpeg', 'webp',
|
||||
'mp4', 'webm', 'ogv',
|
||||
'mp3', 'ogg', 'oga', 'wav', 'flac',
|
||||
] );
|
||||
$wgUseImageMagick = true;
|
||||
$wgImageMagickConvertCommand = "/usr/bin/convert";
|
||||
|
||||
# InstantCommons allows wiki to use images from https://commons.wikimedia.org
|
||||
$wgUseInstantCommons = false;
|
||||
|
||||
# Periodically send a pingback to https://www.mediawiki.org/ with basic data
|
||||
# about this MediaWiki instance. The Wikimedia Foundation shares this data
|
||||
# with MediaWiki developers to help guide future development efforts.
|
||||
$wgPingback = true;
|
||||
|
||||
# Site language code, should be one of the list in ./includes/languages/data/Names.php
|
||||
$wgLanguageCode = "ru";
|
||||
|
||||
# Time zone
|
||||
$wgLocaltimezone = "UTC";
|
||||
|
||||
## Set $wgCacheDirectory to a writable directory on the web server
|
||||
## to make your wiki go slightly faster. The directory should not
|
||||
## be publicly accessible from the web.
|
||||
#$wgCacheDirectory = "$IP/cache";
|
||||
|
||||
$wgSecretKey = "5156b70f5486793efade503a2301caf53b9fe241505868a90e33509814fb7d1f";
|
||||
|
||||
# Changing this will log out all existing sessions.
|
||||
$wgAuthenticationTokenVersion = "1";
|
||||
|
||||
# Bust ResourceLoader CSS cache
|
||||
$wgCacheEpoch = '20260403000001';
|
||||
|
||||
# Site upgrade key. Must be set to a string (default provided) to turn on the
|
||||
# web installer while LocalSettings.php is in place
|
||||
$wgUpgradeKey = "da11c2f2774f498f";
|
||||
|
||||
## For attaching licensing metadata to pages, and displaying an
|
||||
## appropriate copyright notice / icon. GNU Free Documentation
|
||||
## License and Creative Commons licenses are supported so far.
|
||||
$wgRightsPage = ""; # Set to the title of a wiki page that describes your license/copyright
|
||||
$wgRightsUrl = "";
|
||||
$wgRightsText = "";
|
||||
$wgRightsIcon = "";
|
||||
|
||||
# Path to the GNU diff3 utility. Used for conflict resolution.
|
||||
$wgDiff3 = "/usr/bin/diff3";
|
||||
|
||||
## Default skin: you can change the default skin. Use the internal symbolic
|
||||
## names, e.g. 'vector' or 'monobook':
|
||||
$wgDefaultSkin = "vector-2022";
|
||||
|
||||
# Enabled skins.
|
||||
# The following skins were automatically enabled:
|
||||
wfLoadSkin( 'MinervaNeue' );
|
||||
wfLoadSkin( 'MonoBook' );
|
||||
wfLoadSkin( 'Timeless' );
|
||||
wfLoadSkin( 'Vector' );
|
||||
|
||||
|
||||
wfLoadExtension( 'Cite' );
|
||||
wfLoadExtension( 'MultimediaViewer' );
|
||||
wfLoadExtension( 'ParserFunctions' );
|
||||
wfLoadExtension( 'VisualEditor' );
|
||||
wfLoadExtension( 'TimedMediaHandler' );
|
||||
|
||||
$wgDefaultUserOptions['visualeditor-enable'] = 1;
|
||||
$wgDefaultUserOptions['visualeditor-editor'] = 'visualeditor';
|
||||
$wgVisualEditorParsoidAutoConfig = true;
|
||||
|
||||
# End of automatically generated settings.
|
||||
# Add more configuration options below.
|
||||
|
||||
# Restrict all access to logged-in users only
|
||||
$wgGroupPermissions['*']['read'] = false;
|
||||
$wgGroupPermissions['*']['edit'] = false;
|
||||
$wgGroupPermissions['*']['createaccount'] = false;
|
||||
|
||||
# Only admins can create accounts
|
||||
$wgGroupPermissions['sysop']['createaccount'] = true;
|
||||
BIN
family/__pycache__/migrate.cpython-312.pyc
Normal file
BIN
family/__pycache__/migrate.cpython-312.pyc
Normal file
Binary file not shown.
32
family/docker-compose.yml
Normal file
32
family/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
mediawiki:
|
||||
build: .
|
||||
image: mediawiki-tmh:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8099:80"
|
||||
volumes:
|
||||
- /mnt/ssd/dbs/wiki/mediawiki_images:/var/www/html/images
|
||||
- ./LocalSettings.php:/var/www/html/LocalSettings.php # uncomment after initial setup
|
||||
- ./IMG_0448.JPG:/var/www/html/images/logo.jpg
|
||||
- ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini
|
||||
environment:
|
||||
MEDIAWIKI_DB_HOST: db
|
||||
MEDIAWIKI_DB_NAME: mediawiki
|
||||
MEDIAWIKI_DB_USER: mw_k7px2q
|
||||
MEDIAWIKI_DB_PASSWORD: Vt9#mLqR4wXn8bZ2
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
db:
|
||||
image: mariadb:lts
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: mediawiki
|
||||
MYSQL_USER: mw_k7px2q
|
||||
MYSQL_PASSWORD: Vt9#mLqR4wXn8bZ2
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
|
||||
volumes:
|
||||
- /mnt/ssd/dbs/wiki/mediawiki_db:/var/lib/mysql
|
||||
|
||||
|
||||
458
family/migrate.py
Normal file
458
family/migrate.py
Normal 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)
|
||||
# and plain images: 
|
||||
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()
|
||||
2
family/uploads.ini
Normal file
2
family/uploads.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
upload_max_filesize = 20M
|
||||
post_max_size = 25M
|
||||
Reference in New Issue
Block a user