diff --git a/lib/Controller/NotesController.php b/lib/Controller/NotesController.php
index 3b3fc1d1c..b4296c5f4 100644
--- a/lib/Controller/NotesController.php
+++ b/lib/Controller/NotesController.php
@@ -11,6 +11,7 @@
namespace OCA\Notes\Controller;
use OCA\Notes\Service\Note;
+use OCA\Notes\Service\NoteLinkService;
use OCA\Notes\Service\NotesService;
use OCA\Notes\Service\SettingsService;
use OCP\AppFramework\Controller;
@@ -38,6 +39,7 @@ public function __construct(
private IConfig $settings,
private IL10N $l10n,
private IMimeTypeDetector $mimeTypeDetector,
+ private NoteLinkService $noteLinkService,
) {
parent::__construct($AppName, $request);
}
@@ -260,9 +262,18 @@ public function updateProperty(
case 'title':
if ($title !== null) {
+ $oldTitle = $note->getTitle();
$this->inLockScope($note, function () use ($note, $title) {
$note->setTitle($title);
});
+ // only an explicit rename refreshes link labels — autotitle
+ // fires while a new note is being typed
+ $this->noteLinkService->refreshLinkLabels(
+ $this->helper->getUID(),
+ $id,
+ $oldTitle,
+ $note->getTitle(),
+ );
}
$result = [
'title' => $note->getTitle(),
diff --git a/lib/Service/NoteLinkService.php b/lib/Service/NoteLinkService.php
new file mode 100644
index 000000000..8e924630a
--- /dev/null
+++ b/lib/Service/NoteLinkService.php
@@ -0,0 +1,113 @@
+)` to use `$newTitle`.
+ *
+ * Never throws: a rename must not fail because a link could not be tidied
+ * up. Notes that cannot be read or written are skipped.
+ *
+ * @return int number of notes changed
+ */
+ public function refreshLinkLabels(string $userId, int $noteId, string $oldTitle, string $newTitle): int {
+ if ($oldTitle === '' || $oldTitle === $newTitle) {
+ return 0;
+ }
+
+ $pattern = $this->linkPattern($noteId, $oldTitle);
+ // ${1} is the target, ${2} any trailing slashes it was written with
+ $replacement = '[' . $this->escapeReplacement($newTitle) . '](${1}${2})';
+ $changed = 0;
+
+ foreach ($this->notesService->getAll($userId)['notes'] as $note) {
+ if ($note->getId() === $noteId) {
+ continue;
+ }
+
+ try {
+ $content = $note->getContent();
+ $updated = preg_replace($pattern, $replacement, $content);
+ if ($updated === null || $updated === $content) {
+ continue;
+ }
+ $note->setContent($updated);
+ $changed++;
+ } catch (\Throwable $e) {
+ // a read-only note, or one that vanished mid-walk
+ $this->logger->debug('Could not refresh note links in ' . $note->getId(), ['exception' => $e]);
+ }
+ }
+
+ return $changed;
+ }
+
+ /**
+ * Matches a markdown link whose label is $oldTitle and whose target is any
+ * spelling of the route to $noteId — absolute or root-relative, with or
+ * without the /index.php prefix.
+ */
+ private function linkPattern(int $noteId, string $oldTitle): string {
+ $targets = [];
+ foreach (['/apps/notes/note/', '/index.php/apps/notes/note/'] as $path) {
+ $absolute = $this->urlGenerator->getAbsoluteURL($path . $noteId);
+ $targets[] = $absolute;
+ $relative = parse_url($absolute, PHP_URL_PATH);
+ if (is_string($relative) && $relative !== '') {
+ $targets[] = $relative;
+ }
+ }
+
+ $targets = array_map(
+ static fn (string $target): string => preg_quote($target, '/'),
+ array_values(array_unique($targets)),
+ );
+
+ return '/\[' . preg_quote($oldTitle, '/') . '\]\(\s*(' . implode('|', $targets) . ')(\/*)\s*\)/u';
+ }
+
+ /**
+ * `$` and `\` carry meaning in a preg_replace replacement, so a title
+ * containing them would otherwise corrupt the link.
+ */
+ private function escapeReplacement(string $value): string {
+ return str_replace(['\\', '$'], ['\\\\', '\\$'], $value);
+ }
+}
diff --git a/src/components/EditorMarkdownIt.vue b/src/components/EditorMarkdownIt.vue
index 792e1b90e..eb01fa924 100644
--- a/src/components/EditorMarkdownIt.vue
+++ b/src/components/EditorMarkdownIt.vue
@@ -5,7 +5,7 @@
-
+