mirror of https://github.com/nextcloud/server
Add accessible system tags select
Signed-off-by: Christopher Ng <chrng8@gmail.com>
This commit is contained in:
parent
c580b1a52c
commit
ee81e2cef8
|
@ -10,6 +10,9 @@ module.exports = {
|
|||
firstDay: true,
|
||||
'cypress/globals': true,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: '@typescript-eslint/parser',
|
||||
},
|
||||
plugins: [
|
||||
'cypress',
|
||||
],
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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()
|
|
@ -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'))
|
||||
}
|
||||
}
|
|
@ -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() ?? '',
|
||||
},
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue