Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 258 additions & 0 deletions otterwiki/backlinks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
#!/usr/bin/env python3

"""
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.
"""

import re

from otterwiki.server import app, storage
from urllib.parse import quote, unquote

"""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 nested images inside links
IMAGE_LINK_WRAPPER_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 = unquote(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 = match.group(1)
url = match.group(2)

new_url = _rewrite_url(
url,
old_pagepath,
new_pagepath,
)

return f"![{alt}]({new_url})"


def _rewrite_markdown_link(match, new_pagepath, old_pagepath):
"""Rewrite Markdown links pointing to a renamed page."""

text = match.group(1)
url = match.group(2)

new_url = _rewrite_url(
url,
old_pagepath,
new_pagepath,
)

return f"[{text}]({new_url})"


def _rewrite_url(url, old_pagepath, new_pagepath):
"""Rewrite a Markdown URL pointing to a renamed page or one of its
attachments.

Returns the rewritten URL, or the original URL if no rewrite is needed.
"""

from otterwiki.helper import get_filename

retain_case = app.config.get("RETAIN_PAGE_NAME_CASE", False)

# Split query string.
path, qsep, query = url.partition("?")

# Split anchor.
path, hsep, anchor = path.partition("#")

#
# First: attachment URLs
#
old_page_dir = quote(old_pagepath.rsplit(".", 1)[0].strip("/"), safe="/")
new_page_dir = quote(new_pagepath.rsplit(".", 1)[0].strip("/"), safe="/")

old_prefix = "/" + old_page_dir + "/"

if retain_case:
matches = path.startswith(old_prefix)
else:
matches = path.lower().startswith(old_prefix.lower())

if matches:
relative = path[len(old_prefix) - 1 :]
new_path = "/" + new_page_dir + relative

else:
#
# Page URL
#
leading_slash = path.startswith("/")

page = unquote(path.lstrip("/"))

if not page:
return url

old_key = get_filename(old_pagepath)
if get_filename(page) != old_key:
return url

encoded = quote(new_pagepath, safe="/")
new_path = ("/" if leading_slash else "") + encoded

# Restore anchor.
if hsep:
new_path += "#" + anchor

# Restore query string.
if qsep:
new_path += "?" + query

return new_path


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 wrapper_repl(match):
image = match.group(1)
url = _rewrite_url(match.group(2), old_pagepath, new_pagepath)
return f"[{image}]({url})"

def attachment_repl(match):
return _rewrite_attachment(match, old_pagepath, new_pagepath)

def markdown_link_repl(match):
return _rewrite_markdown_link(
match,
new_pagepath,
old_pagepath,
)

# Rewrite WikiLinks
content = WIKILINK_RE.sub(repl, content)

# Rewrite nested images inside links
content = IMAGE_LINK_WRAPPER_RE.sub(wrapper_repl, content)

# Rewrite image 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):
from otterwiki.helper import get_filename

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:
try:
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
except Exception as e:
app.logger.warning(
"Skipping page '%s': %s",
md_file,
e,
)
continue

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
26 changes: 20 additions & 6 deletions otterwiki/gitstorage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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])
Expand Down Expand Up @@ -450,6 +462,7 @@ def rename(
message=None,
author=None,
no_commit=False,
files_updated=[],
):
if self.exists(new_filename):
raise StorageError(
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion otterwiki/help_admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ 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 <span class="help-button"><input type="checkbox" style="display:inline;" id="true-retain-page-name" checked> Retain page name case</span>.
the upper and lower case of the filenames, check <span class="help-button"><input type="checkbox" style="display:inline;" id="true-retain-page-name" checked>Retain page name case</span>.

#### Git Web server
With <span class="help-button"><input type="checkbox" style="display:inline;" id="true-git-webserver" checked> Enable Git Server</span> 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.
Expand Down
17 changes: 17 additions & 0 deletions otterwiki/static/js/otterwiki.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ var otterwiki = {
for (let i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = !checkboxes[i].checked;
}
},
retain_update_backlinks_checkbox: function() {
const checkbox = document.getElementById("update_backlinks");
if (!checkbox) return;

const key = "otterwiki/rename/update-backlinks";

const saved = sessionStorage.getItem(key);
if (saved !== null) {
checkbox.checked = (saved === "true");
}

checkbox.addEventListener("change", () => {
sessionStorage.setItem(key, checkbox.checked);
});
}
}

Expand Down Expand Up @@ -269,6 +284,8 @@ window.addEventListener("keydown", function(event) {


document.addEventListener("DOMContentLoaded", () => {
otterwiki.retain_update_backlinks_checkbox();

if (document.body.dataset.pageIndexRetainUserExpandedNodes === 'False') return;

const detailsList = document.querySelectorAll("details");
Expand Down
14 changes: 14 additions & 0 deletions otterwiki/templates/rename.html
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@
<label for="new_pagename" class="required w-150">New name</label>
<input type="text" class="form-control" name="new_pagename" id="new_pagename" value="{{new_pagename if new_pagename else pagename}}" placeholder="{{pagename}}" autofocus="" onfocus="this.setSelectionRange(this.value.length,this.value.length);">
</div>
<div class="form-group">
<label for="update_backlinks" class="w-150">Update backlinks</label>
<div class="form-check">
<input
type="checkbox"
class="form-check-input"
name="update_backlinks"
id="update_backlinks"
value="1">
<label class="form-check-label" for="update_backlinks">
Update links pointing to this page
</label>
</div>
</div>
<div class="form-group">
<label for="message" class="w-150">Commit message</label>
<input type="text" class="form-control" name="message" id="message" placeholder="Renamed {{pagename}} to {{new_pagename if new_pagename else "..."}}" value="{{ message if message }}">
Expand Down
1 change: 1 addition & 0 deletions otterwiki/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ def rename(path):
new_pagename=request.form.get("new_pagename"),
message=request.form.get("message"),
author=otterwiki.auth.get_author(),
update_backlinks=request.form.get("update_backlinks"),
)
return p.rename_form()

Expand Down
Loading