feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles
This commit is contained in:
1 parent
339e646eb8
commit
45fbbc33b1
93 files changed
+7630
-294
No files matched your search
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addTranslationLocation } from '@/lib/addTranslationLocation'
|
||||
|
||||
describe('The list row menu leads to the Add translation dialog', () => {
|
||||
it('names the language when exactly one is missing', () => {
|
||||
expect(addTranslationLocation({ id: 12, missing_langs: ['de'] })).toEqual({
|
||||
path: '/article/12/edit',
|
||||
query: { dialog: 'add-translation', lang: 'de' },
|
||||
})
|
||||
})
|
||||
|
||||
it('names no language when several are missing', () => {
|
||||
expect(addTranslationLocation({ id: 12, missing_langs: ['de', 'ar'] })).toEqual({
|
||||
path: '/article/12/edit',
|
||||
query: { dialog: 'add-translation' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the language of the chip that was activated', () => {
|
||||
expect(addTranslationLocation({ id: 12, missing_langs: ['de', 'ar'] }, 'ar')).toEqual({
|
||||
path: '/article/12/edit',
|
||||
query: { dialog: 'add-translation', lang: 'ar' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { languageAfterSiteChange } from '@/lib/languageAfterSiteChange'
|
||||
|
||||
describe('languageAfterSiteChange', () => {
|
||||
it('keeps the language the new site still offers', () => {
|
||||
expect(languageAfterSiteChange('fr', ['en', 'fr'], 'en')).toEqual({
|
||||
lang: 'fr',
|
||||
replaced: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the default and reports the replacement', () => {
|
||||
expect(languageAfterSiteChange('de', ['en'], 'en')).toEqual({
|
||||
lang: 'en',
|
||||
replaced: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('picks the default without a replacement when nothing was chosen', () => {
|
||||
expect(languageAfterSiteChange(undefined, ['en', 'fr'], 'en')).toEqual({
|
||||
lang: 'en',
|
||||
replaced: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the language empty when the site has no default', () => {
|
||||
expect(languageAfterSiteChange('de', [], null)).toEqual({
|
||||
lang: undefined,
|
||||
replaced: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError } from '@/lib/apiError'
|
||||
import { classifyWithLanguages } from '@/lib/translationFailure'
|
||||
import type { SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
function row(lang: string, enabled: boolean) {
|
||||
return { lang, enabled } as SiteLanguage
|
||||
}
|
||||
|
||||
const unavailable = new ApiError(409, undefined, 'language_unavailable')
|
||||
|
||||
describe('classifyWithLanguages', () => {
|
||||
it('reads hidden while the language is still enabled', async () => {
|
||||
const outcome = await classifyWithLanguages(unavailable, 'de', async () => [
|
||||
row('de', true),
|
||||
])
|
||||
|
||||
expect(outcome).toBe('hidden')
|
||||
})
|
||||
|
||||
it('reads langOff once the language is disabled', async () => {
|
||||
const outcome = await classifyWithLanguages(unavailable, 'de', async () => [
|
||||
row('de', false),
|
||||
])
|
||||
|
||||
expect(outcome).toBe('langOff')
|
||||
})
|
||||
|
||||
it('assumes the language is still enabled when the languages cannot be read', async () => {
|
||||
const outcome = await classifyWithLanguages(unavailable, 'de', async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
|
||||
expect(outcome).toBe('hidden')
|
||||
})
|
||||
|
||||
it('does not read the languages for other failures', async () => {
|
||||
let reads = 0
|
||||
const outcome = await classifyWithLanguages(new ApiError(500), 'de', async () => {
|
||||
reads += 1
|
||||
return []
|
||||
})
|
||||
|
||||
expect(outcome).toBe('failed')
|
||||
expect(reads).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export const ADD_TRANSLATION_DIALOG = 'add-translation'
|
||||
|
||||
export function addTranslationLocation(
|
||||
item: { id: number; missing_langs?: string[] },
|
||||
lang?: string,
|
||||
): RouteLocationRaw {
|
||||
const missing = item.missing_langs ?? []
|
||||
const named = lang ?? (missing.length === 1 ? missing[0] : undefined)
|
||||
return {
|
||||
path: `/article/${item.id}/edit`,
|
||||
query: { dialog: ADD_TRANSLATION_DIALOG, ...(named ? { lang: named } : {}) },
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ export const KNOWN_ERROR_CODES = [
|
||||
'wrong_password',
|
||||
'last_admin',
|
||||
'account_not_deletable',
|
||||
'revision_conflict',
|
||||
'language_exists',
|
||||
'language_unavailable',
|
||||
'language_not_enabled',
|
||||
'source_unavailable',
|
||||
'site_not_found',
|
||||
'setup_required',
|
||||
'slug_conflict',
|
||||
] as const
|
||||
|
||||
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
|
||||
@@ -32,6 +40,10 @@ export interface ApiErrorDetails {
|
||||
permission?: string
|
||||
escalationKey?: string
|
||||
lastRole?: string
|
||||
existingArticleId?: number
|
||||
currentRevision?: number
|
||||
missingLanguages?: string[]
|
||||
conflictingArticles?: number[]
|
||||
}
|
||||
|
||||
const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
|
||||
@@ -55,6 +67,10 @@ export class ApiError extends Error {
|
||||
permission?: string
|
||||
escalationKey?: string
|
||||
lastRole?: string
|
||||
existingArticleId?: number
|
||||
currentRevision?: number
|
||||
missingLanguages?: string[]
|
||||
conflictingArticles?: number[]
|
||||
|
||||
constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) {
|
||||
super(`Request failed with status ${status}`)
|
||||
@@ -65,5 +81,9 @@ export class ApiError extends Error {
|
||||
this.permission = details.permission
|
||||
this.escalationKey = details.escalationKey
|
||||
this.lastRole = details.lastRole
|
||||
this.existingArticleId = details.existingArticleId
|
||||
this.currentRevision = details.currentRevision
|
||||
this.missingLanguages = details.missingLanguages
|
||||
this.conflictingArticles = details.conflictingArticles
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,11 @@ export interface Article extends Partial<ArticleSeo> {
|
||||
id: number
|
||||
title: string
|
||||
body: string
|
||||
images?: { id: number }[]
|
||||
is_online: boolean
|
||||
lang?: string
|
||||
story_id?: number
|
||||
revision?: number
|
||||
capabilities: ArticleCapability[]
|
||||
translations?: CoverageEntry[]
|
||||
site_id?: number
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const HTML_MIME = 'text/html'
|
||||
const BLOCK_SELECTOR = 'p, h1, h2, h3, h4, h5, h6, li, blockquote, pre'
|
||||
const BLANK_LINE = '\n\n'
|
||||
|
||||
export function bodyPlainText(html: string) {
|
||||
const document = new DOMParser().parseFromString(html, HTML_MIME)
|
||||
const blocks = Array.from(document.body.querySelectorAll(BLOCK_SELECTOR))
|
||||
.filter((block) => !block.querySelector(BLOCK_SELECTOR))
|
||||
.map((block) => (block.textContent ?? '').trim())
|
||||
.filter((text) => text !== '')
|
||||
return blocks.length > 0 ? blocks.join(BLANK_LINE) : (document.body.textContent ?? '').trim()
|
||||
}
|
||||
|
||||
export function conflictDownloadText(title: string, html: string) {
|
||||
return `${title}${BLANK_LINE}${bodyPlainText(html)}\n`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function clockOf(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from '@/i18n'
|
||||
import { clockOf } from '@/lib/clockTime'
|
||||
import type { Notice } from '@/lib/editorActions'
|
||||
|
||||
export interface EditorNotice {
|
||||
@@ -20,12 +21,6 @@ export interface NoticeInput {
|
||||
onlineNotice: boolean
|
||||
}
|
||||
|
||||
function clockOf(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, '0')
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0')
|
||||
return `${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function restoredNotice(input: NoticeInput, restoredAt: Date): EditorNotice {
|
||||
return {
|
||||
key: 'restored',
|
||||
|
||||
@@ -57,8 +57,8 @@ function blocksWholeArticle(context: OverflowContext) {
|
||||
return context.siblings.some((sibling) => !holds(sibling.capabilities, 'delete'))
|
||||
}
|
||||
|
||||
function blockedExtra(blocked: boolean): Partial<OverflowItem> {
|
||||
return blocked ? { disabled: true, reason: t("You can't delete this article.") } : {}
|
||||
function blockedExtra(blocked: boolean, reason: string): Partial<OverflowItem> {
|
||||
return blocked ? { disabled: true, reason } : {}
|
||||
}
|
||||
|
||||
function editItems(context: OverflowContext): OverflowItem[] {
|
||||
@@ -70,12 +70,19 @@ function editItems(context: OverflowContext): OverflowItem[] {
|
||||
t('Move article'),
|
||||
'i-lucide-arrow-right-left',
|
||||
context,
|
||||
blockedExtra(blocked),
|
||||
blockedExtra(blocked, t("You can't delete this article.")),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function deleteItems(context: OverflowContext): OverflowItem[] {
|
||||
if (context.language && context.siblings.length === 0) {
|
||||
return [
|
||||
item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, {
|
||||
danger: true,
|
||||
}),
|
||||
]
|
||||
}
|
||||
const versionItem = item(
|
||||
'deleteVersion',
|
||||
deleteVersionLabel(context.language),
|
||||
@@ -91,7 +98,7 @@ function deleteItems(context: OverflowContext): OverflowItem[] {
|
||||
versionItem,
|
||||
item('deleteArticle', t('Delete article'), 'i-lucide-trash-2', context, {
|
||||
danger: !blocked,
|
||||
...blockedExtra(blocked),
|
||||
...blockedExtra(blocked, t("You can't delete this article.")),
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface LanguageAfterSiteChange {
|
||||
lang: string | undefined
|
||||
replaced: boolean
|
||||
}
|
||||
|
||||
export function languageAfterSiteChange(
|
||||
previous: string | undefined,
|
||||
offered: string[],
|
||||
defaultLang: string | null,
|
||||
): LanguageAfterSiteChange {
|
||||
if (previous !== undefined && offered.includes(previous)) {
|
||||
return { lang: previous, replaced: false }
|
||||
}
|
||||
return { lang: defaultLang ?? undefined, replaced: previous !== undefined }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { CoverageEntry } from '@/lib/articleTypes'
|
||||
import { holds } from '@/lib/editorCapabilities'
|
||||
import { nameInSentence } from '@/lib/storyList'
|
||||
|
||||
export interface MoveVersion {
|
||||
name: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export type MoveResult =
|
||||
| { kind: 'moved' }
|
||||
| { kind: 'languages'; missing: string[]; enabled: string[] | null }
|
||||
| { kind: 'slug'; conflicts: { language: string; slug: string }[] }
|
||||
| { kind: 'perm' }
|
||||
| { kind: 'failed' }
|
||||
| { kind: 'gone' }
|
||||
|
||||
export function moveVersions(entries: CoverageEntry[]): MoveVersion[] {
|
||||
return entries.map((entry) => ({
|
||||
name: nameInSentence(entry.lang),
|
||||
status: [
|
||||
entry.is_online ? t('Published') : t('Draft'),
|
||||
...(holds(entry.capabilities, 'edit') ? [] : [t('Read only')]),
|
||||
].join(' · '),
|
||||
}))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface StoredDraft {
|
||||
body?: string
|
||||
meta?: { site_id: number | null; authors: Byline[] }
|
||||
seo?: ArticleSeo
|
||||
revision?: number
|
||||
mirrored_at: string
|
||||
}
|
||||
|
||||
@@ -73,6 +74,7 @@ function isStoredDraft(value: unknown): value is StoredDraft {
|
||||
isOptionalString(value.body) &&
|
||||
isMeta(value.meta) &&
|
||||
isSeo(value.seo) &&
|
||||
(value.revision === undefined || typeof value.revision === 'number') &&
|
||||
isMirrorTime(value.mirrored_at)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ApiError } from '@/lib/apiError'
|
||||
import { codeOf, statusOf } from '@/lib/directoryErrors'
|
||||
import type { SiteLanguage } from '@/lib/siteLanguages'
|
||||
import { enabledLanguages } from '@/lib/translationSource'
|
||||
|
||||
export type TranslationFailure =
|
||||
| 'exists'
|
||||
| 'hidden'
|
||||
| 'langOff'
|
||||
| 'unreadable'
|
||||
| 'failed'
|
||||
| 'gone'
|
||||
| 'session'
|
||||
|
||||
const NOT_FOUND_ARTICLE = 'Article not found'
|
||||
|
||||
export async function classifyTranslationFailure(
|
||||
error: unknown,
|
||||
languageStillEnabled: () => Promise<boolean>,
|
||||
): Promise<TranslationFailure> {
|
||||
const status = statusOf(error)
|
||||
const code = codeOf(error)
|
||||
if (status === 401) {
|
||||
return 'session'
|
||||
}
|
||||
if (status === 409 && code === 'language_exists') {
|
||||
return 'exists'
|
||||
}
|
||||
if (status === 409 && code === 'language_unavailable') {
|
||||
return (await languageStillEnabled()) ? 'hidden' : 'langOff'
|
||||
}
|
||||
if (status === 422 && code === 'language_not_enabled') {
|
||||
return 'langOff'
|
||||
}
|
||||
if (status === 404 && code === 'source_unavailable') {
|
||||
return 'unreadable'
|
||||
}
|
||||
if (status === 404 && code === NOT_FOUND_ARTICLE) {
|
||||
return 'gone'
|
||||
}
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
export function classifyWithLanguages(
|
||||
error: unknown,
|
||||
lang: string | undefined,
|
||||
loadLanguages: () => Promise<SiteLanguage[]>,
|
||||
) {
|
||||
return classifyTranslationFailure(error, async () => {
|
||||
try {
|
||||
return enabledLanguages(await loadLanguages()).some((entry) => entry.lang === lang)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function existingArticleOf(error: unknown) {
|
||||
return error instanceof ApiError ? error.existingArticleId : undefined
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { locale, t } from '@/i18n'
|
||||
import type { Article, CoverageEntry } from '@/lib/articleTypes'
|
||||
import { joinNames } from '@/lib/nameList'
|
||||
import { languageName, type SiteLanguage } from '@/lib/siteLanguages'
|
||||
|
||||
const NAMED_AUTHORS = 5
|
||||
const MULTIPLE = 2
|
||||
|
||||
export function enabledLanguages(languages: SiteLanguage[]) {
|
||||
return languages.filter((language) => language.enabled)
|
||||
}
|
||||
|
||||
export function offeredLanguages(languages: SiteLanguage[], readable: CoverageEntry[]) {
|
||||
const present = new Set(readable.map((entry) => entry.lang))
|
||||
return enabledLanguages(languages)
|
||||
.map((language) => language.lang)
|
||||
.filter((lang) => !present.has(lang))
|
||||
}
|
||||
|
||||
export function defaultSource(
|
||||
languages: SiteLanguage[],
|
||||
readable: CoverageEntry[],
|
||||
): CoverageEntry | undefined {
|
||||
const preferred = languages.find((language) => language.is_default)?.lang
|
||||
return readable.find((entry) => entry.lang === preferred) ?? readable[0]
|
||||
}
|
||||
|
||||
export function sourceLabel(entry: CoverageEntry) {
|
||||
const name = languageName(entry.lang, locale.value)
|
||||
return entry.is_online ? t('{0} · published', [name]) : t('{0} · draft', [name])
|
||||
}
|
||||
|
||||
export function authorsNote(authors: { name: string }[]) {
|
||||
if (authors.length < MULTIPLE) {
|
||||
return null
|
||||
}
|
||||
const named = authors.slice(0, NAMED_AUTHORS).map((author) => author.name)
|
||||
const rest = authors.length - named.length
|
||||
return rest > 0
|
||||
? t('{0} and {1} more will be copied. You can change them in the editor.', [
|
||||
named.join(', '),
|
||||
rest,
|
||||
])
|
||||
: t('{0} will be copied. You can change them in the editor.', [joinNames(named)])
|
||||
}
|
||||
|
||||
export function imagesNote(source: Pick<Article, 'images' | 'cover_image_id'>, language: string) {
|
||||
const ids = new Set((source.images ?? []).map((image) => image.id))
|
||||
const cover = source.cover_image_id ?? null
|
||||
if (cover !== null) {
|
||||
ids.delete(cover)
|
||||
}
|
||||
const inline = ids.size
|
||||
if (cover === null && inline === 0) {
|
||||
return null
|
||||
}
|
||||
const reused =
|
||||
cover === null
|
||||
? inline === 1
|
||||
? t('1 inline image is reused.')
|
||||
: t('{0} inline images are reused.', [inline])
|
||||
: inline === 0
|
||||
? t('Cover image is reused.')
|
||||
: inline === 1
|
||||
? t('Cover image and 1 inline image are reused.')
|
||||
: t('Cover image and {0} inline images are reused.', [inline])
|
||||
return `${reused} ${t('Alternative text is copied and stays in {0} until you translate it.', [language])}`
|
||||
}
|
||||
Reference in new issue
Block a user