fix: meta title missing translation + missing props on duplicate + tags ordering

scarlett
NGPixel 2 days ago
parent b2edc4fd00
commit 795414ef71
No known key found for this signature in database

@ -264,7 +264,12 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import * as monaco from 'monaco-editor'
import { renderVersionSource, saveVersionSource, versionContentType } from '@/helpers/pageVersions'
import {
renderVersionSource,
saveVersionSource,
versionContentType,
versionPageProps
} from '@/helpers/pageVersions'
import { confirm, dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
@ -627,18 +632,14 @@ function branchFrom(version) {
const content = full.content ?? ''
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
json: {
// -> Everything the page was, as far as a new page may be given it; see `versionPageProps`
...versionPageProps(full),
path: target.path,
title: target.title,
locale: pageStore.locale,
editor: full.meta?.editor || pageStore.editor,
content,
render: await renderOf(full),
description: full.meta?.description ?? '',
icon: full.meta?.icon ?? '',
tags: full.meta?.tags ?? [],
// -> A version that was scheduled carries dates this new page has not got, and the API
// rightly refuses that combination
publishState: full.meta?.publishState === 'published' ? 'published' : 'draft',
reasonForChange: t('history.branchReason', { date: humanizeDate(full.versionDate) })
}
}).json()

@ -30,7 +30,7 @@
v-if="props.edit"
outlined
v-model="pageStore.tags"
:options="state.tags"
:options="sortedTags"
dense
options-dense
use-input
@ -45,7 +45,7 @@
</template>
<script setup>
import { reactive, watch } from 'vue'
import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
@ -87,6 +87,22 @@ const state = reactive({
loading: false
})
// COMPUTED
/**
* The suggestions in the order they are offered: alphabetical.
*
* `GET /sites/:siteId/tags` answers most-used first, and deliberately -- that ordering is what makes
* a `limit` mean the tags a wiki is actually about. It is the wrong order to pick from, though: with
* a few hundred tags in the list, WSelect narrows it as you type but never reorders it, so scanning
* the remaining suggestions meant reading them in an order nothing on screen explains. Sorted here
* rather than in the store, because usage order is the answer another caller may want.
*
* `localeCompare`, not a plain `sort()`, which would order by code unit and scatter every
* non-ASCII tag -- tags are page-authored words, in whatever language the wiki is written in.
*/
const sortedTags = computed(() => [...state.tags].sort((a, b) => a.localeCompare(b)))
// WATCHERS
pageStore.$subscribe(() => {

@ -84,9 +84,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('welcome.title')
})
}))
// METHODS

@ -36,23 +36,24 @@ function apply() {
}
/**
* @param {object|(() => object)} source Either a plain `{ title }` / `{ titleTemplate }` object, or
* a getter returning one -- pass a getter when the title depends on reactive state, since a plain
* object is read once at call time.
* @param {() => object} source A getter returning `{ title }` / `{ titleTemplate }`, re-read
* whenever anything it touched changes.
*
* A getter and not a plain object, because every caller reads reactive state that is not
* necessarily there yet: the locale strings are fetched after the app mounts (`App.vue` ->
* `applyLocale`) and the site config with them, so a `t()` or a `siteStore.title` evaluated once
* during `setup()` resolves to a raw translation key on a page loaded directly, and nothing would
* ever come back to correct it. Read inside the effect, the tab title fixes itself the moment
* `setLocaleMessage` fills the strings in.
*/
export function useMeta(source) {
const entry = {}
stack.push(entry)
if (typeof source === 'function') {
watchEffect(() => {
Object.assign(entry, source())
apply()
})
} else {
Object.assign(entry, source)
watchEffect(() => {
Object.assign(entry, source())
apply()
}
})
onScopeDispose(() => {
const idx = stack.indexOf(entry)

@ -1,4 +1,5 @@
import { fileSave } from 'browser-fs-access'
import { omitBy, pick } from 'es-toolkit/object'
import { MarkdownRenderer } from '@/renderers/markdown'
@ -6,8 +7,8 @@ import { MarkdownRenderer } from '@/renderers/markdown'
* What a recorded page version is, to the two screens that show one.
*
* The history overlay and the version view both have to answer the same questions about a version --
* what format was it written in, what does its source save as, and what HTML does it become -- and
* they used to answer them separately. The format question alone is asked six times between them
* what format was it written in, what does its source save as, what HTML does it become, and what
* does a new page branched off it start with -- and they used to answer them separately. The format question alone is asked six times between them
* (colouring a diff, two renders, two downloads, a restore), so it lives here once.
*
* Nothing here touches a store or the i18n catalogue, which is what keeps it a helper: a caller
@ -101,3 +102,72 @@ export function renderVersionSource(version, { markdownConfig, pagePath }) {
}
return new MarkdownRenderer(markdownConfig ?? {}).render(content, { pagePath })
}
/**
* A version's page properties, in the shape `POST /pages` takes them.
*
* What branching from a version needs, and the reason it needs a translation: a version's `meta` is
* the stored ROW minus the columns it has of its own (`pageHistory.record`), so the display options
* are inside a `config` blob and the per-page scripts inside a `scripts` one, under the names the
* columns use. `PageInput` is flat and spells the scripts differently. Both screens that branch were
* carrying four fields by hand and dropping the rest.
*
* Two page properties are deliberately not among them. `alias` is unique across the site, so a
* branch carrying the source's would be refused with a 409 nobody asked for; and a version records
* `localeGroupId` -- the translation set the page was in -- which is not something a new page joins
* by stating it, and whose set already holds a page for this locale anyway. Both are left for the
* author to fill in on the new page.
*
* A key the version has no answer for is left out rather than defaulted here, so that `createPage`
* applies its own default -- which is what a version recorded before a field existed should get.
*
* @param {object} version A version as the API returns one; `content` is not needed.
* @returns {object} Page properties, ready to spread into a create payload.
*/
export function versionPageProps(version) {
const meta = version?.meta ?? {}
const config = meta.config ?? {}
const scripts = meta.scripts ?? {}
return omitBy(
{
description: meta.description,
icon: meta.icon,
tags: meta.tags,
/*
Narrowed to a relation's own fields, as `pageLoad` does with the live page's: `relations` is
stored as JSON, so anything an older shape left in one would otherwise be written back out.
*/
relations: (meta.relations ?? []).map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
),
/*
The state and its dates travel together or not at all -- a `scheduled` page with neither date
is refused, which is why this used to be downgraded to a draft rather than carried.
*/
publishState: meta.publishState,
publishStartDate: meta.publishStartDate,
publishEndDate: meta.publishEndDate,
isBrowsable: meta.isBrowsable,
isSearchable: meta.isSearchable,
// -> A branch off a protected page is protected too, rather than being an unlocked copy of a
// body somebody chose to put a password on
password: meta.password,
allowComments: config.allowComments,
allowContributions: config.allowContributions,
allowRatings: config.allowRatings,
showSidebar: config.showSidebar,
showTags: config.showTags,
showToc: config.showToc,
tocDepth: config.tocDepth,
/*
Writing either one needs a permission (`write:scripts`, `write:styles`), and `buildScripts`
drops what the author may not write rather than refusing the page -- so a branch made by
somebody without them simply arrives without them.
*/
scriptJsLoad: scripts.jsLoad,
scriptJsUnload: scripts.jsUnload,
scriptCss: scripts.css
},
(v) => v === undefined || v === null
)
}

@ -186,9 +186,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.api.title')
})
}))
// DATA

@ -143,9 +143,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.approval.title')
})
}))
// DATA

@ -290,9 +290,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.audit.title')
})
}))
// DATA

@ -427,9 +427,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.auth.title')
})
}))
// CONSTANTS

@ -135,9 +135,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.editors.title')
})
}))
const state = reactive({
loading: 0,

@ -461,9 +461,9 @@ const versionCard = computed(() => {
// META
useMeta({
useMeta(() => ({
title: t('admin.dashboard.title')
})
}))
// METHODS

@ -116,9 +116,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.editors.title')
})
}))
const state = reactive({
loading: 0,

@ -132,9 +132,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.extensions.title')
})
}))
// DATA

@ -163,9 +163,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.flags.title')
})
}))
// DATA

@ -632,9 +632,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.dashboard.title')
})
}))
// DATA

@ -149,9 +149,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.groups.title')
})
}))
// COMPUTED

@ -266,9 +266,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.icons.title')
})
}))
// DATA

@ -122,9 +122,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.instances.title')
})
}))
// DATA

@ -199,9 +199,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.locale.title')
})
}))
// DATA

@ -263,9 +263,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.login.title')
})
}))
// DATA

@ -392,9 +392,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.mail.title')
})
}))
// DATA

@ -113,9 +113,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.mcp.title')
})
}))
// DATA

@ -268,9 +268,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.metrics.title')
})
}))
/**
* The loopback addresses the `local` class is named by, shown beside its label.

@ -346,9 +346,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.navigation.title')
})
}))
// DATA

@ -93,9 +93,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.rendering.title')
})
}))
// DATA

@ -429,9 +429,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.scheduler.title')
})
}))
// DATA

@ -126,9 +126,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.search.title')
})
}))
// DATA

@ -442,9 +442,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.security.title')
})
}))
// DATA

@ -141,9 +141,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.sites.title')
})
}))
// METHODS

@ -664,9 +664,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.storage.title')
})
}))
// DATA

@ -296,9 +296,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.system.title')
})
}))
// DATA

@ -88,9 +88,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.terminal.title')
})
}))
// DATA

@ -352,9 +352,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.theme.title')
})
}))
// DATA

@ -198,9 +198,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.users.title')
})
}))
// COMPUTED

@ -257,9 +257,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.utilities.title')
})
}))
// DATA

@ -148,9 +148,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('admin.webhooks.title')
})
}))
// DATA

@ -69,9 +69,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('common.error.title')
})
}))
// MOUNTED

@ -18,7 +18,7 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('inbox.inbox')
})
}))
</script>

@ -178,9 +178,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('inbox.pendingReview')
})
}))
// DATA

@ -93,9 +93,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('inbox.watching')
})
}))
// DATA

@ -30,9 +30,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('auth.login.title')
})
}))
</script>
<style lang="scss">

@ -157,7 +157,7 @@ import { loading } from '@/composables/loading'
import { apiErrorMessage } from '@/helpers/apiError'
import { scrollToAnchor } from '@/helpers/anchors'
import { enhanceRenderedContent, resolveContentClick } from '@/helpers/renderedContent'
import { renderVersionSource, saveVersionSource } from '@/helpers/pageVersions'
import { renderVersionSource, saveVersionSource, versionPageProps } from '@/helpers/pageVersions'
import { flattenToc } from '@/helpers/toc'
import { useEditorStore } from '@/stores/editor'
@ -467,6 +467,8 @@ function branchFrom() {
try {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
json: {
// -> Everything the page was, as far as a new page may be given it; see `versionPageProps`
...versionPageProps(version),
path: target.path,
title: target.title,
locale: version.pageLocale,
@ -474,12 +476,6 @@ function branchFrom() {
content: version.content ?? '',
// -> Rendered for where it is going, not for where the version came from
render: await renderFor(version, target.path),
description: version.meta?.description ?? '',
icon: version.meta?.icon ?? '',
tags: version.meta?.tags ?? [],
// -> A version that was scheduled carries dates this new page has not got, and the API
// rightly refuses that combination
publishState: version.meta?.publishState === 'published' ? 'published' : 'draft',
reasonForChange: t('history.branchReason', { date: snapshotFrom.value })
}
}).json()

@ -179,9 +179,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('profile.auth')
})
}))
// DATA

@ -74,9 +74,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('profile.avatar')
})
}))
// DATA

@ -37,9 +37,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('profile.groups')
})
}))
// DATA

@ -234,9 +234,9 @@ const { t } = useI18n()
// META
useMeta({
useMeta(() => ({
title: t('profile.myInfo')
})
}))
// DATA

@ -348,7 +348,16 @@ const publishStates = computed(() => {
]
})
const tags = computed(() => siteStore.tags.map((t) => t.tag))
/**
* The tags offered by the filter, alphabetically.
*
* `GET /sites/:siteId/tags` answers most-used first, which is deliberate on its side but the wrong
* order to pick from: WSelect narrows the list as you type and never reorders it, so the remaining
* suggestions read in an order nothing on screen explains. `localeCompare` rather than a plain
* `sort()`, since a tag is a page-authored word and code-unit order scatters every non-ASCII one.
* `PageTags` sorts the same list the same way for the editor's field.
*/
const tags = computed(() => siteStore.tags.map((t) => t.tag).sort((a, b) => a.localeCompare(b)))
const defaultPageIcon = DEFAULT_PAGE_ICON

@ -15,6 +15,37 @@ import { useUserStore } from './user'
*/
export const DEFAULT_PAGE_ICON = 'mdi:file-document-outline'
/**
* The page properties a copy of a page starts with, as `pageDuplicate` reads them off the source.
*
* Everything the properties panel edits, less the four a copy cannot be given. `title`, `path` and
* `locale` are what the author just picked in the dialog, and `description` comes across as an
* argument of its own. `alias` is unique across the site, so a copy carrying the source's would be
* refused with a 409 nobody asked for. `localeRelations` is a translation set that already holds a
* page for this locale -- refused the same way. Both are left for the author to fill in on the copy.
*/
const DUPLICATED_PAGE_PROPS = [
'allowComments',
'allowContributions',
'allowRatings',
'icon',
'isBrowsable',
'isSearchable',
'password',
'publishEndDate',
'publishStartDate',
'publishState',
'relations',
'scriptCss',
'scriptJsLoad',
'scriptJsUnload',
'showSidebar',
'showTags',
'showToc',
'tags',
'tocDepth'
]
export const usePageStore = defineStore('page', {
state: () => ({
alias: '',
@ -369,6 +400,11 @@ export const usePageStore = defineStore('page', {
/**
* PAGE - CREATE
*/
/**
* @param props Page properties the new page starts with, over the defaults below -- which is
* what duplicating a page carries across from its source. Every key is optional and an absent
* one means the default, so creating a blank page passes none of them.
*/
async pageCreate({
editor,
locale,
@ -377,6 +413,7 @@ export const usePageStore = defineStore('page', {
title = '',
description = '',
content = '',
props = {},
fromNavigate = false
} = {}) {
const editorStore = useEditorStore()
@ -432,14 +469,43 @@ export const usePageStore = defineStore('page', {
editor,
title: title ?? '',
description: description ?? '',
icon: DEFAULT_PAGE_ICON,
icon: props.icon ?? DEFAULT_PAGE_ICON,
// -> Never carried over by a copy: see `DUPLICATED_PAGE_PROPS`
alias: '',
publishState: 'published',
relations: [],
publishState: props.publishState ?? 'published',
/*
Set here alongside the state they belong to rather than left at whatever page the store
last held: a `scheduled` page with no dates is refused, and so is a date on a page that is
not scheduled, so the three only ever make sense together.
*/
publishStartDate: props.publishStartDate ?? '',
publishEndDate: props.publishEndDate ?? '',
relations: props.relations ?? [],
// -> A page being created is in no translation set yet, whatever the page it was started from
// belonged to
// belonged to -- a copy included, whose set already holds a page for this locale
localeRelations: [],
tags: [],
tags: props.tags ?? [],
allowComments: props.allowComments ?? false,
allowContributions: props.allowContributions ?? true,
allowRatings: props.allowRatings ?? true,
showSidebar: props.showSidebar ?? true,
showTags: props.showTags ?? true,
showToc: props.showToc ?? true,
tocDepth: props.tocDepth ?? { min: 1, max: 2 },
/*
Writing either one needs a permission (`write:scripts`, `write:styles`), and the server
drops what an author may not write rather than refusing the page -- so a copy made by
somebody without them arrives without them, which is the right answer either way.
*/
scriptJsLoad: props.scriptJsLoad ?? '',
scriptJsUnload: props.scriptJsUnload ?? '',
scriptCss: props.scriptCss ?? '',
/*
A copy of a protected page is protected too. The source's password is only in the answer
for a requester who may edit it -- and that is the same requester the source's CONTENT is
in the answer for, so a copy can never end up holding the body without the lock.
*/
password: props.password ?? '',
content: content ?? '',
// -> A page being created has no stored source to lose: whatever it starts with IS the source
contentLoaded: true,
@ -451,9 +517,13 @@ export const usePageStore = defineStore('page', {
`createPage` in `models/pages.ts` -- so this is the store agreeing with it rather than
deciding it. The difference is that browsing is a choice the author can turn back on, in the
redirect editor or the properties panel, and searching is not offered at all.
A copy states the source's answer instead, since both are properties panel fields. That
cannot smuggle a searchable redirection in: `createPage` forces `isSearchable` false for
one whatever it was sent, so the source's own answer was already false.
*/
isBrowsable: editor !== 'redirect',
isSearchable: editor !== 'redirect',
isBrowsable: props.isBrowsable ?? editor !== 'redirect',
isSearchable: props.isSearchable ?? editor !== 'redirect',
// -> The page being created is very often the one that was missing, and it is not missing now
notFound: false,
mode: 'edit'
@ -461,6 +531,16 @@ export const usePageStore = defineStore('page', {
},
/**
* PAGE - DUPLICATE
*
* A copy is the whole page and not just its text: the properties panel is where most of what
* makes a page what it is lives -- its tags, its relations, its per-page CSS and scripts, what
* its sidebar shows, whether it is browsable, its password -- and a copy that dropped all of it
* left the author reproducing the original by hand beside it. Carried across through
* `pageCreate`, which is what makes them survive as far as the save: `pageSave` sends every one
* of these fields on a create, so seeding the store is all that was ever missing.
*
* Nothing is written here. What comes back from the dialog is where the copy should go, and the
* editor opens on an unsaved page -- so a duplicate nobody saves never existed.
*/
async pageDuplicate({ sourcePageId, title, path, locale }) {
const siteStore = useSiteStore()
@ -472,7 +552,7 @@ export const usePageStore = defineStore('page', {
if (!pageData?.id) {
throw new Error('ERR_PAGE_NOT_FOUND')
}
this.pageCreate({
await this.pageCreate({
editor: pageData.editor,
title,
path,
@ -480,7 +560,22 @@ export const usePageStore = defineStore('page', {
// at the same path, in a locale that does not have it yet
locale,
content: pageData.content,
description: pageData.description
description: pageData.description,
/*
Picked rather than spread: the answer also carries what identifies the SOURCE -- its id,
its hash, its author, its dates, the reader's own standing on it -- and none of that
describes the page being written.
`relations` is narrowed to its own fields on the way in, as `pageLoad` does with it: it
is the one field of these whose schema is `additionalProperties: true`, so it is the one
whose extra keys survive being serialized and would be written back out on the copy.
*/
props: {
...pick(pageData, DUPLICATED_PAGE_PROPS),
relations: (pageData.relations ?? []).map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
)
}
})
} catch (err) {
console.warn(err)

Loading…
Cancel
Save