зеркало из https://github.com/nextcloud/server.git
Add accessible system tags select
Signed-off-by: Christopher Ng <chrng8@gmail.com>
This commit is contained in:
Родитель
c580b1a52c
Коммит
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
|
||||
}
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
|
@ -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>
|
||||
*
|
||||
|
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
Загрузка…
Ссылка в новой задаче