From 4d81a19a11988b84908669aff66d484fe94349d0 Mon Sep 17 00:00:00 2001
From: ribbal <30695106+ribbal@users.noreply.github.com>
Date: Mon, 13 Jul 2026 22:02:51 +0100
Subject: [PATCH 01/10] update backlinks on page rename
---
otterwiki/backlinks.py | 221 ++++++++++++++++++++++++++++++++++++++++
otterwiki/gitstorage.py | 26 +++--
otterwiki/wiki.py | 22 +++-
3 files changed, 262 insertions(+), 7 deletions(-)
create mode 100644 otterwiki/backlinks.py
diff --git a/otterwiki/backlinks.py b/otterwiki/backlinks.py
new file mode 100644
index 00000000..8c451106
--- /dev/null
+++ b/otterwiki/backlinks.py
@@ -0,0 +1,221 @@
+#!/usr/bin/env python3
+
+"""
+When a page is renamed this plugin rewrites all links
+in the wiki that point to the old page so that they point
+to the new page name instead.
+"""
+
+import re
+
+from otterwiki.server import app, storage
+
+"""Rewrite WikiLinks and images 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"\[\[([^\]]+)\]\]")
+
+# Matches a Markdown image link
+IMAGE_LINK_RE = re.compile(r"!\[([^\]]*)\]\((/[^)]+)\)")
+
+# Matches Markdown links
+# Example: [text](/Parent/ChildPage)
+MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\((/[^)]+)\)")
+
+
+def _is_linktitle_style():
+ """True if the link is the *left* side of ``[[left|right]]``."""
+ style = (
+ app.config.get("WIKILINK_STYLE", "").upper().replace("_", "").strip()
+ )
+ return style in ("LINKTITLE", "PAGENAMETITLE")
+
+
+def _split_link_title(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(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 = _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.
+ 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_attachment(match, old_pagepath, new_pagepath):
+ """Rewrite Markdown image links pointing to attachments on a renamed page."""
+
+ alt_text = match.group(1)
+ attachment_path = match.group(2)
+
+ retain_case = app.config.get("RETAIN_PAGE_NAME_CASE", False)
+
+ # Attachments live in the page folder, not the .md file itself.
+ old_page_dir = old_pagepath.rsplit(".", 1)[0]
+ old_attachment_prefix = "/" + old_page_dir.strip("/") + "/"
+
+ if retain_case:
+ matches = attachment_path.startswith(old_attachment_prefix)
+ else:
+ matches = attachment_path.lower().startswith(
+ old_attachment_prefix.lower()
+ )
+
+ if not matches:
+ return match.group(0)
+
+ # Preserve the attachment relative path.
+ relative_attachment = attachment_path[len(old_attachment_prefix) - 1 :]
+
+ new_page_dir = new_pagepath.rsplit(".", 1)[0]
+ new_attachment_path = "/" + new_page_dir.strip("/") + relative_attachment
+
+ return f""
+
+
+def _rewrite_markdown_link(match, old_key, new_pagepath, old_pagepath):
+ """Rewrite Markdown links pointing to a renamed page."""
+
+ from otterwiki.helper import get_filename
+
+ link_text = match.group(1)
+ link_path = match.group(2)
+
+ # Separate anchor if present.
+ page, hsep, anchor = link_path.partition("#")
+
+ leading_slash = page.startswith("/")
+
+ # Remove leading slash before resolving filename.
+ page_name = page.strip().lstrip("/")
+
+ if not page_name:
+ return match.group(0)
+
+ # Check if this Markdown link points to the renamed page.
+ if get_filename(page_name) != old_key:
+ return match.group(0)
+
+ new_link = ("/" if leading_slash else "") + new_pagepath
+
+ if hsep:
+ new_link += "#" + anchor
+
+ return f"[{link_text}]({new_link})"
+
+
+def _rewrite_content(
+ content, old_key, new_pagepath, linktitle_style, old_pagepath
+):
+ def repl(match):
+ new_inner = _rewrite_inner(
+ match.group(1), old_key, new_pagepath, linktitle_style
+ )
+ if new_inner is None:
+ return match.group(0)
+ return "[[" + new_inner + "]]"
+
+ def attachment_repl(match):
+ return _rewrite_attachment(match, old_pagepath, new_pagepath)
+
+ def markdown_link_repl(match):
+ return _rewrite_markdown_link(
+ match,
+ old_key,
+ new_pagepath,
+ old_pagepath,
+ )
+
+ # Rewrite WikiLinks
+ content = WIKILINK_RE.sub(repl, content)
+
+ # Rewrite attachment links
+ content = IMAGE_LINK_RE.sub(attachment_repl, content)
+
+ # Rewrite Markdown links
+ content = MARKDOWN_LINK_RE.sub(markdown_link_repl, content)
+
+ return content
+
+
+def rename_backlinks(old_pagepath, new_pagepath):
+ if not app.config.get("UPDATE_LINKS_ON_RENAME", True):
+ return {}
+
+ from otterwiki.helper import get_filename
+
+ try:
+ old_key = get_filename(old_pagepath)
+ linktitle_style = _is_linktitle_style()
+
+ all_files, _ = storage.list()
+ md_files = [f for f in all_files if f.endswith(".md")]
+
+ updated = {}
+ for md_file in md_files:
+ content = storage.load(md_file, mode="r")
+ new_content = _rewrite_content(
+ content, old_key, new_pagepath, linktitle_style, old_pagepath
+ )
+ if new_content == content:
+ continue
+ # storage.update() writes the updated content to file.
+ storage.update(
+ filename=md_file,
+ content=new_content,
+ )
+ updated[md_file] = new_content
+
+ if updated:
+ app.logger.info(
+ "rename_backlinks: rewrote backlinks in %d page(s) "
+ "after renaming '%s' to '%s'",
+ len(updated),
+ old_pagepath,
+ new_pagepath,
+ )
+
+ return updated
+
+ except Exception as e:
+ app.logger.error(
+ "rename_backlinks: failed to update links after renaming "
+ "'%s' to '%s': %s",
+ old_pagepath,
+ new_pagepath,
+ e,
+ )
diff --git a/otterwiki/gitstorage.py b/otterwiki/gitstorage.py
index fbc94ee1..bd44ec0b 100644
--- a/otterwiki/gitstorage.py
+++ b/otterwiki/gitstorage.py
@@ -322,16 +322,12 @@ def log_slow(self, filename=None):
# build and return logfile
return [self._get_metadata_of_commit(commit) for commit in commits]
- def store(
+ def update(
self,
filename,
content,
- message: str | None = "",
- author=("", ""),
mode="w",
):
- if message is None:
- message = ""
dirname = os.path.dirname(filename)
if dirname != "":
os.makedirs(
@@ -344,6 +340,22 @@ def store(
diff = self.repo.index.diff(None, paths=filename)
if len(diff) == 0 and filename not in self.repo.untracked_files:
return False
+
+ return True
+
+ def store(
+ self,
+ filename,
+ content,
+ message: str | None = "",
+ author=("", ""),
+ mode="w",
+ ):
+ if not self.update(filename, content, mode):
+ return False
+
+ if message is None:
+ message = ""
# add and commit to git
index = self.repo.index
index.add([filename])
@@ -450,6 +462,7 @@ def rename(
message=None,
author=None,
no_commit=False,
+ files_updated=[],
):
if self.exists(new_filename):
raise StorageError(
@@ -472,7 +485,8 @@ def rename(
if message is None:
message = "{} renamed to {}.".format(old_filename, new_filename)
if not no_commit:
- self.commit([new_filename], message, author, no_add=True)
+ files_updated.append(new_filename)
+ self.commit(files_updated, message, author, no_add=True)
def list(self, p=None, depth=None, exclude=[]):
excludes = [".git"] + exclude
diff --git a/otterwiki/wiki.py b/otterwiki/wiki.py
index 9a219972..c3f27dbd 100644
--- a/otterwiki/wiki.py
+++ b/otterwiki/wiki.py
@@ -73,6 +73,8 @@
int_or_None,
)
+from .backlinks import rename_backlinks
+
# global timeout used in regexps
_REGEX_TIMEOUT = 5
@@ -996,6 +998,10 @@ def rename(self, new_pagename, message, author):
if not self.exists and (len(files) + len(directories)) == 0:
self.exists_or_404()
+ pages_updated = rename_backlinks(self.filename, new_pagename)
+ assert pages_updated is not None
+ files_updated = list(pages_updated.keys())
+
if (len(files) + len(directories)) > 0:
# rename attachment directory
new_attachment_directoryname = get_attachment_directoryname(
@@ -1010,11 +1016,25 @@ def rename(self, new_pagename, message, author):
# if self.exists, do not commit yet, commit will follow below, when the md file is renamed
# if not self.exists, do commit, since no md file will be renamed
no_commit=self.exists,
+ files_updated=files_updated,
)
# rename page
if self.exists:
storage.rename(
- self.filename, new_filename, message=message, author=author
+ self.filename,
+ new_filename,
+ message=message,
+ author=author,
+ files_updated=files_updated,
+ )
+
+ # notify plugins of backlink pages updated
+ for pagepath, content in pages_updated.items():
+ plugin_manager.hook.page_saved(
+ pagepath=pagepath,
+ content=content,
+ author=author,
+ message=message,
)
def handle_rename(self, new_pagename, message, author):
From 154e029d0c552ef610a3d2102d00e7a1272a4e74 Mon Sep 17 00:00:00 2001
From: ribbal <30695106+ribbal@users.noreply.github.com>
Date: Mon, 13 Jul 2026 23:14:14 +0100
Subject: [PATCH 02/10] add config setting: update links on page rename
---
otterwiki/preferences.py | 1 +
otterwiki/server.py | 2 ++
otterwiki/templates/admin.html | 10 ++++++++++
3 files changed, 13 insertions(+)
diff --git a/otterwiki/preferences.py b/otterwiki/preferences.py
index ec18438c..dedc8d68 100644
--- a/otterwiki/preferences.py
+++ b/otterwiki/preferences.py
@@ -185,6 +185,7 @@ def handle_app_preferences(form):
for checkbox in [
"hide_logo",
"open_links_in_new_tab",
+ "update_links_on_rename",
]:
_update_preference(checkbox.upper(), form.get(checkbox, "False"))
diff --git a/otterwiki/server.py b/otterwiki/server.py
index c1a61350..1c937ee1 100644
--- a/otterwiki/server.py
+++ b/otterwiki/server.py
@@ -33,6 +33,7 @@
SITE_LANG="en",
HIDE_LOGO=False,
OPEN_LINKS_IN_NEW_TAB=False,
+ UPDATE_LINKS_ON_RENAME=True,
AUTH_METHOD="",
AUTH_HEADERS_USERNAME="x-otterwiki-name",
AUTH_HEADERS_EMAIL="x-otterwiki-email",
@@ -204,6 +205,7 @@ def update_app_config():
"GIT_REMOTE_PULL_URL_SECURE",
"HIDE_LOGO",
"OPEN_LINKS_IN_NEW_TAB",
+ "UPDATE_LINKS_ON_RENAME",
"TREAT_UNDERSCORE_AS_SPACE_FOR_TITLES",
] or item.name.upper().startswith("SIDEBAR_SHORTCUT_"):
item.value = item.value.lower() in ["true", "yes"]
diff --git a/otterwiki/templates/admin.html b/otterwiki/templates/admin.html
index f27afcc5..f9a9c165 100644
--- a/otterwiki/templates/admin.html
+++ b/otterwiki/templates/admin.html
@@ -105,6 +105,16 @@
Application Preferences
External links open in new browser tab.
+{##}
+
+
+
+
+
+
+ Automatically update links when a page is renamed.
+
+
{##}
From b5c245277997792a160f222959137c0aced63303 Mon Sep 17 00:00:00 2001
From: ribbal <30695106+ribbal@users.noreply.github.com>
Date: Mon, 13 Jul 2026 23:17:57 +0100
Subject: [PATCH 03/10] fix typo
---
otterwiki/backlinks.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/otterwiki/backlinks.py b/otterwiki/backlinks.py
index 8c451106..4eef9c34 100644
--- a/otterwiki/backlinks.py
+++ b/otterwiki/backlinks.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
-When a page is renamed this plugin rewrites all links
+When a page is renamed this module rewrites all links
in the wiki that point to the old page so that they point
to the new page name instead.
"""
From 1a79650a13da1bc570d31e31756f301c80e1ed0d Mon Sep 17 00:00:00 2001
From: ribbal <30695106+ribbal@users.noreply.github.com>
Date: Tue, 14 Jul 2026 00:01:31 +0100
Subject: [PATCH 04/10] move config setting: update links on page rename
---
otterwiki/help_admin.md | 6 +++++-
otterwiki/preferences.py | 2 +-
otterwiki/server.py | 4 ++--
otterwiki/templates/admin.html | 10 ----------
otterwiki/templates/admin/content_and_editing.html | 10 ++++++++++
5 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/otterwiki/help_admin.md b/otterwiki/help_admin.md
index 76929ab6..79a8feda 100644
--- a/otterwiki/help_admin.md
+++ b/otterwiki/help_admin.md
@@ -95,7 +95,11 @@ setting. Setting this to `optional` will allow empty commit messages.
#### Page case name
An Otter Wiki stores pages in files with names of all lowercase names. To retain
-the upper and lower case of the filenames, check Retain page name case.
+the upper and lower case of the filenames, check Retain page name case.
+
+#### Update links on page rename
+When a page is renamed, any links pointing to the old page are updated to the new page. WikiLinks, Markdown links and links to attachments (e.g. images) are all updated automatically.Update links on page rename.
+
#### Git Web server
With Enable Git Server allow users with the permission to READ to clone and pull the wiki content via git and users with UPLOAD/Attachment management permissions to push content. HTTP Basic authentication is used for non anonymous access. There is no option for using git via ssh. When enabled, users find the URL to clone the repositroy in their settings.
diff --git a/otterwiki/preferences.py b/otterwiki/preferences.py
index dedc8d68..862b58a1 100644
--- a/otterwiki/preferences.py
+++ b/otterwiki/preferences.py
@@ -185,7 +185,6 @@ def handle_app_preferences(form):
for checkbox in [
"hide_logo",
"open_links_in_new_tab",
- "update_links_on_rename",
]:
_update_preference(checkbox.upper(), form.get(checkbox, "False"))
@@ -224,6 +223,7 @@ def handle_content_and_editing(form):
for checkbox in [
"retain_page_name_case",
"treat_underscore_as_space_for_titles",
+ "update_links_on_rename",
]:
_update_preference(checkbox.upper(), form.get(checkbox, "False"))
# commit changes to the database
diff --git a/otterwiki/server.py b/otterwiki/server.py
index 1c937ee1..0b7e6301 100644
--- a/otterwiki/server.py
+++ b/otterwiki/server.py
@@ -33,7 +33,6 @@
SITE_LANG="en",
HIDE_LOGO=False,
OPEN_LINKS_IN_NEW_TAB=False,
- UPDATE_LINKS_ON_RENAME=True,
AUTH_METHOD="",
AUTH_HEADERS_USERNAME="x-otterwiki-name",
AUTH_HEADERS_EMAIL="x-otterwiki-email",
@@ -74,6 +73,7 @@
HTML_EXTRA_BODY="",
LOG_LEVEL_WERKZEUG="INFO",
TREAT_UNDERSCORE_AS_SPACE_FOR_TITLES=False,
+ UPDATE_LINKS_ON_RENAME=True,
HOME_PAGE="",
RENDERER_HTML_ALLOWLIST="",
ADMIN_USER_EMAIL="",
@@ -205,8 +205,8 @@ def update_app_config():
"GIT_REMOTE_PULL_URL_SECURE",
"HIDE_LOGO",
"OPEN_LINKS_IN_NEW_TAB",
- "UPDATE_LINKS_ON_RENAME",
"TREAT_UNDERSCORE_AS_SPACE_FOR_TITLES",
+ "UPDATE_LINKS_ON_RENAME",
] or item.name.upper().startswith("SIDEBAR_SHORTCUT_"):
item.value = item.value.lower() in ["true", "yes"]
if item.name.upper() in ["MAIL_PORT"]:
diff --git a/otterwiki/templates/admin.html b/otterwiki/templates/admin.html
index f9a9c165..f27afcc5 100644
--- a/otterwiki/templates/admin.html
+++ b/otterwiki/templates/admin.html
@@ -105,16 +105,6 @@
Application Preferences
External links open in new browser tab.
-{##}
-
-
-
-
-
-
- Automatically update links when a page is renamed.
-