diff --git a/docs/plugin_examples/plugin_updatelinksonrename/otterwiki_updatelinksonrename.py b/docs/plugin_examples/plugin_updatelinksonrename/otterwiki_updatelinksonrename.py new file mode 100644 index 00000000..6ab4f199 --- /dev/null +++ b/docs/plugin_examples/plugin_updatelinksonrename/otterwiki_updatelinksonrename.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 + +""" +Update Links on Rename Plugin for An Otter Wiki + +When a page is renamed this plugin rewrites all WikiLinks (``[[...]]``) +in the wiki that point to the old page so that they point to the new +page name instead. This implements the request from +https://github.com/redimp/otterwiki/discussions/210 (issue #228). + +It demonstrates the ``page_renamed`` hook and shows how a plugin can +read, modify and commit page content through the storage API. + +Scope / limitations: +- Only WikiLinks (``[[Page]]``, ``[[Title|Page]]``) are updated, matching + what the bundled "referencingpages" example can already discover. Plain + Markdown links (``[text](/Page)``) are intentionally left untouched. +- Matching is exact for the renamed page. Child pages and attachment links + are not rewritten (see issue #65 for attachments). +- Renaming pages is not the only way to opt out: set + ``UPDATE_LINKS_ON_RENAME = False`` in your settings.cfg to keep the plugin + installed but disabled (a per-rename checkbox is not currently exposable + via the plugin API). +""" + +import os +import re + +from otterwiki.plugins import hookimpl, plugin_manager + + +class UpdateLinksOnRename: + """Rewrite WikiLinks pointing to a renamed page.""" + + # Matches a single WikiLink and captures its inner content. + # WikiLink inner content never contains a closing bracket. + WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]") + + @hookimpl + def setup(self, app, db, storage): + self.app = app + self.storage = storage + + def _is_linktitle_style(self): + """True if the link is the *left* side of ``[[left|right]]``.""" + style = ( + self.app.config.get("WIKILINK_STYLE", "") + .upper() + .replace("_", "") + .strip() + ) + return style in ("LINKTITLE", "PAGENAMETITLE") + + def _split_link_title(self, inner, linktitle_style): + """Split a WikiLink's inner content into (link_part, title_part). + + ``title_part`` is None when the link carries no explicit title, in + which case the link doubles as the displayed text. + """ + if "|" in inner: + left, right = inner.split("|", 1) + if linktitle_style: + return left, right + return right, left + # no pipe: the whole content is both link and displayed text + return inner, None + + def _rewrite_inner(self, inner, old_key, new_pagepath, linktitle_style): + """Return the rewritten inner content, or None if it doesn't match.""" + from otterwiki.helper import get_filename + + link_part, title_part = self._split_link_title(inner, linktitle_style) + + # preserve a leading slash (absolute links) and any #anchor + leading_slash = link_part.startswith("/") + page, hsep, anchor = link_part.strip().lstrip("/").partition("#") + page = page.strip() + if not page: + # pure anchor link like [[#section]] - nothing to do + return None + + # resolve the link target to its on-disk filename and compare. This + # makes the match case-/slash-/".md"-insensitive, exactly how Otter + # Wiki itself resolves a page path. + if get_filename(page) != old_key: + return None + + new_link = ("/" if leading_slash else "") + new_pagepath + if hsep: + new_link += "#" + anchor + + if title_part is None: + return new_link + if linktitle_style: + return new_link + "|" + title_part + return title_part + "|" + new_link + + def _rewrite_content( + self, content, old_key, new_pagepath, linktitle_style + ): + def repl(match): + new_inner = self._rewrite_inner( + match.group(1), old_key, new_pagepath, linktitle_style + ) + if new_inner is None: + return match.group(0) + return "[[" + new_inner + "]]" + + return self.WIKILINK_RE.sub(repl, content) + + @hookimpl + def page_renamed(self, old_pagepath, new_pagepath, author, message): + if not self.app.config.get("UPDATE_LINKS_ON_RENAME", True): + return + + from otterwiki.helper import get_filename + + try: + old_key = get_filename(old_pagepath) + linktitle_style = self._is_linktitle_style() + + all_files, _ = self.storage.list() + md_files = [f for f in all_files if f.endswith(".md")] + + updated = [] + for md_file in md_files: + content = self.storage.load(md_file, mode="r") + new_content = self._rewrite_content( + content, old_key, new_pagepath, linktitle_style + ) + if new_content == content: + continue + # storage.store() writes, commits, fires repository_changed + # and auto-pushes - the same path a normal page edit takes. + self.storage.store( + filename=md_file, + content=new_content, + author=author, + message="Updated link: renamed '{}' to '{}'".format( + old_pagepath, new_pagepath + ), + ) + updated.append(md_file) + + if updated: + self.app.logger.info( + "UpdateLinksOnRename: rewrote WikiLinks in %d page(s) " + "after renaming '%s' to '%s'", + len(updated), + old_pagepath, + new_pagepath, + ) + except Exception as e: + self.app.logger.error( + "UpdateLinksOnRename: failed to update links after renaming " + "'%s' to '%s': %s", + old_pagepath, + new_pagepath, + e, + ) + + +plugin_manager.register(UpdateLinksOnRename()) diff --git a/docs/plugin_examples/plugin_updatelinksonrename/pyproject.toml b/docs/plugin_examples/plugin_updatelinksonrename/pyproject.toml new file mode 100644 index 00000000..2c372d2f --- /dev/null +++ b/docs/plugin_examples/plugin_updatelinksonrename/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "otterwiki_updatelinksonrename" +description = "An example plugin for An Otter Wiki that rewrites WikiLinks pointing to a page when that page is renamed." +version = "0.1.0" +authors = [ + { name = "An Otter Wiki contributors" } +] + +[project.entry-points.otterwiki] +updatelinksonrename = "otterwiki_updatelinksonrename" diff --git a/tests/test_plugin_updatelinksonrename.py b/tests/test_plugin_updatelinksonrename.py new file mode 100644 index 00000000..96c234ea --- /dev/null +++ b/tests/test_plugin_updatelinksonrename.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python + +"""Tests for the plugin_updatelinksonrename example plugin. + +The plugin rewrites WikiLinks that point to a page when the page is renamed +(https://github.com/redimp/otterwiki/discussions/210, issue #228). + +The example plugins live under docs/ and are not installed, so the plugin +module is loaded by path. Importing it auto-registers an instance; that +instance is removed again so each test controls registration explicitly. +""" + +import importlib.util +import os +import sys + +DOCS_PLUGINS = os.path.normpath( + os.path.join(os.path.dirname(__file__), "..", "docs", "plugin_examples") +) + + +def _get_plugin_manager(): + return sys.modules["otterwiki.plugins"].plugin_manager + + +def _load_example_plugin(subdir, filename, class_name): + path = os.path.join(DOCS_PLUGINS, subdir, filename) + spec = importlib.util.spec_from_file_location(filename[:-3], path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + pm = _get_plugin_manager() + for plugin in list(pm.get_plugins()): + if type(plugin).__name__ == class_name: + pm.unregister(plugin) + return getattr(module, class_name) + + +def save_page(client, pagename, content, commit_message="create"): + rv = client.post( + "/{}/save".format(pagename), + data={"content": content, "commit": commit_message}, + follow_redirects=True, + ) + assert rv.status_code == 200 + + +def _setup_plugin(create_app): + cls = _load_example_plugin( + "plugin_updatelinksonrename", + "otterwiki_updatelinksonrename.py", + "UpdateLinksOnRename", + ) + from otterwiki.server import db + + plugin = cls() + plugin.setup(app=create_app, db=db, storage=create_app.storage) + return plugin + + +def test_rewrites_wikilinks_on_rename(test_client, create_app): + save_page(test_client, "RenameTarget", "# Target") + # default WIKILINK_STYLE: the link is the right-hand side of [[title|link]] + save_page( + test_client, + "Referrer", + "A [[RenameTarget]] B [[click here|RenameTarget#sec]] " + "C [[Unrelated]]", + ) + + plugin = _setup_plugin(create_app) + pm = _get_plugin_manager() + pm.register(plugin) + try: + rv = test_client.post( + "/RenameTarget/rename", + data={"new_pagename": "RenamedTarget", "message": "renaming"}, + follow_redirects=True, + ) + assert rv.status_code == 200 + finally: + pm.unregister(plugin) + + content = create_app.storage.load("referrer.md", mode="r") + # link target rewritten, anchor and explicit title preserved + assert "[[RenamedTarget]]" in content + assert "[[click here|RenamedTarget#sec]]" in content + # the old target name is gone, the unrelated link is untouched + assert "[[RenameTarget]]" not in content + assert "RenameTarget#sec" not in content + assert "[[Unrelated]]" in content + + +def test_can_be_disabled(test_client, create_app): + save_page(test_client, "KeepTarget", "# Target") + save_page(test_client, "KeepReferrer", "Link [[KeepTarget]]") + + plugin = _setup_plugin(create_app) + create_app.config["UPDATE_LINKS_ON_RENAME"] = False + pm = _get_plugin_manager() + pm.register(plugin) + try: + rv = test_client.post( + "/KeepTarget/rename", + data={"new_pagename": "KeepRenamed", "message": "renaming"}, + follow_redirects=True, + ) + assert rv.status_code == 200 + finally: + pm.unregister(plugin) + create_app.config.pop("UPDATE_LINKS_ON_RENAME", None) + + content = create_app.storage.load("keepreferrer.md", mode="r") + # disabled: the link must be left untouched + assert "[[KeepTarget]]" in content