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
11 changes: 11 additions & 0 deletions lib/Controller/NotesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,7 @@ public function __construct(
private IConfig $settings,
private IL10N $l10n,
private IMimeTypeDetector $mimeTypeDetector,
private NoteLinkService $noteLinkService,
) {
parent::__construct($AppName, $request);
}
Expand Down Expand Up @@ -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(),
Expand Down
113 changes: 113 additions & 0 deletions lib/Service/NoteLinkService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Notes\Service;

use OCP\IURLGenerator;
use Psr\Log\LoggerInterface;

/**
* Keeps the visible label of note-to-note links in step with the note's title.
*
* Notes are linked with ordinary markdown links whose target is the note's id:
*
* [Shopping list](/index.php/apps/notes/note/42)
*
* Because the target is an id, renaming note 42 can never break a link to it —
* only the label goes stale. This service refreshes those labels.
*
* Two deliberate limits, because this edits notes the user is not looking at:
*
* - Only labels that still match the *old* title are rewritten. If somebody
* wrote `[my weekly shop](…/note/42)` that is their wording, not a stale
* copy of the title, and it is left alone.
* - Only explicit renames call this. Titles also change through `autotitle`,
* which fires while a new note is being typed, and rewriting the whole
* collection on every one of those would be both wasteful and surprising.
*/
class NoteLinkService {
public function __construct(
private NotesService $notesService,
private IURLGenerator $urlGenerator,
private LoggerInterface $logger,
) {
}

/**
* Rewrite `[$oldTitle](<link to $noteId>)` 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.
Comment on lines +45 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure about this. getAll(...) and linkPattern(...) sit outside the per note try/catch, and they could throw

*
* @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})';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm worried about this replacement. What if I edit a note to a title with containing ]?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

escapeReplacement only escapes the preg_replace specials $ and \. It does not escape ]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe adding something like the replace [ in noteLinkMarkdown helps

$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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to lock while doing this or nah? why or why not?

$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';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here. What if i have a note titled Test]

}

/**
* `$` 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);
}
}
35 changes: 34 additions & 1 deletion src/components/EditorMarkdownIt.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="note-preview" v-html="html" />
<div class="note-preview" @click="onClickPreview" v-html="html" />
</template>

<script>
Expand All @@ -14,6 +14,7 @@ import { generateUrl } from '@nextcloud/router'
import MarkdownIt from 'markdown-it'
import markdownItBidi from 'markdown-it-bidi'
import markdownItTaskCheckbox from 'markdown-it-task-checkbox'
import { parseNoteLink } from '../noteLinks.js'
import { escapeHtml } from '../Util.js'

export default {
Expand Down Expand Up @@ -86,6 +87,38 @@ export default {
}
},

/**
* A link to another note is navigation inside the app, so route to it
* rather than letting the browser reload the whole page. Every other
* link is left completely alone.
*
* Delegated from the container: the preview is replaced wholesale on
* every edit, so per-anchor listeners would have to be re-attached each
* time — which is what the task-checkbox handler already has to do.
*
* @param {MouseEvent} event the click
*/
onClickPreview(event) {
// let modified clicks do their usual thing (new tab, download, …)
if (event.defaultPrevented || event.button !== 0
|| event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return
}

const anchor = event.target?.closest?.('a[href]')
if (!anchor) {
return
}

const noteId = parseNoteLink(anchor.getAttribute('href'))
if (noteId === null) {
return
}

event.preventDefault()
this.$router.push({ name: 'note', params: { noteId: String(noteId) } }).catch(() => {})
},

onClickListItem(event) {
event.stopPropagation()
let idOfCheckbox = 0
Expand Down
28 changes: 27 additions & 1 deletion src/components/NoteItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
{{ actionFavoriteText }}
</NcActionButton>

<NcActionButton @click="onCopyLink">
<template #icon>
<LinkVariantIcon :size="20" />
</template>
{{ t('notes', 'Copy link to note') }}
</NcActionButton>

<NcActionButton @click="onToggleSharing">
<template #icon>
<ShareVariantOutlineIcon :size="20" />
Expand Down Expand Up @@ -99,18 +106,20 @@
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionInput from '@nextcloud/vue/components/NcActionInput'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import AlertOctagonOutlineIcon from 'vue-material-design-icons/AlertOctagonOutline.vue'
import FolderOutlineIcon from 'vue-material-design-icons/FolderOutline.vue'
import LinkVariantIcon from 'vue-material-design-icons/LinkVariant.vue'
import PencilOutlineIcon from 'vue-material-design-icons/PencilOutline.vue'
import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue'
import StarIcon from 'vue-material-design-icons/Star.vue'
import logger from '../Logger.js'
import { noteLinkMarkdown } from '../noteLinks.js'
import { deleteNote, fetchNote, setCategory, setFavorite, setTitle } from '../NotesService.js'
import store from '../store.js'
import { categoryLabel, routeIsNewNote } from '../Util.js'
Expand All @@ -121,6 +130,7 @@ export default {
components: {
AlertOctagonOutlineIcon,
FolderOutlineIcon,
LinkVariantIcon,
NcActionButton,
NcListItem,
StarIcon,
Expand Down Expand Up @@ -332,6 +342,22 @@ export default {
}
},

/**
* Puts a ready-made markdown link on the clipboard, so linking notes is
* paste rather than hand-writing a URL. Markdown rather than a bare URL
* so the link arrives with the note's title as its label.
*/
async onCopyLink() {
this.actionsOpen = false
try {
await navigator.clipboard.writeText(noteLinkMarkdown(this.note))
showSuccess(this.t('notes', 'Link to note copied'))
} catch (error) {
logger.error('Copying the note link has failed', { error })
showError(this.t('notes', 'Could not copy the link to the note.'))
}
},

onToggleSharing() {
this.actionsOpen = false
emit('notes:share:open', { noteId: this.note.id })
Expand Down
12 changes: 12 additions & 0 deletions src/components/NoteRich.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import { markRaw } from 'vue'
import { parseNoteLink } from '../noteLinks.js'
import { queueCommand, refreshNote } from '../NotesService.js'
import store from '../store.js'
import { routeIsNewNote } from '../Util.js'
Expand Down Expand Up @@ -103,6 +104,17 @@ export default {
onLoaded: () => {
this.loading = false
},
// Replacing the default handler means reimplementing its fallback:
// Text's own opens everything in a new tab.
openLinkHandler: (href) => {
const noteId = parseNoteLink(href)
if (noteId !== null) {
this.$router.push({ name: 'note', params: { noteId: String(noteId) } })
.catch(() => {})
return
}
window.open(new URL(href, window.location.href).href, '_blank', 'noopener')
},
onUpdate: ({ markdown }) => {
if (this.note) {
const unsaved = !!(this.note?.content && this.note.content !== markdown)
Expand Down
73 changes: 73 additions & 0 deletions src/noteLinks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { generateUrl } from '@nextcloud/router'

/**
* Notes are linked with ordinary markdown links pointing at 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. The target is the
* file id rather than the title, so renaming a note cannot break a link to it.
*
* This mirrors the URL NoteReferenceProvider already matches on the server, so
* a link pasted into Talk or a Text document still resolves to a rich preview.
*/

/**
* @param {number|string} noteId id of the note to link to
* @return {string} URL for a markdown link
*/
export function noteLinkUrl(noteId) {
return generateUrl('apps/notes/note/{noteId}', { noteId })
}

/**
* A markdown link to a note, ready to paste into another note.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it really ready to past into another note? Cuz this gives a relative link that is likely not immediately useful 🤔

*
* @param {object} note the note to link to
* @return {string} markdown
*/
export function noteLinkMarkdown(note) {
// ']' would end the label early, so it is the one character worth escaping
const label = String(note?.title ?? '').replaceAll(']', '\\]')

return `[${label}](${noteLinkUrl(note.id)})`
}

/**
* The note a link points at, or null when it points somewhere else.
*
* Accepts every shape the link may have been written in — absolute or
* root-relative, with or without the `/index.php` prefix, and under a
* subdirectory install — by resolving it and matching on the path. Links to
* another origin are never treated as note links, so a URL that merely looks
* like one cannot make Notes navigate.
*
* @param {string} href the link target
* @return {number|null} note id, or null
*/
export function parseNoteLink(href) {
if (!href) {
return null
}

let url
try {
url = new URL(href, window.location.href)
} catch {
return null
}

if (url.origin !== window.location.origin) {
return null
}

const match = url.pathname.match(/\/apps\/notes\/note\/(\d+)\/*$/)

return match ? Number(match[1]) : null
}