refactor: rework async AppInfo

This commit is contained in:
axolotle 2024-08-13 00:12:56 +02:00
parent 38859f5afc
commit a5714e56fc
5 changed files with 347 additions and 330 deletions

View file

@ -88,6 +88,15 @@ export function arrayDiff<T extends string>(
return arr1.filter((item) => !arr2.includes(item))
}
export function joinOrNull(
value: any[] | string | null | undefined,
): string | null {
if (Array.isArray(value) && value.length) {
return value.join(i18n.global.t('words.separator'))
}
return typeof value === 'string' ? value : null
}
/**
* Returns a new string with escaped HTML (`&<>"'` replaced by entities).
*

View file

@ -1,4 +1,5 @@
import type { Obj, Translation } from '@/types/commons'
import type { Permission } from '@/types/core/data'
// APPS
@ -71,3 +72,48 @@ export type Catalog = {
title: string
}[]
}
export type AppInfo = {
id: string
description: string
label: string
name: string
version: string
domain_path: string
logo: string | null
screenshot?: string
upgradable: string
settings: { domain?: string; path?: string } & Obj
setting_path: string
permissions: Obj<Permission & { sublabel: string }>
manifest: AppMinManifest & {
install: Obj<AnyOption>
upstream: {
license: string | null
website: string | null
demo: string | null
admindoc: string | null
userdoc: string | null
code: string | null
}
resources: Obj
doc: {
DESCRIPTION?: Translation
ADMIN?: Translation
} & Obj<Translation>
notifications: {
PRE_INSTALL: Obj<Translation> | null
POST_INSTALL: Obj<Translation> | null
PRE_UPGRADE: Obj<Translation> | null
POST_UPGRADE: Obj<Translation> | null
}
}
from_catalog: CatalogApp
is_webapp: boolean
is_default: boolean
supports_change_url: boolean
supports_backup_restore: boolean
supports_multi_instance: boolean
supports_config_panel: boolean
supports_purge: boolean
}

View file

@ -1,25 +1,26 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { computed, reactive, ref, shallowRef } from 'vue'
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import api, { objectToParams } from '@/api'
import { type APIError } from '@/api/errors'
import ConfigPanelsComponent from '@/components/ConfigPanels.vue'
import type {
ConfigPanelsProps,
OnPanelApply,
} from '@/composables/configPanels'
import { formatConfigPanels, useConfigPanels } from '@/composables/configPanels'
import { useDomains } from '@/composables/data'
import { useArrayRule, useForm } from '@/composables/form'
import { useAutoModal } from '@/composables/useAutoModal'
import { useInitialQueries } from '@/composables/useInitialQueries'
import { isEmptyValue } from '@/helpers/commons'
import { isEmptyValue, joinOrNull, pick, toEntries } from '@/helpers/commons'
import { humanPermissionName } from '@/helpers/filters/human'
import { helpers, required } from '@/helpers/validators'
import { required } from '@/helpers/validators'
import { formatI18nField } from '@/helpers/yunohostArguments'
import type { Obj } from '@/types/commons'
import type { AppInfo } from '@/types/core/api'
import type { Permission } from '@/types/core/data'
import type { CoreConfigPanels } from '@/types/core/options'
import type { BaseItemComputedProps } from '@/types/form'
import AppIntegrationAndLinks from './_AppIntegrationAndLinks.vue'
import { formatAppIntegration, formatAppLinks } from './appData'
const props = defineProps<{
id: string
@ -27,249 +28,177 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const modalConfirm = useAutoModal()
const { domainsAsChoices } = useDomains()
// FIXME
type AppForm = {
labels: { label: string; show_tile: boolean }[]
url: { domain: string; path: string }
}
const form: AppForm = reactive({
labels: [],
url: { domain: '', path: '' },
})
const rules = computed(() => ({
labels: {
$each: helpers.forEach({
label: { required },
}),
},
url: { path: { required } },
}))
const externalResults = reactive({})
const v$ = useVuelidate(rules, form, { $externalResults: externalResults })
const { loading, refetch } = useInitialQueries(
[
const [app, form, coreConfig, configPanelErr] = await api
.fetchAll<[AppInfo, Obj<Permission>]>([
{ uri: `apps/${props.id}?full` },
// FIXME permissions needed?
{ uri: 'users/permissions?full', cachePath: 'permissions' },
{ uri: 'domains', cachePath: 'domains' },
],
{ onQueriesResponse },
)
])
.then(async ([app_]) => {
// Query config panels if app supports it
let config: CoreConfigPanels | undefined
let configPanelErr: string | undefined
if (app_.supports_config_panel) {
await api
.get<CoreConfigPanels>(`apps/${props.id}/config?full`)
.then((coreConfig) => {
// Fake integration of operations in config panels
coreConfig.panels.unshift({
id: 'operations',
name: t('operations'),
})
config = coreConfig
})
.catch((err: APIError) => {
configPanelErr = err.message
})
}
const app = ref()
const purge = ref(false)
const configPanelErr = ref('')
const config = shallowRef<ConfigPanelsProps | undefined>()
const doc = ref()
const currentTab = computed(() => {
return route.params.tabId
})
const allowedGroups = computed(() => {
if (!app.value) return
return app.value.permissions[0].allowed
})
function appLinksIcons(linkType) {
const linksIcons = {
license: 'institution',
website: 'globe',
admindoc: 'book',
userdoc: 'book',
code: 'code',
package: 'code',
package_license: 'institution',
forum: 'comments',
}
return linksIcons[linkType]
}
async function onQueriesResponse(app_: any) {
// const form = { labels: [] }
const mainPermission = app_.permissions[props.id + '.main']
mainPermission.name = props.id + '.main'
mainPermission.title = t('permission_main')
mainPermission.tileAvailable =
mainPermission.url !== null && !mainPermission.url.startsWith('re:')
form.labels.push({
label: mainPermission.label,
show_tile: mainPermission.show_tile,
})
const permissions = [mainPermission]
for (const [name, perm] of Object.entries(app_.permissions)) {
if (!name.endsWith('.main')) {
permissions.push({
const { domain, path } = app_.settings
const form = ref({
labels: [] as { label: string; show_tile: boolean }[],
url: domain && path ? { domain, path: path.slice(1) } : undefined,
})
const permissions = []
for (const [name, perm] of toEntries(app_.permissions)) {
const isMain = name.endsWith('.main')
const permission = {
...perm,
name,
label: perm.sublabel,
title: humanPermissionName(name),
tileAvailable: perm.url !== null && !perm.url.startsWith('re:'),
})
form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile })
label: isMain ? perm.label : perm.sublabel,
title: isMain ? t('permission_main') : humanPermissionName(name),
tileAvailable: !!perm.url && !perm.url.startsWith('re:'),
}
permissions.push(permission)
form.value.labels.push(pick(permission, ['label', 'show_tile']))
}
}
// this.form = form
const { DESCRIPTION, ADMIN, ...doc } = app_.manifest.doc
const notifs = app_.manifest.notifications
const {
ldap,
sso,
multi_instance,
ram,
disk,
architectures: archs,
} = app_.manifest.integration
app.value = {
id: props.id,
version: app_.version,
label: mainPermission.label,
domain: app_.settings.domain,
alternativeTo: app_.from_catalog.potential_alternative_to?.length
? app_.from_catalog.potential_alternative_to.join(t('words.separator'))
: null,
description: DESCRIPTION ? formatI18nField(DESCRIPTION) : app_.description,
integration:
app_.manifest.packaging_format >= 2
? {
archs: Array.isArray(archs)
? archs.join(t('words.separator'))
: archs,
ldap: ldap === 'not_relevant' ? null : ldap,
sso: sso === 'not_relevant' ? null : sso,
multi_instance,
resources: { ram: ram.runtime, disk },
}
: null,
links: [
[
'license',
`https://spdx.org/licenses/${app_.manifest.upstream.license}`,
],
...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
return [key, app_.manifest.upstream[key]]
const { DESCRIPTION, ADMIN, ...doc } = app_.manifest.doc
const notifs = app_.manifest.notifications
const { label, allowed } = app_.permissions[props.id + '.main']
const app = {
id: props.id,
version: app_.version,
label,
domain,
url: domain && path ? `https://${domain}${path}` : null,
allowedGroups: allowed.length ? allowed.join(', ') : t('nobody'),
alternativeTo: joinOrNull(app_.from_catalog.potential_alternative_to),
description: formatI18nField(DESCRIPTION) || app_.description,
integration: formatAppIntegration(
app_.manifest.integration,
app_.manifest.packaging_format,
),
// TODO: could return `remote` key of manifest to pass only manifest and id?
links: formatAppLinks({
...app_.manifest,
// @ts-expect-error
remote: app_.from_catalog.git ?? { url: null },
}),
['package', app_.from_catalog.git?.url],
['package_license', app_.from_catalog.git?.url + '/blob/master/LICENSE'],
['forum', `https://forum.yunohost.org/tag/${app_.manifest.id}`],
].filter(([key, val]) => !!val),
doc: {
notifications: {
postInstall:
notifs.POST_INSTALL && notifs.POST_INSTALL.main
doc: {
notifications: {
postInstall: notifs.POST_INSTALL?.main
? [['main', formatI18nField(notifs.POST_INSTALL.main)]]
: [],
postUpgrade: notifs.POST_UPGRADE
? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => {
return [key, formatI18nField(content)]
})
: [],
postUpgrade: notifs.POST_UPGRADE
? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => {
return [key, formatI18nField(content)]
})
: [],
},
admin: [
['admin', formatI18nField(ADMIN)],
...Object.keys(doc)
.sort()
.map((key) => [
key.charAt(0) + key.slice(1).toLowerCase(),
formatI18nField(doc[key]),
]),
].filter((doc) => doc[1]),
},
admin: [
['admin', formatI18nField(ADMIN)],
...Object.keys(doc)
.sort()
.map((key) => [
key.charAt(0) + key.slice(1).toLowerCase(),
formatI18nField(doc[key]),
]),
].filter((doc) => doc[1]),
},
is_webapp: app_.is_webapp,
is_default: app_.is_default,
supports_change_url: app_.supports_change_url,
supports_config_panel: app_.supports_config_panel,
supports_purge: app_.supports_purge,
permissions,
}
if (app_.settings.domain && app_.settings.path) {
app.value.url = 'https://' + app_.settings.domain + app_.settings.path
form.url = {
domain: app_.settings.domain,
path: app_.settings.path.slice(1),
isWebapp: app_.is_webapp,
isDefault: ref(app_.is_default),
supportsChangeUrl: app_.supports_change_url,
supportsPurge: app_.supports_purge,
permissions,
}
}
if (
!Object.values(app.value.doc.notifications).some((notif) => notif.length)
) {
app.value.doc.notifications = null
}
return [app, form, config, configPanelErr] as const
})
const { domainsAsChoices } = useDomains()
if (app_.supports_config_panel) {
await api
.get(`apps/${props.id}/config?full`)
.then((cp) => {
const config_ = cp as CoreConfigPanels
// Fake integration of operations in config panels
config_.panels.unshift({
id: 'operations',
name: t('operations'),
})
config.value = useConfigPanels(
formatConfigPanels(config_),
() => props.tabId,
onPanelApply,
)
})
.catch((err: APIError) => {
configPanelErr.value = err.message
})
}
}
const onPanelApply: OnPanelApply = ({ panelId, data, action }, onError) => {
api
.put({
uri: action
? `apps/${props.id}/actions/${action}`
: `apps/${props.id}/config/${panelId}`,
data: isEmptyValue(data) ? {} : { args: objectToParams(data) },
humanKey: {
key: `apps.${action ? 'action' : 'update'}_config`,
id: panelId,
name: props.id,
const config = coreConfig
? useConfigPanels(
formatConfigPanels(coreConfig),
() => props.tabId,
({ panelId, data, action }, onError) => {
api
.put({
uri: action
? `apps/${props.id}/actions/${action}`
: `apps/${props.id}/config/${panelId}`,
data: isEmptyValue(data) ? {} : { args: objectToParams(data) },
humanKey: {
key: `apps.${action ? 'action' : 'update'}_config`,
id: panelId,
name: props.id,
},
})
.then(() => api.refetch())
.catch(onError)
},
})
.then(() => refetch())
.catch(onError)
}
)
: undefined
function changeLabel(permName, data) {
data.show_tile = data.show_tile ? 'True' : 'False'
const fields = {
labels: reactive({
rules: useArrayRule(() => form.value.labels, { label: { required } }),
}),
url: {
rules: { path: { required } },
},
}
const { v } = useForm(form, fields)
const purge = ref(false)
async function changeLabel(permName: string, i: number) {
if (!(await v.value.form.labels[i].$validate())) return
const data = form.value.labels[i]
api
.put({
uri: 'users/permissions/' + permName,
data,
data: {
label: data.label,
show_tile: data.show_tile ? 'True' : 'False',
},
humanKey: {
key: 'apps.change_label',
prevName: app.value.label,
prevName: app.label,
nextName: data.label,
},
})
.then(() => refetch(false))
// FIXME really need to refetch? permissions store update should be ok
.then(() => api.refetch())
}
async function changeUrl() {
if (!(await v.value.form.url.$validate())) return
const confirmed = await modalConfirm(t('confirm_app_change_url'))
if (!confirmed) return
const { domain, path } = form.url
const { domain, path } = form.value.url!
api
.put({
uri: `apps/${props.id}/changeurl`,
data: { domain, path: '/' + path },
humanKey: { key: 'apps.change_url', name: app.value.label },
humanKey: { key: 'apps.change_url', name: app.label },
})
.then(() => refetch(false))
// Refetch because some content of this page relies on the url
.then(() => api.refetch())
}
async function setAsDefaultDomain(undo = false) {
@ -281,20 +210,21 @@ async function setAsDefaultDomain(undo = false) {
uri: `apps/${props.id}/default${undo ? '?undo' : ''}`,
humanKey: {
key: 'apps.set_default',
name: app.value.label,
domain: app.value.domain,
name: app.label,
domain: app.domain,
},
})
.then(() => refetch(false))
.then(() => (app.isDefault.value = true))
}
async function dismissNotification(name: string) {
api
.put({
uri: `apps/${props.id}/dismiss_notification/${name}`,
humanKey: { key: 'apps.dismiss_notification', name: app.value.label },
humanKey: { key: 'apps.dismiss_notification', name: app.label },
})
.then(() => refetch(false))
// FIXME no need to refetch i guess, filter the reactive notifs?
.then(() => api.refetch)
}
async function uninstall() {
@ -303,7 +233,7 @@ async function uninstall() {
.delete({
uri: 'apps/' + props.id,
data,
humanKey: { key: 'apps.uninstall', name: app.value.label },
humanKey: { key: 'apps.uninstall', name: app.label },
})
.then(() => {
router.push({ name: 'app-list' })
@ -312,14 +242,9 @@ async function uninstall() {
</script>
<template>
<ViewBase :loading="loading">
<div>
<YAlert
v-if="
app &&
app.doc &&
app.doc.notifications &&
app.doc.notifications.postInstall.length
"
v-if="app.doc.notifications.postInstall.length"
variant="info"
class="my-4"
>
@ -345,12 +270,7 @@ async function uninstall() {
</YAlert>
<YAlert
v-if="
app &&
app.doc &&
app.doc.notifications &&
app.doc.notifications.postUpgrade.length
"
v-if="app.doc.notifications.postUpgrade.length"
variant="info"
class="my-4"
>
@ -375,7 +295,7 @@ async function uninstall() {
/>
</YAlert>
<section v-if="app" class="border rounded p-3 mb-4">
<section class="border rounded p-3 mb-4">
<div class="d-md-flex align-items-center mb-4">
<h1 class="mb-3 mb-md-0">
<YIcon iname="cube" />
@ -436,14 +356,14 @@ async function uninstall() {
<!-- BASIC INFOS -->
<ConfigPanelsComponent
v-if="config"
v-model="config.form"
v-model="config.form.value"
:panel="config.panel.value"
:validations="config.v.value"
:routes="config.routes"
@apply="config.onPanelApply"
>
<!-- OPERATIONS TAB -->
<template v-if="currentTab === 'operations'" #default>
<template v-if="tabId === 'operations'" #default>
<!-- CHANGE PERMISSIONS LABEL -->
<BFormGroup
:label="$t('app_manage_label_and_tiles')"
@ -457,15 +377,14 @@ async function uninstall() {
label-cols="0"
label-class=""
class="m-0"
:validation="v$.form.labels.$each[i]"
:validation="v.form.labels[i]"
>
<template #default="{ self }">
<template #default="componentProps">
<BInputGroup>
<InputItem
:state="self.state"
v-model="form.labels[i].label"
:id="'perm' + i"
:aria-describedby="'perm-' + i + '_group__BV_description_'"
v-bind="componentProps as BaseItemComputedProps"
v-model="form.labels[i].label"
/>
<BInputGroupText v-if="perm.tileAvailable">
@ -476,9 +395,9 @@ async function uninstall() {
</BInputGroupText>
<BButton
variant="info"
v-t="'save'"
@click="changeLabel(perm.name, form.labels[i])"
variant="info"
@click="changeLabel(perm.name, i)"
/>
</BInputGroup>
</template>
@ -500,9 +419,7 @@ async function uninstall() {
label-class="fw-bold"
label-cols-lg="0"
>
{{
allowedGroups.length > 0 ? allowedGroups.join(', ') : $t('nobody')
}}
{{ app.allowedGroups }}
<BButton
size="sm"
:to="{ name: 'group-list' }"
@ -516,14 +433,13 @@ async function uninstall() {
<hr />
<!-- CHANGE URL -->
<BFormGroup
<FormField
v-if="app.isWebapp"
:label="$t('app_info_changeurl_desc')"
label-for="input-url"
:label-cols-lg="app.supports_change_url ? 0 : 0"
:label-cols="0"
label-class="fw-bold"
v-if="app.is_webapp"
>
<BInputGroup v-if="app.supports_change_url">
<BInputGroup v-if="app.supportsChangeUrl && form.url">
<BInputGroupText>https://</BInputGroupText>
<BFormSelect
@ -533,11 +449,7 @@ async function uninstall() {
<BInputGroupText>/</BInputGroupText>
<BFormInput
id="input-url"
v-model="form.url.path"
class="flex-grow-3"
/>
<BFormInput v-model="form.url.path" class="flex-grow-3" />
<BButton @click="changeUrl" variant="info" v-t="'save'" />
</BInputGroup>
@ -546,18 +458,17 @@ async function uninstall() {
<YIcon iname="exclamation" />
{{ $t('app_info_change_url_disabled_tooltip') }}
</div>
</BFormGroup>
<hr v-if="app.is_webapp" />
</FormField>
<hr v-if="app.isWebapp" />
<!-- MAKE DEFAULT -->
<BFormGroup
<FormField
v-if="app.isWebapp"
:label="$t('app_info_default_desc', { domain: app.domain })"
label-for="main-domain"
label-class="fw-bold"
label-cols-md="4"
v-if="app.is_webapp"
label-cols="0"
>
<template v-if="!app.is_default">
<template v-if="!app.isDefault.value">
<BButton
@click="setAsDefaultDomain(false)"
id="main-domain"
@ -576,11 +487,11 @@ async function uninstall() {
<YIcon iname="star" /> {{ $t('app_make_not_default') }}
</BButton>
</template>
</BFormGroup>
</FormField>
</template>
</ConfigPanelsComponent>
<BCard v-if="app && app.doc.admin.length" no-body>
<BCard v-if="app.doc.admin.length" no-body>
<BTabs card fill pills>
<BTab v-for="[name, content] in app.doc.admin" :key="name">
<template #title>
@ -592,83 +503,23 @@ async function uninstall() {
</BTabs>
</BCard>
<YCard
v-if="app && app.integration"
id="app-integration"
:title="$t('app.integration.title')"
collapsable
collapsed
no-body
>
<BListGroup flush>
<YListGroupItem variant="info">
{{ $t('app.integration.archs') }} {{ app.integration.archs }}
</YListGroupItem>
<YListGroupItem
v-if="app.integration.ldap"
:variant="app.integration.ldap === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.ldap.${app.integration.ldap}`) }}
</YListGroupItem>
<YListGroupItem
v-if="app.integration.sso"
:variant="app.integration.sso === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.sso.${app.integration.sso}`) }}
</YListGroupItem>
<YListGroupItem variant="info">
{{
$t(
`app.integration.multi_instance.${app.integration.multi_instance}`,
)
}}
</YListGroupItem>
<YListGroupItem variant="info">
{{ $t('app.integration.resources', app.integration.resources) }}
</YListGroupItem>
</BListGroup>
</YCard>
<YCard
v-if="app"
id="app-links"
icon="link"
:title="$t('app.links.title')"
collapsable
collapsed
no-body
>
<BListGroup flush>
<YListGroupItem v-for="[key, link] in app.links" :key="key" no-status>
<BLink :href="link" target="_blank">
<YIcon :iname="appLinksIcons(key)" class="me-1" />
{{ $t('app.links.' + key) }}
</BLink>
</YListGroupItem>
</BListGroup>
</YCard>
<AppIntegrationAndLinks :integration="app.integration" :links="app.links" />
<BModal
v-if="app"
id="uninstall-modal"
:title="$t('confirm_uninstall', { name: id })"
header-bg-variant="warning"
header-class="text-black"
:body-class="{ 'd-none': !app.supports_purge }"
:body-class="{ 'd-none': !app.supportsPurge }"
@ok="uninstall"
>
<BFormGroup v-if="app.supports_purge">
<BFormGroup v-if="app.supportsPurge">
<BFormCheckbox v-model="purge">
{{ $t('app.uninstall.purge_desc', { name: id }) }}
</BFormCheckbox>
</BFormGroup>
</BModal>
<template #skeleton>
<CardInfoSkeleton :item-count="8" />
<CardFormSkeleton />
</template>
</ViewBase>
</div>
</template>
<style lang="scss" scoped>
@ -689,4 +540,9 @@ select {
.yuno-alert div div:not(:last-child) {
margin-bottom: 1rem;
}
// FIXME bootstrap-vue-next bug
:deep(.card-header-tabs) {
margin-bottom: unset;
}
</style>

View file

@ -0,0 +1,67 @@
<script setup lang="ts">
import type { AppLinks, AppIntegration } from './appData'
defineProps<{
links: AppLinks
integration?: AppIntegration
}>()
</script>
<template>
<YCard
v-if="integration"
id="app-integration"
:title="$t('app.integration.title')"
collapsable
collapsed
no-body
>
<BListGroup flush>
<YListGroupItem variant="info">
{{ $t('app.integration.archs') }} {{ integration.archs }}
</YListGroupItem>
<YListGroupItem
v-if="integration.ldap"
:variant="integration.ldap === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.ldap.${integration.ldap}`) }}
</YListGroupItem>
<YListGroupItem
v-if="integration.sso"
:variant="integration.sso === true ? 'success' : 'warning'"
>
{{ $t(`app.integration.sso.${integration.sso}`) }}
</YListGroupItem>
<YListGroupItem variant="info">
{{ $t(`app.integration.multi_instance.${integration.multiInstance}`) }}
</YListGroupItem>
<YListGroupItem variant="info">
{{ $t('app.integration.resources', integration.resources) }}
</YListGroupItem>
</BListGroup>
</YCard>
<YCard
id="app-links"
icon="link"
:title="$t('app.links.title')"
collapsable
collapsed
no-body
>
<template #header>
<h2><YIcon iname="link" /> {{ $t('app.links.title') }}</h2>
</template>
<BListGroup flush>
<template v-for="([icon, link], key) in links" :key="key">
<YListGroupItem v-if="link" no-status>
<YIcon :iname="icon" class="me-3" />
<BLink :href="link" target="_blank">
{{ $t('app.links.' + key) }}
</BLink>
</YListGroupItem>
</template>
</BListGroup>
</YCard>
</template>

View file

@ -1,4 +1,5 @@
import type { AppLevel, AppState } from '@/types/core/api'
import { joinOrNull } from '@/helpers/commons'
import type { AppLevel, AppManifest, AppState } from '@/types/core/api'
export function formatAppQuality(app: { state: AppState; level: AppLevel }) {
const variants = {
@ -17,3 +18,41 @@ export function formatAppQuality(app: { state: AppState; level: AppLevel }) {
: app.state
return { state, variant: variants[state] }
}
export function formatAppIntegration(
{
architectures,
ldap,
sso,
multi_instance,
ram,
disk,
}: AppManifest['integration'],
packagingFormat: number,
) {
if (packagingFormat < 2) return undefined // LEGACY
return {
archs: joinOrNull(architectures),
ldap: ldap === 'not_relevant' ? null : ldap,
sso: sso === 'not_relevant' ? null : sso,
multiInstance: multi_instance,
resources: { ram: ram.runtime, disk },
}
}
export function formatAppLinks({ upstream, id, remote }: AppManifest) {
const url = remote.url
return {
license: ['institution', `https://spdx.org/licenses/${upstream.license}`],
website: ['globe', upstream.website],
admindoc: ['book', upstream.admindoc],
userdoc: ['book', upstream.userdoc],
code: ['code', upstream.code],
package: ['code', url],
package_license: ['institution', url ? `${url}/blob/master/LICENSE` : null],
forum: ['comments', `https://forum.yunohost.org/tag/${id}`],
} as const
}
export type AppIntegration = ReturnType<typeof formatAppIntegration>
export type AppLinks = ReturnType<typeof formatAppLinks>