Browse Source

Add accessible system tags select

Signed-off-by: Christopher Ng <chrng8@gmail.com>
Christopher Ng 1 year ago
parent
commit
ee81e2cef8

+ 3 - 0
.eslintrc.js

@@ -10,6 +10,9 @@ module.exports = {
 		firstDay: true,
 		'cypress/globals': true,
 	},
+	parserOptions: {
+		parser: '@typescript-eslint/parser',
+	},
 	plugins: [
 		'cypress',
 	],

+ 29 - 9
apps/files/src/views/Sidebar.vue

@@ -36,10 +36,16 @@
 		@closed="handleClosed">
 		<!-- TODO: create a standard to allow multiple elements here? -->
 		<template v-if="fileInfo" #description>
-			<LegacyView v-for="view in views"
-				:key="view.cid"
-				:component="view"
-				:file-info="fileInfo" />
+			<div class="sidebar__description">
+				<SystemTags v-if="isSystemTagsEnabled"
+					v-show="showTags"
+					:file-id="fileInfo.id"
+					@has-tags="value => showTags = value" />
+				<LegacyView v-for="view in views"
+					:key="view.cid"
+					:component="view"
+					:file-info="fileInfo" />
+			</div>
 		</template>
 
 		<!-- Actions menu -->
@@ -96,22 +102,25 @@ import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
 import FileInfo from '../services/FileInfo.js'
 import SidebarTab from '../components/SidebarTab.vue'
 import LegacyView from '../components/LegacyView.vue'
+import SystemTags from '../../../systemtags/src/components/SystemTags.vue'
 
 export default {
 	name: 'Sidebar',
 
 	components: {
+		LegacyView,
 		NcActionButton,
 		NcAppSidebar,
 		NcEmptyContent,
-		LegacyView,
 		SidebarTab,
+		SystemTags,
 	},
 
 	data() {
 		return {
 			// reactive state
 			Sidebar: OCA.Files.Sidebar.state,
+			showTags: false,
 			error: null,
 			loading: true,
 			fileInfo: null,
@@ -410,9 +419,7 @@ export default {
 		 * Toggle the tags selector
 		 */
 		toggleTags() {
-			if (OCA.SystemTags && OCA.SystemTags.View) {
-				OCA.SystemTags.View.toggle()
-			}
+			this.showTags = !this.showTags
 		},
 
 		/**
@@ -505,7 +512,7 @@ export default {
 </script>
 <style lang="scss" scoped>
 .app-sidebar {
-	&--has-preview::v-deep {
+	&--has-preview:deep {
 		.app-sidebar-header__figure {
 			background-size: cover;
 		}
@@ -525,6 +532,12 @@ export default {
 		height: 100% !important;
 	}
 
+	:deep {
+		.app-sidebar-header__description {
+			margin: 0 16px 4px 16px !important;
+		}
+	}
+
 	.svg-icon {
 		::v-deep svg {
 			width: 20px;
@@ -533,4 +546,11 @@ export default {
 		}
 	}
 }
+
+.sidebar__description {
+	display: flex;
+	flex-direction: column;
+	width: 100%;
+	gap: 8px 0;
+}
 </style>

+ 235 - 0
apps/systemtags/src/components/SystemTags.vue

@@ -0,0 +1,235 @@
+<!--
+  - @copyright 2023 Christopher Ng <chrng8@gmail.com>
+  -
+  - @author Christopher Ng <chrng8@gmail.com>
+  -
+  - @license AGPL-3.0-or-later
+  -
+  - This program is free software: you can redistribute it and/or modify
+  - it under the terms of the GNU Affero General Public License as
+  - published by the Free Software Foundation, either version 3 of the
+  - License, or (at your option) any later version.
+  -
+  - This program is distributed in the hope that it will be useful,
+  - but WITHOUT ANY WARRANTY; without even the implied warranty of
+  - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+  - GNU Affero General Public License for more details.
+  -
+  - You should have received a copy of the GNU Affero General Public License
+  - along with this program. If not, see <http://www.gnu.org/licenses/>.
+  -
+-->
+
+<template>
+	<div class="system-tags">
+		<label for="system-tags-input">{{ t('systemtags', 'Search or create collaborative tags') }}</label>
+		<NcSelectTags class="system-tags__select"
+			input-id="system-tags-input"
+			:placeholder="t('systemtags', 'Collaborative tags …')"
+			:options="sortedTags"
+			:value="selectedTags"
+			:create-option="createOption"
+			:taggable="true"
+			:passthru="true"
+			:fetch-tags="false"
+			:loading="loading"
+			@input="handleInput"
+			@option:selected="handleSelect"
+			@option:created="handleCreate"
+			@option:deselected="handleDeselect">
+			<template #no-options>
+				{{ t('systemtags', 'No tags to select, type to create a new tag') }}
+			</template>
+		</NcSelectTags>
+	</div>
+</template>
+
+<script lang="ts">
+// FIXME Vue TypeScript ESLint errors
+/* eslint-disable */
+import Vue from 'vue'
+import NcSelectTags from '@nextcloud/vue/dist/Components/NcSelectTags.js'
+
+import { translate as t } from '@nextcloud/l10n'
+import { showError } from '@nextcloud/dialogs'
+
+import {
+	createTag,
+	deleteTag,
+	fetchLastUsedTagIds,
+	fetchSelectedTags,
+	fetchTags,
+	selectTag,
+} from '../services/api.js'
+
+import type { BaseTag, Tag, TagWithId } from '../types.js'
+
+const defaultBaseTag: BaseTag = {
+	userVisible: true,
+	userAssignable: true,
+	canAssign: true,
+}
+
+export default Vue.extend({
+	name: 'SystemTags',
+
+	components: {
+		NcSelectTags,
+	},
+
+	props: {
+		fileId: {
+			type: Number,
+			required: true,
+		},
+	},
+
+	data() {
+		return {
+			sortedTags: [] as TagWithId[],
+			selectedTags: [] as TagWithId[],
+			loading: false,
+		}
+	},
+
+	async created() {
+		try {
+			const tags = await fetchTags()
+			const lastUsedOrder = await fetchLastUsedTagIds()
+
+			const lastUsedTags: TagWithId[] = []
+			const remainingTags: TagWithId[] = []
+
+			for (const tag of tags) {
+				if (lastUsedOrder.includes(tag.id)) {
+					lastUsedTags.push(tag)
+					continue
+				}
+				remainingTags.push(tag)
+			}
+
+			const sortByLastUsed = (a: TagWithId, b: TagWithId) => {
+				return lastUsedOrder.indexOf(a.id) - lastUsedOrder.indexOf(b.id)
+			}
+			lastUsedTags.sort(sortByLastUsed)
+
+			this.sortedTags = [...lastUsedTags, ...remainingTags]
+		} catch (error) {
+			showError(t('systemtags', 'Failed to load tags'))
+		}
+	},
+
+	watch: {
+		fileId: {
+			immediate: true,
+			async handler() {
+				try {
+					this.selectedTags = await fetchSelectedTags(this.fileId)
+					this.$emit('has-tags', this.selectedTags.length > 0)
+				} catch (error) {
+					showError(t('systemtags', 'Failed to load selected tags'))
+				}
+			},
+		},
+	},
+
+	methods: {
+		t,
+
+		createOption(newDisplayName: string): Tag {
+			for (const tag of this.sortedTags) {
+				const { id, displayName, ...baseTag } = tag
+				if (
+					displayName === newDisplayName
+					&& Object.entries(baseTag)
+						.every(([key, value]) => defaultBaseTag[key] === value)
+				) {
+					// Return existing tag to prevent vue-select from thinking the tags are different and showing duplicate options
+					return tag
+				}
+			}
+			return {
+				...defaultBaseTag,
+				displayName: newDisplayName,
+			}
+		},
+
+		handleInput(selectedTags: Tag[]) {
+			/**
+			 * Filter out tags with no id to prevent duplicate selected options
+			 *
+			 * Created tags are added programmatically by `handleCreate()` with
+			 * their respective ids returned from the server
+			 */
+			this.selectedTags = selectedTags.filter(selectedTag => Boolean(selectedTag.id)) as TagWithId[]
+		},
+
+		async handleSelect(tags: Tag[]) {
+			const selectedTag = tags[tags.length - 1]
+			if (!selectedTag.id) {
+				// Ignore created tags handled by `handleCreate()`
+				return
+			}
+			this.loading = true
+			try {
+				await selectTag(this.fileId, selectedTag)
+				const sortToFront = (a: TagWithId, b: TagWithId) => {
+					if (a.id === selectedTag.id) {
+						return -1
+					} else if (b.id === selectedTag.id) {
+						return 1
+					}
+					return 0
+				}
+				this.sortedTags.sort(sortToFront)
+			} catch (error) {
+				showError(t('systemtags', 'Failed to select tag'))
+			}
+			this.loading = false
+		},
+
+		async handleCreate(tag: Tag) {
+			this.loading = true
+			try {
+				const id = await createTag(this.fileId, tag)
+				const createdTag = { ...tag, id }
+				this.sortedTags.unshift(createdTag)
+				this.selectedTags.push(createdTag)
+			} catch (error) {
+				showError(t('systemtags', 'Failed to create tag'))
+			}
+			this.loading = false
+		},
+
+		async handleDeselect(tag: Tag) {
+			this.loading = true
+			try {
+				await deleteTag(this.fileId, tag)
+			} catch (error) {
+				showError(t('systemtags', 'Failed to delete tag'))
+			}
+			this.loading = false
+		},
+	},
+})
+</script>
+
+<style lang="scss" scoped>
+.system-tags {
+	display: flex;
+	flex-direction: column;
+
+	label[for="system-tags-input"] {
+		margin-bottom: 2px;
+	}
+
+	&__select {
+		width: 100%;
+		:deep {
+			.vs__deselect {
+				padding: 0;
+			}
+		}
+	}
+}
+</style>

+ 28 - 0
apps/systemtags/src/logger.ts

@@ -0,0 +1,28 @@
+/**
+ * @copyright 2023 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+
+export const logger = getLoggerBuilder()
+	.setApp('systemtags')
+	.detectUser()
+	.build()

+ 137 - 0
apps/systemtags/src/services/api.ts

@@ -0,0 +1,137 @@
+/**
+ * @copyright 2023 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+import { translate as t } from '@nextcloud/l10n'
+
+import { davClient } from './davClient.js'
+import { formatTag, parseIdFromLocation, parseTags } from '../utils.js'
+import { logger } from '../logger.js'
+
+import type { FileStat, ResponseDataDetailed } from 'webdav'
+
+import type { ServerTag, Tag, TagWithId } from '../types.js'
+
+const fetchTagsBody = `<?xml version="1.0"?>
+<d:propfind  xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
+	<d:prop>
+		<oc:id />
+		<oc:display-name />
+		<oc:user-visible />
+		<oc:user-assignable />
+		<oc:can-assign />
+	</d:prop>
+</d:propfind>`
+
+export const fetchTags = async (): Promise<TagWithId[]> => {
+	const path = '/systemtags'
+	try {
+		const { data: tags } = await davClient.getDirectoryContents(path, {
+			data: fetchTagsBody,
+			details: true,
+			glob: '/systemtags/*', // Filter out first empty tag
+		}) as ResponseDataDetailed<Required<FileStat>[]>
+		return parseTags(tags)
+	} catch (error) {
+		logger.error(t('systemtags', 'Failed to load tags'), { error })
+		throw new Error(t('systemtags', 'Failed to load tags'))
+	}
+}
+
+export const fetchLastUsedTagIds = async (): Promise<number[]> => {
+	const url = generateUrl('/apps/systemtags/lastused')
+	try {
+		const { data: lastUsedTagIds } = await axios.get<string[]>(url)
+		return lastUsedTagIds.map(Number)
+	} catch (error) {
+		logger.error(t('systemtags', 'Failed to load last used tags'), { error })
+		throw new Error(t('systemtags', 'Failed to load last used tags'))
+	}
+}
+
+export const fetchSelectedTags = async (fileId: number): Promise<TagWithId[]> => {
+	const path = '/systemtags-relations/files/' + fileId
+	try {
+		const { data: tags } = await davClient.getDirectoryContents(path, {
+			data: fetchTagsBody,
+			details: true,
+			glob: '/systemtags-relations/files/*/*', // Filter out first empty tag
+		}) as ResponseDataDetailed<Required<FileStat>[]>
+		return parseTags(tags)
+	} catch (error) {
+		logger.error(t('systemtags', 'Failed to load selected tags'), { error })
+		throw new Error(t('systemtags', 'Failed to load selected tags'))
+	}
+}
+
+export const selectTag = async (fileId: number, tag: Tag | ServerTag): Promise<void> => {
+	const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
+	const tagToPut = formatTag(tag)
+	try {
+		await davClient.customRequest(path, {
+			method: 'PUT',
+			data: tagToPut,
+		})
+	} catch (error) {
+		logger.error(t('systemtags', 'Failed to select tag'), { error })
+		throw new Error(t('systemtags', 'Failed to select tag'))
+	}
+}
+
+/**
+ * @return created tag id
+ */
+export const createTag = async (fileId: number, tag: Tag): Promise<number> => {
+	const path = '/systemtags'
+	const tagToPost = formatTag(tag)
+	try {
+		const { headers } = await davClient.customRequest(path, {
+			method: 'POST',
+			data: tagToPost,
+		})
+		const contentLocation = headers.get('content-location')
+		if (contentLocation) {
+			const tagToPut = {
+				...tagToPost,
+				id: parseIdFromLocation(contentLocation),
+			}
+			await selectTag(fileId, tagToPut)
+			return tagToPut.id
+		}
+		logger.error(t('systemtags', 'Missing "Content-Location" header'))
+		throw new Error(t('systemtags', 'Missing "Content-Location" header'))
+	} catch (error) {
+		logger.error(t('systemtags', 'Failed to create tag'), { error })
+		throw new Error(t('systemtags', 'Failed to create tag'))
+	}
+}
+
+export const deleteTag = async (fileId: number, tag: Tag): Promise<void> => {
+	const path = '/systemtags-relations/files/' + fileId + '/' + tag.id
+	try {
+		await davClient.deleteFile(path)
+	} catch (error) {
+		logger.error(t('systemtags', 'Failed to delete tag'), { error })
+		throw new Error(t('systemtags', 'Failed to delete tag'))
+	}
+}

+ 33 - 0
apps/systemtags/src/services/davClient.ts

@@ -0,0 +1,33 @@
+/**
+ * @copyright 2023 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import { createClient } from 'webdav'
+import { generateRemoteUrl } from '@nextcloud/router'
+import { getRequestToken } from '@nextcloud/auth'
+
+const rootUrl = generateRemoteUrl('dav')
+
+export const davClient = createClient(rootUrl, {
+	headers: {
+		requesttoken: getRequestToken() ?? '',
+	},
+})

+ 38 - 0
apps/systemtags/src/types.ts

@@ -0,0 +1,38 @@
+/**
+ * @copyright 2023 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+export interface BaseTag {
+	id?: number
+	userVisible: boolean
+	userAssignable: boolean
+	readonly canAssign: boolean // Computed server-side
+}
+
+export type Tag = BaseTag & {
+	displayName: string
+}
+
+export type TagWithId = Required<Tag>
+
+export type ServerTag = BaseTag & {
+	name: string
+}

+ 66 - 0
apps/systemtags/src/utils.ts

@@ -0,0 +1,66 @@
+/**
+ * @copyright 2023 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+import camelCase from 'camelcase'
+
+import type { FileStat } from 'webdav'
+
+import type { ServerTag, Tag, TagWithId } from './types.js'
+
+export const parseTags = (tags: Required<FileStat>[]): TagWithId[] => {
+	return tags.map(({ props }) => Object.fromEntries(
+		Object.entries(props)
+			.map(([key, value]) => [camelCase(key), value])
+	)) as TagWithId[]
+}
+
+/**
+ * Parse id from `Content-Location` header
+ */
+export const parseIdFromLocation = (url: string): number => {
+	const queryPos = url.indexOf('?')
+	if (queryPos > 0) {
+		url = url.substring(0, queryPos)
+	}
+
+	const parts = url.split('/')
+	let result
+	do {
+		result = parts[parts.length - 1]
+		parts.pop()
+		// note: first result can be empty when there is a trailing slash,
+		// so we take the part before that
+	} while (!result && parts.length > 0)
+
+	return Number(result)
+}
+
+export const formatTag = (initialTag: Tag | ServerTag): ServerTag => {
+	const tag: any = { ...initialTag }
+	if (tag.name && !tag.displayName) {
+		return tag
+	}
+	tag.name = tag.displayName
+	delete tag.displayName
+
+	return tag
+}

File diff suppressed because it is too large
+ 0 - 0
dist/core-common.js


File diff suppressed because it is too large
+ 0 - 0
dist/core-common.js.map


File diff suppressed because it is too large
+ 0 - 0
dist/files-sidebar.js


+ 22 - 0
dist/files-sidebar.js.LICENSE.txt

@@ -1,3 +1,25 @@
+/**
+ * @copyright 2023 Christopher Ng <chrng8@gmail.com>
+ *
+ * @author Christopher Ng <chrng8@gmail.com>
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
 /**
  * @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
  *

File diff suppressed because it is too large
+ 0 - 0
dist/files-sidebar.js.map


Some files were not shown because too many files changed in this diff