feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles

This commit is contained in:
Florian Sylvain committed 2026-11-03 00:12:29 +01:00
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)
})
})
+15
View File
@@ -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 } : {}) },
}
}
+20
View File
@@ -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
}
}
+2
View File
@@ -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
+16
View File
@@ -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`
}
+5
View File
@@ -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 -6
View File
@@ -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',
+11 -4
View File
@@ -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.")),
}),
]
}
+15
View File
@@ -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 }
}
+27
View File
@@ -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(' · '),
}))
}
+2
View File
@@ -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)
)
}
+60
View File
@@ -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
}
+68
View File
@@ -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])}`
}