Uploads a file from the disk of the machine the local wiki runs on, by its name, and puts it on the page. Write the call, save, and a few seconds later the call is gone and the picture is there: no file picker, no drag and drop, no going through the 𝕐 composer or MsUpload.
{{upload|figs/race-bundler-0LSUBb-1440.webm}}
The name is looked up exactly as showme looks up a file: a bare name (race-bundler-0LSUBb-1440.webm), the end of a path (figs/race-bundler-0LSUBb-1440.webm), a glob, or a full path. When several files answer, the one taken is the one showme would open—the newest—and {{upload|NAME|2}} takes the second, as showme NAME 2 does.
What the call becomes:
| File | On an ordinary page | On a 𝕐 or ℤ month page |
|---|---|---|
| picture | <center>[[File:Name.png]]</center> |
<center>[[File:Name.png|200px]]</center>
|
| video (mp4, webm) | {{video|Name.webm}} |
{{video|Name.webm|size=400}}
|
{{pdf|Name.pdf}} |
{{pdf|Name.pdf}}
| |
| anything else | [[Media:Name.zip]] |
[[Media:Name.zip]]
|
On a 𝕐 or ℤ page it writes what the composer itself writes for a dropped file. A video is a {{video}} and not a File: link because this wiki has no TimedMediaHandler: a File: link to a clip does not play.
The file goes up under its own name (MediaWiki capitalises the first letter). Other parameters:
{{upload|x.png|400px|left}}—anything else given is passed on: [[File:X.png|400px|left]]. A size given replaces the 𝕐 default. For a video, 300px becomes size=300, and loop=1, poster= etc. go to {{video}}.wz=caption—the picture inside a <wz> tooltip, as the composer's wz= line does.as=Other name.png—upload under another name.replace=yes—a file of the same name already on the wiki with different content is not overwritten without it. The same bytes already there are simply used.When it cannot be done—no such file, a name already taken—the call stays, gets error= with the reason, and shows in red. Saving the page again tries again.
Local wiki only: the files are on that disk. lwupload is a Python script, and saving a page whose text calls this template sets off a chain:
PageSaveComplete hook in LocalSettings.php, in the block that only runs on localhost, writes the revision number and the page title into ~/.cache/lwupload/spool once the response has gone out. It skips lwupload's own saves, recognised by their summary;lwupload.path watches that directory and starts lwupload.service as soon as anything is in it;~/bin/lwupload --spool, which empties the spool, reads each page, finds the file of every call with showme, uploads it and edits the calls out: one edit per page, flagged bot, summary lwupload: NAME → File:X, uploaded. The edit is made against the revision it read, so a save of yours in between makes it read the page again rather than overwrite you;lwupload (MediaWiki:Gadget-lwupload.js), on a page showing a call still waiting, watches the page's latest revision for 90 s and reloads when it moves on.A desktop notice says what went up and what did not. The wiki only ever sees the name as it is written in the call, never where on the disk the file was found: that goes to the notice and to the log.
A call written online, in a 𝕐 post from the phone say, waits there with its placeholder until syncy brings the page down: syncy's save sets the chain off like any other, and llw2lw carries the result back up.
A call inside <nowiki>, <pre>, <syntaxhighlight> or <source> is left alone. One inside a comment is done, which is what a scheduled 𝕐 post needs: its picture is ready when the post comes out.
lwupload # every page that still calls {{upload}}
lwupload 'October (2026)' # just these pages
lwupload -n 'October (2026)' # say what would be done, upload and edit nothing
lwupload -v # version; -h for the help
What it did is in ~/.cache/lwupload/log. Whether the watcher is there: systemctl --user status lwupload.path; what a run printed: journalctl --user -u lwupload.service. It logs in with the account in ~/.syncy, the one syncy uses, so the script itself holds no password.
A name that is not found costs a few seconds: showme refreshes the locate database (sudo updatedb) before giving up, in case the file is too new to be in it.
#!/usr/bin/env python3
"""lwupload — v1.0.0 — {{upload|NAME}} on laussywiki becomes the file itself.
Write {{upload|figs/race-bundler-0LSUBb-1440.webm}} in any page of the LOCAL
wiki and save. The save drops "revid<TAB>title" into the spool directory
(LocalSettings.php, PageSaveComplete, local only); the systemd user unit
lwupload.path sees it and runs `lwupload --spool`, which for every call:
1. finds the file with `showme -p NAME [N]` — the same lookup as showme
(a path suffix, a bare name, a glob, an absolute path), and the same
choice when several files answer: the first one, i.e. the one showme
would open; {{upload|NAME|2}} takes the second, as `showme NAME 2` does;
2. uploads it as File:<its own name> (|as=Other.png renames it). A file of
that name already on the wiki with the same bytes is used as it is; with
different bytes it is NOT overwritten unless the call says |replace=yes;
3. writes, in place of the call,
picture <center>[[File:Name.png]]</center>
video {{video|Name.webm}} (no TimedMediaHandler: a File: link
to a clip does not play here)
pdf {{pdf|Name.pdf}}
other [[Media:Name.zip]]
On a 𝕐 or ℤ month page ("October (2026)", "Z/October (2026)") it writes
what the composer would have: a picture at |200px, a video at size=400.
Other parameters go through: {{upload|x.png|400px|left}} →
[[File:X.png|400px|left]], {{upload|clip.webm|300px|loop=1}} →
{{video|Clip.webm|size=300|loop=1}}; |wz=caption puts the picture in a
<wz> tooltip as the composer's wz= line does.
A call that cannot be done (no such file, a name clash) gets |error=REASON
added, which Template:Upload shows in red; the next save of the page tries
again. Calls inside <nowiki>, <pre>, <syntaxhighlight>, <source> are text, not
calls; calls inside <!-- --> ARE done (a scheduled 𝕐 post sits in a comment).
lwupload every page that transcludes Template:Upload
lwupload TITLE... those pages
lwupload --spool what the wiki left in the spool (the systemd unit)
lwupload -n ... say what would be done, upload and edit nothing
lwupload -v version
Credentials: ~/.syncy (user=, pass=), as syncy. Log: ~/.cache/lwupload/log.
The full path of an uploaded file goes to the log and the desktop notice
only; the wiki (summary, file description) sees the name as typed.
Versions
1.0.0 2026-10-15 first version
"""
import fcntl
import hashlib
import json
import mimetypes
import os
import re
import subprocess
import sys
import time
from pathlib import Path
import requests
VERSION = '1.0.0'
API = 'http://localhost/laussywiki/api.php'
USER_AGENT = 'lwupload/' + VERSION
HOME = Path.home()
STATE = HOME / '.cache' / 'lwupload'
SPOOL = STATE / 'spool' # LocalSettings.php writes here; keep in step
LOG = STATE / 'log'
LOCK = STATE / 'lock'
SHOWME = HOME / 'bin' / 'showme'
SUMMARY = 'lwupload' # the hook skips saves whose summary starts so
# 𝕐 and ℤ month pages, as MediaWiki:Gadget-microblog.js names them.
MONTH_PAGE = re.compile(r'^(?:Z/)?(?:January|February|March|April|May|June|July|'
r'August|September|October|November|December) \(\d{4}\)$')
Y_IMAGE_SIZE = '200px' # composer imageSnippet
Y_VIDEO_SIZE = '400' # composer videoSnippet
VIDEO_EXT = {'mp4', 'webm', 'ogv'}
CALL = re.compile(r'\{\{\s*[Uu]pload\s*\|([^{}]*)\}\}')
# Text where {{upload|…}} is quoted, not called.
VERBATIM = re.compile(r'<(nowiki|pre|syntaxhighlight|source)\b[^>]*>.*?</\1\s*>',
re.S | re.I)
SIZE_PARAM = re.compile(r'^(\d*x?\d+px|upright(=.*)?|full|thumb|thumbnail|frame|frameless)$')
def log(msg):
STATE.mkdir(parents=True, exist_ok=True)
with open(LOG, 'a', encoding='utf-8') as f:
f.write(time.strftime('%Y-%m-%d %H:%M:%S ') + msg + '\n')
def notify(title, body, urgent=False):
try:
subprocess.run(['notify-send', '-a', 'lwupload', '-i',
'dialog-error' if urgent else 'document-send',
title, body], timeout=10,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
# ---- API ---------------------------------------------------------------------
def load_creds():
u, p = os.environ.get('SYNCY_USER'), os.environ.get('SYNCY_PASS')
if u and p:
return u, p
d = {}
path = HOME / '.syncy'
if path.exists():
for line in open(path, encoding='utf-8'):
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
d[k.strip().lower()] = v.strip()
if d.get('user') and d.get('pass'):
return d['user'], d['pass']
sys.exit('lwupload: no credentials (~/.syncy with user=, pass=, as syncy)')
class Wiki:
def __init__(self):
self.s = requests.Session()
self.s.headers['User-Agent'] = USER_AGENT
self.token = None
def get(self, **p):
p.update(format='json', formatversion=2)
return self.s.get(API, params=p, timeout=60).json()
def post(self, files=None, timeout=60, **p):
p.update(format='json', formatversion=2)
return self.s.post(API, data=p, files=files, timeout=timeout).json()
def login(self):
user, pwd = load_creds()
lt = self.get(action='query', meta='tokens', type='login')['query']['tokens']['logintoken']
r = self.post(action='login', lgname=user, lgpassword=pwd, lgtoken=lt)
if r.get('login', {}).get('result') != 'Success':
sys.exit('lwupload: login failed: ' + json.dumps(r.get('login', r)))
self.token = self.get(action='query', meta='tokens')['query']['tokens']['csrftoken']
def page(self, title):
r = self.get(action='query', prop='revisions', titles=title, curtimestamp=1,
rvprop='ids|timestamp|content', rvslots='main')
pg = r['query']['pages'][0]
if pg.get('missing') or pg.get('invalid'):
return None
rev = pg['revisions'][0]
return {'title': pg['title'], 'ns': pg['ns'], 'revid': rev['revid'],
'timestamp': rev['timestamp'], 'now': r['curtimestamp'],
'text': rev['slots']['main']['content']}
def fileinfo(self, name):
r = self.get(action='query', prop='imageinfo', titles='File:' + name,
iiprop='sha1|size')
pg = r['query']['pages'][0]
if pg.get('missing') or not pg.get('imageinfo'):
return pg.get('title'), None
return pg['title'], pg['imageinfo'][0]
def extensions(self):
r = self.get(action='query', meta='siteinfo', siprop='fileextensions')
return {e['ext'].lower() for e in r['query']['fileextensions']}
def embedding(self):
r = self.get(action='query', list='embeddedin', eititle='Template:Upload',
eilimit='max')
return [p['title'] for p in r['query']['embeddedin']]
# ---- one call ----------------------------------------------------------------
def split_params(body):
"""Split on | but not inside [[…]] (a caption may hold a link)."""
out, cur, depth, i = [], '', 0, 0
while i < len(body):
if body.startswith('[[', i):
depth += 1; cur += '[['; i += 2; continue
if body.startswith(']]', i) and depth:
depth -= 1; cur += ']]'; i += 2; continue
if body[i] == '|' and not depth:
out.append(cur); cur = ''; i += 1; continue
cur += body[i]; i += 1
out.append(cur)
return out
def parse_call(body):
parts = [p.strip() for p in split_params(body)]
call = {'name': parts[0], 'pick': None, 'as': None, 'replace': False,
'wz': None, 'pos': [], 'named': []}
for p in parts[1:]:
m = re.match(r'^([A-Za-z][\w-]*)\s*=\s*(.*)$', p, re.S)
if m and m.group(1).lower() in ('as', 'replace', 'error', 'wz'):
k, v = m.group(1).lower(), m.group(2).strip()
if k == 'as':
call['as'] = v
elif k == 'replace':
call['replace'] = v.lower() in ('1', 'yes', 'y', 'true', 'on')
elif k == 'wz':
call['wz'] = v
continue # error= is ours, dropped on a retry
if re.fullmatch(r'\d+', p) and call['pick'] is None and not call['pos']:
call['pick'] = p
elif m:
call['named'].append((m.group(1), m.group(2).strip()))
elif p:
call['pos'].append(p)
return call
def with_error(body, reason):
"""The call as written, with |error= set to reason."""
parts = [p for p in split_params(body) if not re.match(r'^\s*error\s*=', p)]
reason = reason.replace('|', '/').replace('{', '(').replace('}', ')')
return '{{upload|' + '|'.join(p.strip() for p in parts) + '|error=' + reason + '}}'
def showme(name, pick):
"""showme's own answer: the list it would choose from, first = its choice."""
name = os.path.expanduser(name)
cmd = [str(SHOWME), '-p', name] + ([pick] if pick else [])
r = subprocess.run(cmd, capture_output=True, text=True, timeout=120,
stdin=subprocess.DEVNULL, cwd=str(HOME))
paths = [l for l in r.stdout.splitlines() if l.strip()]
if r.returncode or not paths:
why = (r.stderr.strip().splitlines() or ['not found'])[-1]
return None, [], why
return paths[0], paths, None
def sha1(path):
h = hashlib.sha1()
with open(path, 'rb') as f:
for block in iter(lambda: f.read(1 << 20), b''):
h.update(block)
return h.hexdigest()
def wiki_name(path, want):
"""MediaWiki's name for the upload: |as= or the file's own name."""
base = os.path.basename(path)
name = want or base
if '.' not in name and '.' in base:
name += '.' + base.rsplit('.', 1)[1]
name = re.sub(r'[:/\\#<>\[\]|{}]', '-', name).replace('_', ' ').strip()
return name[:1].upper() + name[1:]
def markup(name, call, ymonth):
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
mime = mimetypes.guess_type('x.' + ext)[0] or ''
named = ['%s=%s' % kv for kv in call['named']]
if ext in VIDEO_EXT:
size = None
rest = []
for p in call['pos']:
m = re.fullmatch(r'(\d+)px', p)
if m and size is None:
size = m.group(1)
else:
rest.append(p)
if size is None and not any(k.lower() in ('size', 'width') for k, _ in call['named']):
size = Y_VIDEO_SIZE if ymonth else None
args = [name] + rest + (['size=' + size] if size else []) + named
return '{{video|' + '|'.join(args) + '}}'
if ext == 'pdf':
return '{{pdf|' + '|'.join([name] + call['pos'] + named) + '}}'
if mime.startswith('image/'):
opts = list(call['pos'])
if ymonth and not any(SIZE_PARAM.match(p) for p in opts + named):
opts.insert(0, Y_IMAGE_SIZE)
link = '[[File:' + '|'.join([name] + opts + named) + ']]'
if call['wz'] is not None:
link = '<wz tip="' + call['wz'].replace('"', '"') + '">' + link + '</wz>'
return '<center>' + link + '</center>'
return '[[Media:' + name + ']]'
class Runner:
def __init__(self, dry):
self.dry = dry
self.wiki = Wiki()
self.wiki.login()
self.allowed = self.wiki.extensions()
self.done = {} # (path, name, replace) -> (name, note) | error
def do_call(self, call):
"""→ (markup-ready file name, note for the summary) or raises ValueError."""
path, cands, why = showme(call['name'], call['pick'])
if not path:
raise ValueError('not found (' + why.replace('Error: ', '') + ')'
if 'no file' not in why else 'no such file')
if not os.path.isfile(path):
raise ValueError('not a file (showme found ' + os.path.basename(path.rstrip('/')) + ')')
name = wiki_name(path, call['as'])
ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
if ext not in self.allowed:
raise ValueError('.%s files are not allowed on the wiki' % ext)
key = (path, name, call['replace'])
if key in self.done:
return self.done[key]
digest = sha1(path)
note = ''
distinct = {sha1(p) for p in cands if os.path.isfile(p)} if len(cands) > 1 else set()
if len(distinct) > 1 and not call['pick']:
note = ' (newest of %d)' % len(cands)
title, info = self.wiki.fileinfo(name)
if info and info.get('sha1') == digest:
log('same bytes already on the wiki: %s = %s' % (title, path))
result = (name, 'already there')
elif info and not call['replace']:
raise ValueError('%s already exists with different content; '
'add replace=yes to overwrite, or as=OtherName' % title)
else:
size = os.path.getsize(path)
if self.dry:
print(' would upload %s (%d bytes) as %s' % (path, size, title or 'File:' + name))
result = (name, 'dry run')
else:
with open(path, 'rb') as fh:
r = self.wiki.post(
action='upload', filename=name, ignorewarnings=1,
comment='lwupload: ' + call['name'], token=self.wiki.token,
files={'file': (name, fh, mimetypes.guess_type(name)[0]
or 'application/octet-stream')},
timeout=600)
up = r.get('upload', {})
if up.get('result') != 'Success':
raise ValueError('upload refused: ' + json.dumps(r.get('error', r))[:200])
name = up.get('filename', name).replace('_', ' ')
log('uploaded %s (%d bytes) as File:%s%s' % (path, size, name,
' (new version)' if info else ''))
notify('Uploaded File:' + name, path + note)
result = (name, ('new version' if info else 'uploaded') + note)
self.done[key] = result
return result
def do_page(self, title, revid=0):
pg = None
for _ in range(10): # the spool can be ahead of a replica
pg = self.wiki.page(title)
if pg is None or pg['revid'] >= revid:
break
time.sleep(0.5)
if pg is None:
log('no page %s' % title)
return
for attempt in range(3):
new, notes, errors = self.rewrite(pg)
if new == pg['text']:
if not notes and not errors:
log('nothing to do on %s' % pg['title'])
return
if self.dry:
print('%s: would write\n %s' % (pg['title'], '\n '.join(notes + errors)))
return
summary = SUMMARY + ': ' + '; '.join(notes + errors)
r = self.wiki.post(action='edit', title=pg['title'], text=new,
summary=summary[:480], minor=1, bot=1, nocreate=1,
basetimestamp=pg['timestamp'], starttimestamp=pg['now'],
token=self.wiki.token)
if r.get('edit', {}).get('result') == 'Success':
log('edited %s: %s' % (pg['title'], summary))
if errors:
notify('Not uploaded on ' + pg['title'], '\n'.join(errors), urgent=True)
return
if r.get('error', {}).get('code') == 'editconflict':
log('edit conflict on %s, again' % pg['title'])
time.sleep(1)
pg = self.wiki.page(title)
continue
log('edit refused on %s: %s' % (pg['title'], json.dumps(r)[:300]))
notify('lwupload could not save ' + pg['title'], json.dumps(r.get('error', r))[:200], True)
return
def rewrite(self, pg):
text = pg['text']
ymonth = pg['ns'] == 0 and bool(MONTH_PAGE.match(pg['title']))
quoted = [m.span() for m in VERBATIM.finditer(text)]
notes, errors, out, last = [], [], [], 0
for m in CALL.finditer(text):
if any(a <= m.start() < b for a, b in quoted):
continue
body = m.group(1)
call = parse_call(body)
if not call['name']:
continue
try:
name, note = self.do_call(call)
rep = markup(name, call, ymonth)
note = '%s → File:%s, %s' % (call['name'], name, note)
if note not in notes:
notes.append(note)
except ValueError as e:
rep = with_error(body, str(e))
if rep != m.group(0):
errors.append('%s: %s' % (call['name'], e))
log('%s on %s: %s' % (call['name'], pg['title'], e))
out.append(text[last:m.start()]); out.append(rep); last = m.end()
out.append(text[last:])
return ''.join(out), notes, errors
def read_spool():
"""Every request left by the wiki, oldest first; the files are removed
BEFORE the work, so a crash cannot leave the path unit firing forever."""
wanted = {}
if not SPOOL.is_dir():
return wanted
for f in sorted(SPOOL.iterdir(), key=lambda p: p.stat().st_mtime):
try:
with open(f, encoding='utf-8') as fh:
fcntl.flock(fh, fcntl.LOCK_SH)
for line in fh:
revid, _, title = line.rstrip('\n').partition('\t')
if title:
wanted[title] = max(wanted.get(title, 0), int(revid or 0))
except (OSError, ValueError, UnicodeDecodeError) as e:
log('spool %s: %s' % (f.name, e))
try:
f.unlink()
except OSError as e:
log('spool %s not removed: %s' % (f.name, e))
return wanted
def main(argv):
if '-h' in argv or '--help' in argv:
print(__doc__); return 0
if '-v' in argv or '--version' in argv:
print('lwupload ' + VERSION); return 0
dry = '-n' in argv or '--dry-run' in argv
spool = '--spool' in argv
titles = [a for a in argv if not a.startswith('-')]
STATE.mkdir(parents=True, exist_ok=True)
with open(LOCK, 'w') as lk:
fcntl.flock(lk, fcntl.LOCK_EX) # one run at a time; the next waits
if spool:
wanted = read_spool()
else:
wanted = {t: 0 for t in titles}
run = Runner(dry)
if not spool and not titles:
wanted = {t: 0 for t in run.wiki.embedding()}
if not wanted:
print('lwupload: no page calls {{upload}}')
for title, revid in wanted.items():
try:
run.do_page(title, revid)
except Exception as e: # one page must not stop the others
log('%s: %s: %s' % (title, type(e).__name__, e))
notify('lwupload failed on ' + title, str(e)[:200], True)
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
The hook, in LocalSettings.php, inside the block that only runs on localhost:
# {{upload|NAME}} (2026-10-15) -- a save whose text calls Template:Upload
# leaves "revid<TAB>title" in ~/.cache/lwupload/spool; the systemd user
# unit lwupload.path sees it and runs ~/bin/lwupload --spool, which finds
# NAME with showme, uploads the file and writes the picture in place of
# the call. Nothing runs from the request itself: the work is done as
# laussy, who owns the files, not as www-data. lwupload's own saves are
# skipped by their summary. LOCAL ONLY because the files are on this disk.
$wgHooks['PageSaveComplete'][] = static function ( $wikiPage, $user, $summary, $flags, $revisionRecord ) {
try {
$spool = '/home/laussy/.cache/lwupload/spool';
if ( strpos( (string)$summary, 'lwupload' ) === 0 || !is_dir( $spool ) ) {
return true;
}
$content = $revisionRecord->getContent( MediaWiki\Revision\SlotRecord::MAIN );
$text = $content instanceof TextContent ? $content->getText() : '';
if ( !preg_match( '/\{\{\s*[Uu]pload\s*\|/', $text ) ) {
return true;
}
$file = $spool . '/' . $revisionRecord->getId() . '.req';
$line = $revisionRecord->getId() . "\t" . $wikiPage->getTitle()->getPrefixedText() . "\n";
MediaWiki\Deferred\DeferredUpdates::addCallableUpdate( static function () use ( $file, $line ) {
@file_put_contents( $file, $line, LOCK_EX );
}, MediaWiki\Deferred\DeferredUpdates::POSTSEND );
} catch ( Throwable $e ) {
wfLogWarning( 'lwupload hook: ' . $e->getMessage() );
}
return true;
};
The two systemd user units, in ~/.config/systemd/user/, switched on with systemctl --user enable --now lwupload.path. The web server has to be able to write into the spool: www-data is in the laussy group, and the directory is 2770.
# lwupload.path — the laussywiki save hook (LocalSettings.php) drops a file
# here when a page calls {{upload|…}}; this runs lwupload.service for it.
# DirectoryNotEmpty fires again after the service if anything is left.
[Unit]
Description=laussywiki {{upload}} spool (~/bin/lwupload)
[Path]
DirectoryNotEmpty=%h/.cache/lwupload/spool
MakeDirectory=yes
DirectoryMode=2770
[Install]
WantedBy=default.target
# lwupload.service — started by lwupload.path; see ~/bin/lwupload.
[Unit]
Description=Upload the files named by {{upload|…}} on laussywiki
StartLimitIntervalSec=60
StartLimitBurst=30
[Service]
Type=oneshot
ExecStart=%h/bin/lwupload --spool
Environment=PATH=%h/.local/bin:%h/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
WorkingDirectory=%h