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

scarlett
NGPixel 3 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 { useRouter } from 'vue-router'
import * as monaco from 'monaco-editor' 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 { confirm, dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
@ -627,18 +632,14 @@ function branchFrom(version) {
const content = full.content ?? '' const content = full.content ?? ''
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, { const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
json: { json: {
// -> Everything the page was, as far as a new page may be given it; see `versionPageProps`
...versionPageProps(full),
path: target.path, path: target.path,
title: target.title, title: target.title,
locale: pageStore.locale, locale: pageStore.locale,
editor: full.meta?.editor || pageStore.editor, editor: full.meta?.editor || pageStore.editor,
content, content,
render: await renderOf(full), 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) }) reasonForChange: t('history.branchReason', { date: humanizeDate(full.versionDate) })
} }
}).json() }).json()

@ -30,7 +30,7 @@
v-if="props.edit" v-if="props.edit"
outlined outlined
v-model="pageStore.tags" v-model="pageStore.tags"
:options="state.tags" :options="sortedTags"
dense dense
options-dense options-dense
use-input use-input
@ -45,7 +45,7 @@
</template> </template>
<script setup> <script setup>
import { reactive, watch } from 'vue' import { computed, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -87,6 +87,22 @@ const state = reactive({
loading: false 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 // WATCHERS
pageStore.$subscribe(() => { pageStore.$subscribe(() => {

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

@ -36,23 +36,24 @@ function apply() {
} }
/** /**
* @param {object|(() => object)} source Either a plain `{ title }` / `{ titleTemplate }` object, or * @param {() => object} source A getter returning `{ title }` / `{ titleTemplate }`, re-read
* a getter returning one -- pass a getter when the title depends on reactive state, since a plain * whenever anything it touched changes.
* object is read once at call time. *
* 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) { export function useMeta(source) {
const entry = {} const entry = {}
stack.push(entry) stack.push(entry)
if (typeof source === 'function') { watchEffect(() => {
watchEffect(() => { Object.assign(entry, source())
Object.assign(entry, source())
apply()
})
} else {
Object.assign(entry, source)
apply() apply()
} })
onScopeDispose(() => { onScopeDispose(() => {
const idx = stack.indexOf(entry) const idx = stack.indexOf(entry)

@ -1,4 +1,5 @@
import { fileSave } from 'browser-fs-access' import { fileSave } from 'browser-fs-access'
import { omitBy, pick } from 'es-toolkit/object'
import { MarkdownRenderer } from '@/renderers/markdown' 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. * 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 -- * 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 * what format was it written in, what does its source save as, what HTML does it become, and what
* they used to answer them separately. The format question alone is asked six times between them * 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. * (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 * 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 }) 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 // META
useMeta({ useMeta(() => ({
title: t('admin.api.title') title: t('admin.api.title')
}) }))
// DATA // DATA

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -157,7 +157,7 @@ import { loading } from '@/composables/loading'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
import { scrollToAnchor } from '@/helpers/anchors' import { scrollToAnchor } from '@/helpers/anchors'
import { enhanceRenderedContent, resolveContentClick } from '@/helpers/renderedContent' 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 { flattenToc } from '@/helpers/toc'
import { useEditorStore } from '@/stores/editor' import { useEditorStore } from '@/stores/editor'
@ -467,6 +467,8 @@ function branchFrom() {
try { try {
const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, { const resp = await API_CLIENT.post(`sites/${siteStore.id}/pages`, {
json: { json: {
// -> Everything the page was, as far as a new page may be given it; see `versionPageProps`
...versionPageProps(version),
path: target.path, path: target.path,
title: target.title, title: target.title,
locale: version.pageLocale, locale: version.pageLocale,
@ -474,12 +476,6 @@ function branchFrom() {
content: version.content ?? '', content: version.content ?? '',
// -> Rendered for where it is going, not for where the version came from // -> Rendered for where it is going, not for where the version came from
render: await renderFor(version, target.path), 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 }) reasonForChange: t('history.branchReason', { date: snapshotFrom.value })
} }
}).json() }).json()

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

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

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

@ -234,9 +234,9 @@ const { t } = useI18n()
// META // META
useMeta({ useMeta(() => ({
title: t('profile.myInfo') title: t('profile.myInfo')
}) }))
// DATA // 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 const defaultPageIcon = DEFAULT_PAGE_ICON

@ -15,6 +15,37 @@ import { useUserStore } from './user'
*/ */
export const DEFAULT_PAGE_ICON = 'mdi:file-document-outline' 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', { export const usePageStore = defineStore('page', {
state: () => ({ state: () => ({
alias: '', alias: '',
@ -369,6 +400,11 @@ export const usePageStore = defineStore('page', {
/** /**
* PAGE - CREATE * 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({ async pageCreate({
editor, editor,
locale, locale,
@ -377,6 +413,7 @@ export const usePageStore = defineStore('page', {
title = '', title = '',
description = '', description = '',
content = '', content = '',
props = {},
fromNavigate = false fromNavigate = false
} = {}) { } = {}) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
@ -432,14 +469,43 @@ export const usePageStore = defineStore('page', {
editor, editor,
title: title ?? '', title: title ?? '',
description: description ?? '', description: description ?? '',
icon: DEFAULT_PAGE_ICON, icon: props.icon ?? DEFAULT_PAGE_ICON,
// -> Never carried over by a copy: see `DUPLICATED_PAGE_PROPS`
alias: '', alias: '',
publishState: 'published', publishState: props.publishState ?? 'published',
relations: [], /*
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 // -> 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: [], 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 ?? '', content: content ?? '',
// -> A page being created has no stored source to lose: whatever it starts with IS the source // -> A page being created has no stored source to lose: whatever it starts with IS the source
contentLoaded: true, 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 `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 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. 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', isBrowsable: props.isBrowsable ?? editor !== 'redirect',
isSearchable: 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 // -> The page being created is very often the one that was missing, and it is not missing now
notFound: false, notFound: false,
mode: 'edit' mode: 'edit'
@ -461,6 +531,16 @@ export const usePageStore = defineStore('page', {
}, },
/** /**
* PAGE - DUPLICATE * 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 }) { async pageDuplicate({ sourcePageId, title, path, locale }) {
const siteStore = useSiteStore() const siteStore = useSiteStore()
@ -472,7 +552,7 @@ export const usePageStore = defineStore('page', {
if (!pageData?.id) { if (!pageData?.id) {
throw new Error('ERR_PAGE_NOT_FOUND') throw new Error('ERR_PAGE_NOT_FOUND')
} }
this.pageCreate({ await this.pageCreate({
editor: pageData.editor, editor: pageData.editor,
title, title,
path, path,
@ -480,7 +560,22 @@ export const usePageStore = defineStore('page', {
// at the same path, in a locale that does not have it yet // at the same path, in a locale that does not have it yet
locale, locale,
content: pageData.content, 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) { } catch (err) {
console.warn(err) console.warn(err)

Loading…
Cancel
Save