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 @@