From b940990d1b37782ca4a024d45aa9e6921fe7d6ae Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 19:18:16 +0200 Subject: [PATCH 1/2] feat(notes): expose file versions in the note sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes have been versioned all along — they are ordinary files, so files_versions keeps history for them without the app doing anything. There was just no way to see it from Notes. Most of the wiring already existed: * PageController dispatches OCA\Files\Event\LoadSidebar, and files_versions registers a listener on that event which adds its sidebar-tab script. The Versions tab has therefore been registered on every Notes page already, simply never rendered. * NotePlain and NoteRich both already subscribe to files_versions:restore:requested and :restored, showing a loading state and refreshing the note afterwards. The restore path was built and unreachable. * NoteShareSidebar already knew how to mount a registered Files sidebar tab as a custom element with the node/folder/view props it expects. The only thing missing was that the sidebar hard-filtered the tab registry down to `id === 'sharing'`. It now renders every tab from an allow-list, so Sharing and Versions sit side by side. Details: * Tab selection moved to a pure function in sidebarTabs.js. It is an allow-list rather than "everything registered", because LoadSidebar brings in whatever every installed app registers and a note sidebar should not grow new tabs when an unrelated app is installed. A tab's own enabled() predicate still has the final say — the versions tab hides itself on public shares and for non-files — but it needs a node to judge, so while the node is still loading tabs are kept and filtered again once it arrives, and a predicate that throws drops that tab instead of taking the sidebar down. * Tabs initialise independently, so one failing to define its custom element no longer hides the others; only a total failure is reported. * New event notes:sidebar:open carries a tab id. notes:share:open is kept as a thin wrapper so anything already emitting it keeps working. * "Versions" action added to the note's action menu, next to "Share". That menu lives in the note list row, so it is present in every editor mode rather than only the non-default one. * Sidebar copy no longer says "sharing" now that it hosts two tabs. The data-cy-notes-share-sidebar hook is deliberately unchanged, since playwright/e2e/basic.spec.ts asserts on it. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/NoteItem.vue | 14 ++++ src/components/NoteShareSidebar.vue | 108 +++++++++++++++++----------- src/sidebarTabs.js | 48 +++++++++++++ 3 files changed, 129 insertions(+), 41 deletions(-) create mode 100644 src/sidebarTabs.js diff --git a/src/components/NoteItem.vue b/src/components/NoteItem.vue index 535bfd6e0..635b5af52 100644 --- a/src/components/NoteItem.vue +++ b/src/components/NoteItem.vue @@ -42,6 +42,13 @@ {{ t('notes', 'Share') }} + + + {{ t('notes', 'Versions') }} + + @@ -65,8 +65,9 @@ import NcAppSidebarTab from '@nextcloud/vue/components/NcAppSidebarTab' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' -import ShareVariantOutlineIcon from 'vue-material-design-icons/ShareVariantOutline.vue' +import FileOutlineIcon from 'vue-material-design-icons/FileOutline.vue' import logger from '../Logger.js' +import { selectNoteSidebarTabs } from '../sidebarTabs.js' import store from '../store.js' import { fetchDavNode } from '../WebdavService.js' @@ -79,7 +80,7 @@ export default { NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, - ShareVariantOutlineIcon, + FileOutlineIcon, }, data() { @@ -115,8 +116,12 @@ export default { return store.notes.getNote(this.noteId) }, - sharingTab() { - return getSidebarTabs().find((tab) => tab.id === 'sharing') || null + tabs() { + return selectNoteSidebarTabs(getSidebarTabs(), { + node: this.currentNode, + folder: this.currentFolder, + view: this.currentView, + }) }, currentView() { @@ -128,57 +133,74 @@ export default { }, mounted() { + // the share event is kept so anything already emitting it keeps working subscribe('notes:share:open', this.onShareOpen) + subscribe('notes:sidebar:open', this.onSidebarOpen) }, unmounted() { unsubscribe('notes:share:open', this.onShareOpen) + unsubscribe('notes:sidebar:open', this.onSidebarOpen) }, methods: { - async initializeSharingTab() { - const tab = this.sharingTab - if (!tab) { + async initializeTabs() { + const tabs = this.tabs + if (tabs.length === 0) { this.loadingTab = false - this.tabError = this.t('notes', 'Sharing is not available right now.') + this.tabError = this.t('notes', 'Sharing and versions are not available right now.') return } + // One tab failing to define its element must not hide the others, so + // they are initialised independently and only a total failure is + // reported as an error. + const results = await Promise.all(tabs.map((tab) => this.initializeTab(tab))) + + this.loadingTab = false + this.tabError = results.includes(true) + ? '' + : this.t('notes', 'Failed to load the note sidebar.') + }, + + /** + * @param {object} tab a registered Files sidebar tab + * @return {Promise} whether the tab is usable + */ + async initializeTab(tab) { if (window.customElements.get(tab.tagName) || this.initializedTabs.has(tab.tagName)) { - this.loadingTab = false - this.tabError = '' - return + return true } if (this.initializingTabs.has(tab.tagName)) { + // another open is already awaiting this one this.loadingTab = true - return + return true } this.initializingTabs.add(tab.tagName) this.loadingTab = true - this.tabError = '' try { await tab.onInit?.() await window.customElements.whenDefined(tab.tagName) this.initializedTabs.add(tab.tagName) + return true } catch (error) { - logger.error('Failed to initialize the sharing sidebar tab in Notes', { error }) - this.tabError = this.t('notes', 'Failed to load the sharing sidebar.') + logger.error('Failed to initialize a sidebar tab in Notes', { error, tab: tab.id }) + return false } finally { this.initializingTabs.delete(tab.tagName) - this.loadingTab = false } }, - async loadShareContext() { + async loadNodeContext() { const internalPath = this.note?.internalPath if (!internalPath) { this.loadingContext = false this.currentNode = null this.currentFolder = null - this.contextError = this.t('notes', 'Unable to load the selected note for sharing.') + this.contextError = this.t('notes', 'Unable to load the selected note.') return } @@ -193,7 +215,7 @@ export default { try { folder = await fetchDavNode(node.dirname || '/') } catch (error) { - logger.error('Failed to load the parent folder for the Notes sharing sidebar', { error }) + logger.error('Failed to load the parent folder for the Notes sidebar', { error }) } if (requestToken !== this.contextRequestToken) { @@ -207,10 +229,10 @@ export default { return } - logger.error('Failed to load the selected note for the Notes sharing sidebar', { error }) + logger.error('Failed to load the selected note for the Notes sidebar', { error }) this.currentNode = null this.currentFolder = null - this.contextError = this.t('notes', 'Unable to load the selected note for sharing.') + this.contextError = this.t('notes', 'Unable to load the selected note.') } finally { if (requestToken === this.contextRequestToken) { this.loadingContext = false @@ -218,10 +240,14 @@ export default { } }, - async onShareOpen({ noteId }) { + onShareOpen({ noteId }) { + return this.onSidebarOpen({ noteId, tab: 'sharing' }) + }, + + async onSidebarOpen({ noteId, tab = 'sharing' }) { this.contextRequestToken += 1 this.noteId = Number(noteId) - this.activeTab = 'sharing' + this.activeTab = tab this.isOpen = true this.contextError = '' this.tabError = '' @@ -230,14 +256,14 @@ export default { this.loadingContext = false this.loadingTab = false - if (!this.sharingTab) { - await this.initializeSharingTab() + if (this.tabs.length === 0) { + await this.initializeTabs() return } await Promise.all([ - this.initializeSharingTab(), - this.loadShareContext(), + this.initializeTabs(), + this.loadNodeContext(), ]) }, diff --git a/src/sidebarTabs.js b/src/sidebarTabs.js new file mode 100644 index 000000000..016cc1844 --- /dev/null +++ b/src/sidebarTabs.js @@ -0,0 +1,48 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Files sidebar tabs the Notes sidebar hosts, and nothing else. + * + * Notes dispatches OCA\Files\Event\LoadSidebar when rendering its page, so every + * app that registers a sidebar tab has registered one by the time this runs — + * including tabs that make no sense for a note. This is an allow-list so a newly + * installed app cannot start appearing in the Notes sidebar unannounced. + * + * @type {string[]} + */ +export const NOTE_SIDEBAR_TAB_IDS = ['sharing', 'files_versions'] + +/** + * The tabs to render, in the order the registering apps asked for. + * + * A tab's own `enabled()` predicate has the final say — the versions tab for + * instance hides itself on public shares and for anything that is not a file — + * but it needs a node to judge, so while the node is still loading the tabs are + * kept and filtered again once it arrives. A predicate that throws is treated as + * "not usable" rather than being allowed to take the sidebar down. + * + * @param {Array} tabs all registered tabs, from getSidebarTabs() + * @param {object} context what the tab is being asked about + * @param {object|null} context.node the note's DAV node, null while loading + * @param {object|null} context.folder the note's parent folder + * @param {object|null} context.view the pseudo view Notes reports + * @return {Array} tabs to render, sorted by their declared order + */ +export function selectNoteSidebarTabs(tabs, { node = null, folder = null, view = null } = {}) { + return (tabs ?? []) + .filter((tab) => NOTE_SIDEBAR_TAB_IDS.includes(tab?.id)) + .filter((tab) => { + if (typeof tab.enabled !== 'function' || node === null) { + return true + } + try { + return tab.enabled({ node, folder, view }) + } catch { + return false + } + }) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) +} From 86ae56b26ec95a2b3f9eafff2a7acd604af3fa11 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Thu, 6 Aug 2026 20:30:32 +0200 Subject: [PATCH 2/2] feat(notes): add a Note info tab to the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar hosts Sharing and Versions but says nothing about the note itself. This adds a first tab with category, word and character counts, a reading estimate, size, created and modified times, the file path, and a read-only marker when the note cannot be written. Most of it is free: the sidebar already fetches the note's DAV node for the Files tabs, so size and creation time come from data it was loading anyway, and everything else is on the note in the store. The counts are not free, and that shapes the design. The note list payload excludes `content`, so a note that has never been opened has none client-side. Rather than fetching every body up front, the tab pulls the one note it needs and only once its tab is actually selected — opening the sidebar to share a note does not drag its body down with it. Until then the row shows a placeholder. Counting words means ignoring the markup, otherwise '#' and '**' inflate the number. noteStats.js strips the obvious things — fenced code, image syntax, link targets while keeping labels, heading, quote and list markers, setext underlines, emphasis — and leaves the rest alone. A full parse would be much more code for a number nobody checks to the decimal. Characters are counted on the note as stored, since that is what was typed, and by code point rather than UTF-16 unit so astral characters count once. The tab is Notes' own rather than a Files sidebar tab, so it renders outside the registry loop with order 0 to sit ahead of Sharing. The "nothing to show here" empty state now also checks for a note, so it cannot appear just because the Files tabs failed to register. Stacked on the sidebar generalisation: without a multi-tab sidebar there is nowhere to put this. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/NoteInfo.vue | 156 ++++++++++++++++++++++++++++ src/components/NoteShareSidebar.vue | 48 ++++++++- src/noteStats.js | 61 +++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/components/NoteInfo.vue create mode 100644 src/noteStats.js diff --git a/src/components/NoteInfo.vue b/src/components/NoteInfo.vue new file mode 100644 index 000000000..c1db25418 --- /dev/null +++ b/src/components/NoteInfo.vue @@ -0,0 +1,156 @@ + + + + + + + diff --git a/src/components/NoteShareSidebar.vue b/src/components/NoteShareSidebar.vue index 9fb465a40..bc49d016b 100644 --- a/src/components/NoteShareSidebar.vue +++ b/src/components/NoteShareSidebar.vue @@ -15,6 +15,17 @@ @closed="onClosed" @update:open="onToggle" > + + + + + - + @@ -66,7 +77,10 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon' import FileOutlineIcon from 'vue-material-design-icons/FileOutline.vue' +import InformationOutlineIcon from 'vue-material-design-icons/InformationOutline.vue' +import NoteInfo from './NoteInfo.vue' import logger from '../Logger.js' +import { fetchNote } from '../NotesService.js' import { selectNoteSidebarTabs } from '../sidebarTabs.js' import store from '../store.js' import { fetchDavNode } from '../WebdavService.js' @@ -81,6 +95,8 @@ export default { NcIconSvgWrapper, NcLoadingIcon, FileOutlineIcon, + InformationOutlineIcon, + NoteInfo, }, data() { @@ -94,6 +110,7 @@ export default { initializedTabs: new Set(), isOpen: false, loadingContext: false, + loadingContent: false, loadingTab: false, noteId: null, tabError: '', @@ -132,6 +149,10 @@ export default { }, }, + watch: { + activeTab: 'ensureContent', + }, + mounted() { // the share event is kept so anything already emitting it keeps working subscribe('notes:share:open', this.onShareOpen) @@ -144,6 +165,30 @@ export default { }, methods: { + /** + * The note list payload excludes content, so a note that has never been + * opened has none. The info tab needs it for the counts — fetch it, but + * only when that tab is actually being looked at, so opening the sidebar + * to share a note does not pull its whole body down. + */ + async ensureContent() { + if (this.activeTab !== 'notes-info' || this.loadingContent) { + return + } + if (!Number.isFinite(this.noteId) || typeof this.note?.content === 'string') { + return + } + + this.loadingContent = true + try { + await fetchNote(this.noteId) + } catch (error) { + logger.error('Failed to load the note body for the info tab', { error }) + } finally { + this.loadingContent = false + } + }, + async initializeTabs() { const tabs = this.tabs if (tabs.length === 0) { @@ -264,6 +309,7 @@ export default { await Promise.all([ this.initializeTabs(), this.loadNodeContext(), + this.ensureContent(), ]) }, diff --git a/src/noteStats.js b/src/noteStats.js new file mode 100644 index 000000000..79aee1380 --- /dev/null +++ b/src/noteStats.js @@ -0,0 +1,61 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** Words per minute used for the reading estimate — the usual figure for prose. */ +const WORDS_PER_MINUTE = 200 + +/** + * Strips the markup so a word count counts words rather than syntax. + * + * Deliberately shallow: it drops the things that would otherwise be counted as + * words — fenced code, link targets, image syntax, heading and list markers — + * and leaves everything else alone. A full parse would be more accurate and far + * more code for a number nobody checks to the decimal. + * + * @param {string} content raw markdown + * @return {string} text with the obvious markup removed + */ +function stripMarkup(content) { + return content + // fenced code blocks, including the fence lines + .replaceAll(/^```[\s\S]*?^```/gm, ' ') + .replaceAll(/^~~~[\s\S]*?^~~~/gm, ' ') + // images: drop entirely, they contribute no words + .replaceAll(/!\[[^\]]*\]\([^)]*\)/g, ' ') + // links: keep the label, drop the target + .replaceAll(/\[([^\]]*)\]\([^)]*\)/g, '$1') + // inline code, keeping what is inside it + .replaceAll(/`([^`]*)`/g, '$1') + // heading, blockquote and list markers at the start of a line + .replaceAll(/^\s{0,3}#{1,6}\s+/gm, '') + .replaceAll(/^\s*>+\s?/gm, '') + // \u00A0 escaped rather than literal: markdown-it-task-checkbox accepts a + // non-breaking space inside the brackets, and a bare one here is invisible + .replaceAll(/^\s*(?:[-+*]|\d+\.)\s+(?:\[[xX \u00A0]\]\s+)?/gm, '') + // setext underlines and thematic breaks + .replaceAll(/^\s*(?:={2,}|-{3,}|\*{3,}|_{3,})\s*$/gm, ' ') + // emphasis markers + .replaceAll(/(\*{1,3}|_{1,3}|~~)(?=\S)([\s\S]*?\S)\1/g, '$2') +} + +/** + * Counts for a note's body. + * + * @param {string} content raw markdown, may be empty or absent + * @return {{words: number, characters: number, readingMinutes: number}} counts + */ +export function noteTextStats(content) { + const text = typeof content === 'string' ? content : '' + const stripped = stripMarkup(text).trim() + const words = stripped === '' ? 0 : stripped.split(/\s+/u).length + + return { + words, + // characters of the note as stored, since that is what the user typed + characters: [...text].length, + // never round an existing note down to "0 min" + readingMinutes: words === 0 ? 0 : Math.max(1, Math.round(words / WORDS_PER_MINUTE)), + } +}