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/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 +76,12 @@ 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 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' @@ -79,7 +94,9 @@ export default { NcEmptyContent, NcIconSvgWrapper, NcLoadingIcon, - ShareVariantOutlineIcon, + FileOutlineIcon, + InformationOutlineIcon, + NoteInfo, }, data() { @@ -93,6 +110,7 @@ export default { initializedTabs: new Set(), isOpen: false, loadingContext: false, + loadingContent: false, loadingTab: false, noteId: null, tabError: '', @@ -115,8 +133,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() { @@ -127,58 +149,103 @@ export default { }, }, + watch: { + activeTab: 'ensureContent', + }, + 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) { - this.loadingTab = false - this.tabError = this.t('notes', 'Sharing is not available right now.') + /** + * 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 } - if (window.customElements.get(tab.tagName) || this.initializedTabs.has(tab.tagName)) { + 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) { this.loadingTab = false - this.tabError = '' + 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)) { + 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 +260,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 +274,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 +285,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 +301,15 @@ 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(), + 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)), + } +} 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)) +}