From 9b1c4c7221cf9387a391fd1c2e1571e9b932a8e4 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 20:14:53 +0200 Subject: [PATCH] feat(notes): link notes to each other with markdown links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to link one note to another. Now there is, using ordinary markdown links whose target is the note's route: [Shopping list](/index.php/apps/notes/note/42) No custom syntax, so every editor renders them — the rich editor, the markdown preview, and anything else that reads the file. This is the same URL NoteReferenceProvider already matches, so a link pasted into Talk or a Text document still resolves to a rich preview. The target is the file id rather than the title, so renaming a note cannot break a link to it. Only the visible label goes stale. * "Copy link to note" in the note's action menu puts the markdown on the clipboard, label included, so linking is a paste rather than hand-writing a URL. It sits in the note list row menu, which is present in every editor mode. * Clicking a note link routes inside the app instead of reloading the page. In rich mode that goes through Text's openLinkHandler hook; note that providing a handler replaces Text's default, so the fallback for every other link — open in a new tab — is reimplemented rather than lost. In preview mode the click is delegated from the preview container, because the rendered HTML is replaced wholesale on every edit. * Modified clicks (ctrl, meta, shift, alt, middle button) are left alone so "open in new tab" keeps working, and links to another origin are never treated as note links. Stale labels are refreshed on rename (NoteLinkService), with two limits, because this edits notes the user is not looking at: * Only labels that still match the old title are rewritten. A link written as [my weekly shop](…/note/42) is the author's wording, not a stale copy of the title, and is left alone. * Only explicit renames trigger it. Titles also change through autotitle, which fires while a new note is being typed, and sweeping the whole collection on each of those would be wasteful and surprising. The two are separate controller paths, so only updateProperty('title') is hooked. A rename therefore costs one pass over the notes folder. That is the same O(n) content read as search, acceptable for an explicit, infrequent action. Co-Authored-By: Claude Opus 5 (1M context) --- lib/Controller/NotesController.php | 11 +++ lib/Service/NoteLinkService.php | 113 ++++++++++++++++++++++++++++ src/components/EditorMarkdownIt.vue | 35 ++++++++- src/components/NoteItem.vue | 28 ++++++- src/components/NoteRich.vue | 12 +++ src/noteLinks.js | 73 ++++++++++++++++++ 6 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 lib/Service/NoteLinkService.php create mode 100644 src/noteLinks.js 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 @@