- Python 100%
| converter | ||
| tests | ||
| .gitignore | ||
| convert.py | ||
| README.md | ||
pmwiki-to-outline
End-to-end migration guide and tool for moving a PmWiki installation into Outline.
This document walks an admin through the full journey — from copying wiki.d off the PmWiki server to a working import in Outline. Tool internals (architecture, extending rules, tests) are covered at the bottom.
wiki.d/ wiki-out/
Main.HomePage ─→ Main/
Main.GettingStarted HomePage.md
Recipes.FooBar GettingStarted.md
attachments/image.png
uploads/ Recipes/
Main/ FooBar.md
image.png
Attachments live inside each collection folder and are referenced as
./attachments/<file>. This is not cosmetic — see “Why attachments live inside
the collection” below.
Public and members-only content
PmWiki decides at render time who sees which part of a page. Outline's Markdown import has no equivalent, so every conditional region has to be resolved before the export — and it cuts both ways:
| PmWiki region | shown to | converter |
|---|---|---|
(:if false:) |
nobody | dropped (--keep-hidden overrides) |
(:if auth edit:) etc. |
logged-in members | protected output |
(:else:) of an auth check |
anonymous visitors | public (that is the public branch) |
(:if equal …:) |
depends on page variables | decided when the variables are known, else public + a TODO note |
A page can be partly restricted — on hsmr.cc, 296 of 1252 pages have an
(:if auth …:) region inside otherwise public content, and 18 more carry a
PmWiki page password. Pass --protected-out DIR and that content goes into a
second tree; ZIP it and import it into a restricted Outline collection.
Without the flag it is dropped and reported, never silently published.
(:if:) has no closing tag: a region runs until the next (:if:), (:else:),
(:elseif:) or (:ifend:), so plain conditionals partition a page instead of
nesting — which is why PmWiki also has (:if2:)/(:if3:) for overlapping
levels. 763 of hsmr.cc's pages contain (:if:) with no (:ifend:) anywhere; a
parser that assumes nesting mis-attributes whole sections, including protected
ones.
Page text variables
Name: value lines define a PTV, {$:Name} references it, and the definitions
almost always sit inside an (:if false:) block. They are therefore collected
from the raw page before conditionals are resolved, and substituted into the
markup before the conversion phases run — the way PmWiki does it. Resolve them
later and a PTV holding '''bold''' or [[a link]] would land in the output
unconverted. An undefined PTV expands to nothing, as in PmWiki.
Old-wiki URLs
Wikis are full of links written as full URLs instead of [[WikiLinks]]. Pass
--wiki-url and they are rewritten into relative document links and attachment
references instead of pointing back at the wiki you are retiring:
https://hsmr.cc/Infrastruktur/Door → [Door](../Infrastruktur/Door.md)
https://hsmr.cc/Main/Anfahrt#bus → [Anfahrt](./Anfahrt.md#bus)
https://hsmr.cc/?n=Main.Anfahrt → [Anfahrt](./Anfahrt.md)
https://hsmr.cc/index.php?n=Events/Termine → [Termine](../Events/Termine.md)
https://hsmr.cc/Freifunk/ → group default page (--wiki-default-page)
https://hsmr.cc/uploads/Main/logo.png → 
Foreign hosts are left alone. Without the flag nothing is rewritten.
Prerequisites
- Local machine with Python 3.9+ installed (no third-party Python packages required — stdlib only).
- Access to the PmWiki server via SSH/SFTP to retrieve
wiki.d/anduploads/directories. - Outline workspace access:
- For bulk ZIP import, you need Outline admin rights (Settings → Preferences → Import).
- Without admin: you can still drag-and-drop
.mdfiles into any Collection you have write access to, one folder at a time.
Step 1 — Copy the PmWiki data to your local machine
PmWiki keeps everything in two directories on the server. Typical paths:
wiki.d/— page files (one file per page, namedGroup.PageName, no extension)uploads/— attachments (organized by group:uploads/<Group>/<filename>)
Paths may differ; check the PmWiki install's config.php for the $WorkDir and $UploadDir variables.
Copy both to the directory where you'll run the converter:
rsync -av user@pmwiki-server:/var/www/pmwiki/wiki.d/ ./wiki.d/
rsync -av user@pmwiki-server:/var/www/pmwiki/uploads/ ./uploads/
Step 2 — Run the converter
python3 convert.py \
--wiki-dir ../wiki.d \
--uploads-dir ../uploads \
--out ../wiki-out \
--protected-out ../wiki-out-protected \
--wiki-url https://hsmr.cc \
--intermap ../scripts/intermap.txt \
--report
The --protected-out tree is a second ZIP for a restricted collection. Read
the summary the run prints before importing anything.
Flags:
| Flag | Required | Description |
|---|---|---|
--wiki-dir |
yes | Path to PmWiki's wiki.d directory |
--uploads-dir |
no | Path to PmWiki's uploads directory (attachments). Omit if you have none. |
--out |
yes | Where to write the converted Markdown tree (must be empty, or pass --overwrite) |
--overwrite |
no | Allow writing into a non-empty --out directory |
--report |
no | After conversion, scan output for any surviving PmWiki syntax and print a per-category summary |
--exclude-group |
no | Skip a PmWiki group, repeatable. Defaults to Site, SiteAdmin, PmWiki — these hold skin configuration and, in SiteAdmin.AuthUser, htpasswd-style credential hashes |
--include-group |
no | Convert ONLY these groups, repeatable |
--wiki-url |
no | Base URL of the old PmWiki, repeatable (e.g. https://hsmr.cc). Rewrites absolute links to that host — see “Old-wiki URLs” above |
--wiki-default-page |
no | PmWiki's $DefaultName, used to resolve a bare /Group/ URL (default HomePage) |
--protected-out |
no | Write members-only content to a second tree — see “Public and members-only content” |
--keep-hidden |
no | Keep (:if false:) regions, which PmWiki never renders |
--include-infra |
no | Also convert PmWiki machinery pages (RecentChanges, GroupHeader, Templates, …), skipped by default |
--intermap |
no | PmWiki InterMap definitions, e.g. the installation's scripts/intermap.txt (repeatable). <wiki-dir>/Site.InterMap is picked up automatically; without them [[Wikipedia:X]] links cannot be resolved |
--include-protected |
no | Also convert pages carrying a PmWiki read/edit password. Skipped by default — Outline's import has no per-document permissions, so they would become readable by the whole workspace |
Exit codes: 0 clean, 1 a page failed to parse/convert (or nothing was
converted), 2 converted but --report found something to review.
--overwrite empties the output directory first, so a page you renamed or a
group you have since excluded does not survive into the next ZIP.
The output tree mirrors Outline's Collection/document structure:
wiki-out/
├── Main/
│ ├── HomePage.md
│ ├── GettingStarted.md
│ └── attachments/
│ └── logo.png
└── Recipes/
└── FooBar.md
Each top-level directory will become a Collection in Outline; the .md files within become documents. Attachments are referenced from page content as ./attachments/<filename>.
Step 3 — Package for Outline import
cd wiki-out
zip -r ../wiki.zip .
cd ..
Outline's import accepts a ZIP whose top-level folders become Collections — which is exactly what we produce.
Size note: Outline's bulk import is capped at ~1.5 GB. For most wikis this is fine. If your ZIP exceeds that, split by Collection:
cd wiki-out
for group in */; do
zip -r "../${group%/}.zip" "$group" attachments
done
You'll import one group at a time.
Step 4 — Import into Outline
4a. Admin bulk import (recommended)
- In Outline: Settings → Preferences → Import
- Choose Markdown
- Upload
wiki.zip - Wait for the async import to finish (email notification, or refresh the Import page)
- Each top-level directory in the ZIP becomes a Collection; files within become documents
4b. Non-admin per-collection (drag-drop)
If you don't have admin rights but have write access to at least one Collection:
- Open the target Collection in Outline
- Drag-and-drop
.mdfiles (or a folder of them) directly into the Collection - Repeat for each Collection
4c. API (for scripted re-imports)
If you expect to re-import (e.g. to refine the converter and re-run), scripting against the Outline API is worth it:
curl -X POST https://outline.example.com/api/documents.import \
-H "Authorization: Bearer $OUTLINE_TOKEN" \
-F "file=@Main/HomePage.md" \
-F "collectionId=$COLLECTION_ID" \
-F "publish=true"
The response includes a file operation ID; poll fileOperations.info to know when each import completes.
Step 5 — Verify after import
- Counts match: the number of documents in Outline matches the
.mdcount inwiki-out/(find wiki-out -name '*.md' | wc -l). - Spot-check 5–10 random pages — formatting, links resolve, images render.
- Broken-link pass: inside Outline, search for pages containing
(.md)in prose — any such literal pattern is a link that didn't resolve. Outline rewrites./Page.mdand../Group/Page.mdinto real document links, so a leftover means the target page was not part of the export (a PmWiki red link, or a group you excluded). - Attachment pass: open a page with images. The files should render, not show as broken images.
- TODO pass: search for
TODO:inside Outline. These are the converter's visible markers for manual attention (see next section). They are emitted as blockquotes, not HTML comments — Outline's importer has no rule for<!-- ... -->and would show the angle brackets as literal text. - Broken images: open a page with an image. If you see a grey placeholder, the attachment layout regressed — see “Why attachments live inside the collection”.
Why attachments live inside the collection
Verified against Outline v1.9.2 by importing both layouts into a throwaway instance:
| ZIP layout | reference in the .md |
result |
|---|---|---|
attachments/<Group>/bild.png (shared, top level) |
../attachments/<Group>/bild.png |
broken |
<Group>/attachments/bild.png (what we emit) |
./attachments/bild.png |
works |
With the shared top-level folder, Outline creates a zero-byte Attachment row
carrying the id it wrote into the document and stores the real bytes under a
different id, so attachments.redirect answers 404 and every image renders as
a broken placeholder.
Two further concessions to Outline's importer, both dictated by its markdown
rule in shared/editor/rules/links.ts:
- A link to a non-image attachment becomes a file-attachment node by replacing the whole enclosing paragraph — the rule says so itself: "this makes the assumption that the attachment is the only thing in the para". Anything else in that paragraph is destroyed, so non-image references are emitted as their own paragraph. Images are unaffected and stay inline.
- The file's name and size are read out of the link text, split on the last
space (
const size = parts.pop(); const title = parts.join(" ")). A plain[Hallenplan](...)therefore yields an empty name, which is why such cards showed up blank. We emit[<name> <bytes>]. Outline still displays "0 Bytes" — it stores the size as a string and its own formatter reads it back as 0 — but the filename is correct.
Filenames are percent-encoded, because Outline matches references against
encodeURI(pathInZip): a raw Grundriß.png never matches and stays dangling.
Reference names are resolved the way PmWiki does. $UploadNameChars strips
non-ASCII characters both when an upload is stored and when the link is
built, so a page saying Attach:Grieß.jpg displays
uploads/<Group>/Grie.jpg — verified against the live wiki. Taking the
reference literally would break an image that works today. The visible label
keeps the original name.
Known lossy conversions
| Construct | What the converter does | Action for the reviewer |
|---|---|---|
(:if auth …:) regions |
Moved to --protected-out, or dropped |
Import that ZIP into a restricted collection |
(:if false:) regions |
Dropped (PmWiki shows them to nobody) | --keep-hidden if you want them |
RecentChanges, GroupHeader, Templates, … |
Skipped | --include-infra if you want them |
Attachment referenced but not in uploads/ |
Reported as dangling (it is dangling in the wiki too) | Fix or remove the reference |
| Unknown InterMap prefix | [[Prefix:target]] left as-is |
Add the prefix to an --intermap file |
| Edit history | Not preserved (Outline's import doesn't support per-document history anyway) | Optional: keep a separate git-history backup using pmwiki-to-git (see Optional section below) |
| Authorship | All docs land under the importing user | Create a dedicated "Import" user in Outline so migrated content is attributable |
(:pagelist ...:) |
Replaced with <!-- TODO: (:pagelist ...:) --> |
Manually rebuild the page list or delete the TODO |
(:include OtherPage:) |
Replaced with <!-- TODO: (:include OtherPage:) --> |
Inline the target content or delete |
(:redirect OtherPage:) |
Replaced with a visible pointer: → [OtherPage](./OtherPage.md) <!-- was (:redirect OtherPage:) --> |
Fine as-is, or edit the line |
(:Summary: X:) |
Stripped silently; summary text lost | If summaries matter, keep the directive in _STRIP_NAMES but emit a note instead |
(:if:)/(:else:) |
Both branches are kept, each preceded by a > **TODO:** note |
Conditionals pick one branch at render time; a flat document shows both. Check pages where the condition hid member-only content |
(:input ...:) form widgets |
Stripped | No Outline equivalent; accept the loss |
(:table:)...(:tableend:) advanced tables |
Replaced with a > **TODO:** note + inner content (cell markers stripped) |
Manually reconstruct as a Markdown pipe table |
| ` | table without any!` header cells |
|
-> / -< indented paragraphs |
Marker stripped, text kept | Markdown has no plain indented paragraph |
Absolute https://<old-wiki>/… links |
Rewritten with --wiki-url; left unchanged without it |
Pass --wiki-url |
| Text directly after an inline image | Outline renders images as their own centred block, so following text is displayed on its own line. A tail that is only punctuation is moved ahead of the image so it does not strand there; real text is left where the author put it | Nothing to do |
Long-tail custom PmWiki recipes (e.g. (:workadventure-url:), (:jitsi-url:), (:e_preview:)) |
Left as literal text | Decide per-recipe: delete, or replace with meaningful content |
Unpaired style markers outside the whitelist (e.g. %Siteem%, %LOCALAPPDATA%) |
Preserved as literal text | Hand-fix on affected pages |
An unbalanced directive ((:div class=box with no :)) |
Left as literal text, exactly as PmWiki renders it, and flagged by --report |
Fix the source page |
URL-encoded UTF-8 bytes like %C3%A4 inside URLs |
Preserved (correct) | Not a bug — these are valid URL syntax |
[[SomePage]] with unusual characters (e.g. [, ] in page names) |
Some edge cases survive as literal [[...]] |
Manually replace with proper Markdown links |
Re-running
The converter is idempotent for a given input. To re-run after refining the converter or cleaning up source pages:
rm -rf wiki-out/
python3 convert.py --wiki-dir ../wiki.d --uploads-dir ../uploads --out ../wiki-out --report
Re-import into a fresh Collection or fresh workspace to avoid duplicate content — Outline imports append, they don't replace.
Troubleshooting
- "Converted 0 page(s)" — check that
--wiki-dirpoints at the directory that contains files namedGroup.PageName, not at a parent directory. - Parse errors listed after the "Converted N" line — the file names are shown; usually indicates unusual characters in filenames. Rename the source file or skip it and add a note.
- Mojibake in output (e.g.
Lösunginstead ofLösung) — the page file's charset isn't being detected correctly. The parser reads the header'scharset=field if present (default UTF-8). Check a page file for acharset=line; if it saysiso-8859-1or similar and the file actually contains UTF-8 bytes, UTF-8 decoding will produce replacement chars. Open an issue if you hit this. - Import fails with "file too large" — split the ZIP by Collection (see Step 3).
- Pages appear but images don't render — confirm the
attachments/folder was included in the ZIP at the top level (same level as the Collection folders).
Optional: keep a git history backup
PmWiki's per-page edit history is lost in the Outline migration. If you want a browsable backup with full history, run pmwiki-to-git separately — it's independent of this tool:
go install github.com/oxzi/pmwiki-pagefileformat-go/cmd/pmwiki-to-git@latest
git init pmwiki-git
pmwiki-to-git -pmwiki ./wiki.d -git pmwiki-git
You now have a git repo with one commit per page edit. Useful as a long-term archive alongside the Outline deployment.
The tool
Architecture
Conversion runs in phases (see converter/ — one module per phase):
| # | Module | What it handles | Status |
|---|---|---|---|
| 0 | pagefile.py |
PmWiki page-file parsing, respecting the charset= header field (default UTF-8) |
done |
| – | pipeline.py |
Orchestration + code protection: [@...@], @@monospace@@, [=escaped=], (:markup:)...(:markupend:) and leading-whitespace preformatted blocks are stashed as placeholders before Phase 1 so they survive unchanged through every phase |
done |
| 1 | inline.py |
Headings, bold, italic, bullet/numbered lists (3-space indent so nesting survives CommonMark), definition lists :term:def, (:comment:) |
done |
| 2 | directives.py |
(:title:), (:if:)/(:else:)/(:ifend:) variants, (:include:), (:redirect:), (:pagelist:), (:div:), display-mode flags (accepts both (:name arg:) and (:name: arg:) separators) |
done |
| 2.5 | domains.py |
Absolute URLs of the old wiki → PmWiki markup, so the link/attachment phases resolve them (--wiki-url) |
done |
| – | conditionals.py |
Resolves (:if:)/(:else:)/(:ifend:) on the raw page and splits it into public / members-only / discarded |
done |
| – | ptv.py |
Collects Name: value page text variables and substitutes {$:Name} |
done |
| – | intermap.py |
Loads InterMap prefixes so [[Wikipedia:X]] resolves |
done |
| 3 | links.py |
All [[...]] forms: [[Page]], [[Group.Page]], [[Group/Page]], display text, anchors, external URLs, mailto, [[!Category]], [[~Profile]], [[<<]] |
done |
| 4 | attachments.py |
Attach:file.ext (bare and [[...]]-wrapped, with/without caption, same/cross-group) → Markdown image or link (image extensions rendered inline) |
done |
| 5 | tables.py |
|| simple tables → Markdown pipe tables, separating attribute lines / caption lines / data rows; (:table:)...(:tableend:) → TODO note + stripped inner content |
done |
| 6 | misc.py |
>>class<<…>><< wikistyle blocks, %Group%/%Page% PTVs, {$PageVars}, paired %class%text%% inline styles, lone-marker whitelist strip, ->/-< indents, '^sup^'/'_sub_', [--small--], strikethrough {-...-}, revision-insertion {+...+}, line continuation \\, horizontal rule ---- |
done |
| 7 | cleanup.py |
Blank lines around headings/tables/rules/fences (without them CommonMark folds a table into the paragraph above), whitespace normalization, preserves Markdown hard breaks, trailing newline | done |
Local test instance
../outline-lab/ holds a throwaway Outline (Postgres, Redis, Dex as a
one-user OIDC provider, Caddy) plus run_import.py / verify.py to drive an
import through the API. Every claim in this README about Outline's behaviour was
checked against it. See its README.md — including the two lab-only hacks that
must not reach production.
For a trustworthy measurement, run docker compose down -v and do exactly
one import: an aborted import leaves zero-byte attachment rows behind that
make a later run look broken when it is not.
Extending
Each rule has a fixture pair in tests/fixtures/:
tests/fixtures/
headings.pmwiki ← PmWiki input
headings.expected.md ← expected Markdown output
To add a rule:
- Add a fixture pair (or extend an existing one) showing the input/output transformation.
- Add the regex to the right phase module.
- Run
pytest— the test harness auto-discovers all fixture pairs.
Running the tests (requires pytest):
pip install pytest
pytest
Credits
Phase 1 regex rules derive from dohliam/pmdown (MIT). Page-file format reference: PmWiki PageFileFormat docs and oxzi/pmwiki-pagefileformat-go (the canonical Go implementation, including revision-history reconstruction which we don't need for Outline).
License
MIT.