No description
Find a file
2026-08-11 12:52:18 +02:00
converter Linkziele gegen die echten Seitennamen aufloesen 2026-07-28 22:05:18 +02:00
tests Linkziele gegen die echten Seitennamen aufloesen 2026-07-28 22:05:18 +02:00
.gitignore remove helper script 2026-04-17 15:23:54 +02:00
convert.py Linkziele gegen die echten Seitennamen aufloesen 2026-07-28 22:05:18 +02:00
README.md Testimport-Funde: Platzhalter, Steuerzeichen, Upload-Namen 2026-07-28 19:49:38 +02:00

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    →  ![logo.png](./attachments/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/ and uploads/ directories.
  • Outline workspace access:
    • For bulk ZIP import, you need Outline admin rights (Settings → Preferences → Import).
    • Without admin: you can still drag-and-drop .md files 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, named Group.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

  1. In Outline: Settings → Preferences → Import
  2. Choose Markdown
  3. Upload wiki.zip
  4. Wait for the async import to finish (email notification, or refresh the Import page)
  5. 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:

  1. Open the target Collection in Outline
  2. Drag-and-drop .md files (or a folder of them) directly into the Collection
  3. 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

  1. Counts match: the number of documents in Outline matches the .md count in wiki-out/ (find wiki-out -name '*.md' | wc -l).
  2. Spot-check 510 random pages — formatting, links resolve, images render.
  3. 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.md and ../Group/Page.md into 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).
  4. Attachment pass: open a page with images. The files should render, not show as broken images.
  5. 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.
  6. 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-dir points at the directory that contains files named Group.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ösung instead of Lösung) — the page file's charset isn't being detected correctly. The parser reads the header's charset= field if present (default UTF-8). Check a page file for a charset= line; if it says iso-8859-1 or 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:

  1. Add a fixture pair (or extend an existing one) showing the input/output transformation.
  2. Add the regex to the right phase module.
  3. 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.