ENH - refine Weazel News experience

This commit is contained in:
Dominik
2026-08-16 17:38:02 +02:00
parent b6fb9ce91e
commit a25508c89e
12 changed files with 1222 additions and 806 deletions
+11
View File
@@ -551,6 +551,15 @@ const defaultLocales: LocaleTree = {
category: 'Category',
status: 'Publication Status',
cover: 'Cover Image',
photos: 'Article Photos',
photoCount: '{count} of {maximum} photos',
addPhotos: 'Add Photos',
choosePhotos: 'Choose from Photos',
takePhoto: 'Take Photo',
photoSourceHint:
'Choose several photos from your library or take a new one.',
primaryPhoto: 'Cover',
makePrimary: 'Use as Cover',
chooseCover: 'Choose from Photos',
changeCover: 'Change Cover',
removeCover: 'Remove Cover',
@@ -584,8 +593,10 @@ const defaultLocales: LocaleTree = {
deleteArticle: 'Delete {title}',
search: 'Search Weazel News',
clearSearch: 'Clear article search',
articleImages: 'Article photos',
coverPreview: 'Article cover preview',
removeCover: 'Remove the selected cover image',
removePhoto: 'Remove this article photo',
status: 'Article status: {status}',
},
errors: {
+26 -1
View File
@@ -21,6 +21,16 @@ const article: WeazelNewsArticle = {
id: '7d28b252-0532-4869-9512-e313e34d2fb8',
imageMediaId: 12,
imageUrl: 'https://media.invalid/weazel/article.webp',
images: [
{
mediaId: 12,
url: 'https://media.invalid/weazel/article.webp',
},
{
mediaId: 13,
url: 'https://media.invalid/weazel/article-2.webp',
},
],
publishedAt: 1_754_000_100,
revision: 4,
status: 'published',
@@ -31,7 +41,7 @@ const article: WeazelNewsArticle = {
const draft: WeazelNewsArticleDraft = {
body: 'Updated body.',
category: 'business',
imageMediaId: null,
imageMediaIds: [33, 34],
status: 'draft',
title: 'Updated headline',
}
@@ -52,6 +62,7 @@ describe('Weazel News store', () => {
],
jobGradeLabel: 'Editor',
jobLabel: 'Weazel News',
maximumImages: 6,
},
success: true,
})
@@ -61,6 +72,7 @@ describe('Weazel News store', () => {
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:context')
expect(store.context?.canManage).toBe(true)
expect(store.context?.categories[0]).toEqual({ count: 3, id: 'news' })
expect(store.context?.maximumImages).toBe(6)
})
it('loads and deduplicates appended public pages', async () => {
@@ -103,6 +115,19 @@ describe('Weazel News store', () => {
expect(store.publicHasMore).toBe(false)
})
it('replaces stale search results with a successful empty response', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { hasMore: false, items: [] },
success: true,
})
const store = useWeazelNewsStore()
store.publicItems = [article]
expect(await store.loadPublic({ search: 'missing headline' })).toBe(true)
expect(store.publicItems).toEqual([])
expect(store.publicHasMore).toBe(false)
})
it('loads paged management results including drafts', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { hasMore: false, items: [{ ...article, status: 'draft' }] },
+8 -1
View File
@@ -20,6 +20,11 @@ export type WeazelNewsCategorySummary = {
id: WeazelNewsCategoryId
}
export type WeazelNewsArticleImage = {
mediaId: number
url: string
}
export type WeazelNewsArticle = {
authorName: string
body: string
@@ -29,6 +34,7 @@ export type WeazelNewsArticle = {
id: string
imageMediaId?: number | null
imageUrl?: string | null
images: WeazelNewsArticleImage[]
publishedAt?: number | null
revision: number
status: WeazelNewsArticleStatus
@@ -41,7 +47,7 @@ export type WeazelNewsArticleSummary = Omit<WeazelNewsArticle, 'body'>
export type WeazelNewsArticleDraft = {
body: string
category: WeazelNewsCategoryId
imageMediaId: number | null
imageMediaIds: number[]
status: WeazelNewsArticleStatus
title: string
}
@@ -51,6 +57,7 @@ export type WeazelNewsContext = {
categories: WeazelNewsCategorySummary[]
jobGradeLabel?: string
jobLabel?: string
maximumImages: number
}
export type WeazelNewsListResponse = {
@@ -0,0 +1,196 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./weazel-news-app.vue', import.meta.url),
'utf8',
)
function sectionBetween(start: RegExp, end: RegExp): string {
const startMatch = start.exec(source)
if (!startMatch || startMatch.index === undefined) return ''
const tail = source.slice(startMatch.index + startMatch[0].length)
const endMatch = end.exec(tail)
return endMatch?.index === undefined
? source.slice(startMatch.index)
: source.slice(
startMatch.index,
startMatch.index + startMatch[0].length + endMatch.index,
)
}
function openingTags(
pascalName: string,
kebabName: string,
value = source,
): string[] {
const pattern = new RegExp(`<(?:${pascalName}|${kebabName})\\b[^>]*>`, 'gis')
return value.match(pattern) ?? []
}
function styleRule(selector: string): string {
const start = source.indexOf(selector)
if (start < 0) return ''
const end = source.indexOf('}', start)
return end < 0 ? source.slice(start) : source.slice(start, end + 1)
}
const detailSource = sectionBetween(
/<template\s+v-else-if=["']screen\s*===\s*['"]detail['"][^>]*>/i,
/<template\s+v-else-if=["']screen\s*===\s*['"]composer['"][^>]*>/i,
)
const composerSource = sectionBetween(
/<template\s+v-else-if=["']screen\s*===\s*['"]composer['"][^>]*>/i,
/<(?:SkyActionSheet|sky-action-sheet|SkySheet|sky-sheet)\b/i,
)
const searchSource = sectionBetween(
/<template\s+v-else-if=["']activeTab\s*===\s*['"]search['"][^>]*>/i,
/class=["']weazel-editorial-heading["']/i,
)
describe('Weazel News central navigation contract', () => {
it('uses the full Sky pill navigation and a tabbar-aware scroll owner', () => {
const navigationSource =
source.match(
/<(?:SkyPillNavigation|sky-pill-navigation)\b[\s\S]*?<\/(?:SkyPillNavigation|sky-pill-navigation)>/i,
)?.[0] ?? ''
const pillTags = openingTags(
'SkyPillNavigation',
'sky-pill-navigation',
navigationSource,
)
const segmentedTags = openingTags(
'SkySegmented',
'sky-segmented',
navigationSource,
)
const segmentedButtonTags = openingTags(
'SkySegmentedButton',
'sky-segmented-button',
navigationSource,
)
const scrollTags = openingTags('SkyScrollArea', 'sky-scroll-area')
expect(pillTags).toHaveLength(1)
expect(pillTags[0]).toMatch(/\blayout\s*=\s*["']full["']/i)
expect(segmentedTags.length).toBeGreaterThanOrEqual(1)
expect(segmentedButtonTags).toHaveLength(4)
expect(
scrollTags.some((tag) => /\bwith-(?:tabbar|tab-bar)\b/i.test(tag)),
).toBe(true)
expect(source).not.toMatch(/<(?:SkyTabBar|sky-tab-bar)\b/i)
expect(source).not.toMatch(/\bSkyTabBar\b/)
expect(source).not.toMatch(/\.weazel-tabbar(?:\b|__)/)
})
})
describe('Weazel News article detail contract', () => {
it('uses the centered back action owned by SkyNavbar', () => {
const navbarTag = detailSource.match(
/<(?:SkyNavbar|sky-navbar)\b[^>]*>/is,
)?.[0]
expect(detailSource).not.toBe('')
expect(navbarTag).toBeDefined()
expect(navbarTag).toMatch(/\bshow-(?:back|back-button)\b/i)
expect(navbarTag).toMatch(/\bback-appearance\s*=\s*["']surface["']/i)
expect(navbarTag).toMatch(/@back\s*=\s*["']closeDetail["']/)
expect(detailSource).not.toMatch(
/<(?:SkyNavbarBackLink|sky-navbar-back-link)\b/i,
)
})
it('keeps the lead image full bleed while aligning article copy to the page gutter', () => {
const detailScrollTag = detailSource.match(
/<(?:SkyScrollArea|sky-scroll-area)\b(?=[^>]*\bweazel-detail-scroll\b)[^>]*>/is,
)?.[0]
const coverRule = styleRule('.weazel-detail-cover')
const copyRule = styleRule('.weazel-detail-copy')
expect(detailScrollTag).toBeDefined()
expect(detailScrollTag).toMatch(/\bas\s*=\s*["']article["']/i)
expect(detailScrollTag).not.toMatch(/\bpadded\b/i)
expect(coverRule).toMatch(/\bwidth\s*:\s*100%\s*;/i)
expect(coverRule).not.toMatch(/calc\s*\(\s*100%/i)
expect(coverRule).not.toMatch(/margin[^;]*-\d/i)
expect(copyRule).toContain('var(--sky-page-gutter)')
})
it('offers one canonical edit action to article writers', () => {
const editActions =
detailSource.match(
/@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/g,
) ?? []
const navbarEnd = detailSource.search(/<\/(?:SkyNavbar|sky-navbar)>/i)
const navbarSource = navbarEnd < 0 ? '' : detailSource.slice(0, navbarEnd)
expect(editActions).toHaveLength(1)
expect(navbarSource).toMatch(
/@click\s*=\s*["']\s*editArticle\s*\(\s*selectedArticle\s*\)\s*["']/,
)
})
})
describe('Weazel News empty search contract', () => {
it('uses the shared compact empty state when no article matches', () => {
const emptyStateTag = searchSource.match(
/<(?:SkyEmptyState|sky-empty-state)\b[^>]*>/is,
)?.[0]
expect(searchSource).not.toBe('')
expect(emptyStateTag).toBeDefined()
expect(emptyStateTag).toMatch(/\bcompact\b/i)
expect(emptyStateTag).toMatch(/!\s*news\.publicItems\.length/)
expect(emptyStateTag).toContain("t('search.emptyTitle')")
expect(emptyStateTag).toContain("t('search.emptyBody')")
})
})
describe('Weazel News article composer contract', () => {
it('uses the central SkyField select for the article category', () => {
const categorySelect = openingTags('SkyField', 'sky-field').find(
(tag) =>
/\btype\s*=\s*["']select["']/i.test(tag) && /draft\.category/.test(tag),
)
expect(categorySelect).toBeDefined()
expect(categorySelect).toMatch(/:options\s*=/i)
expect(categorySelect).toMatch(/\bdropdown\b/i)
expect(categorySelect).toContain("t('composer.category')")
expect(composerSource).not.toContain("composerChoice = 'category'")
})
it('offers Photos and Camera through the shared action sheet', () => {
const actionSheet = source.match(
/<(?:SkyActionSheet|sky-action-sheet)\b[\s\S]*?<\/(?:SkyActionSheet|sky-action-sheet)>/i,
)?.[0]
expect(actionSheet).toBeDefined()
expect(actionSheet).toMatch(
/<(?:SkyActionButton|sky-action-button)\b[\s\S]*?(?:photos|gallery)/i,
)
expect(actionSheet).toMatch(
/<(?:SkyActionButton|sky-action-button)\b[\s\S]*?camera/i,
)
expect(actionSheet).toMatch(/@backdropclick\s*=/i)
expect(actionSheet).toMatch(/@escape\s*=/i)
})
it('round-trips and previews multiple article images', () => {
expect(source).toContain('imageMediaIds')
expect(source).toContain('maximumImages')
expect(source).toMatch(/messageMedia\.begin\s*\(/)
expect(source).toMatch(/messageMedia\.consumeMany(?:<[^>]+>)?\s*\(/)
expect(source).toMatch(/selection\?*\.media/)
expect(source).not.toMatch(/selection\?*\.media\s*\[\s*0\s*\]/)
expect(composerSource).toMatch(
/v-for\s*=\s*["'][^"']+\s+in\s+[^"']*(?:image|media)[^"']*["']/i,
)
expect(source).toMatch(
/['"]camera['"]\s*\|\s*['"]photos['"]|['"]photos['"]\s*\|\s*['"]camera['"]/,
)
})
})
File diff suppressed because it is too large Load Diff
+56 -25
View File
@@ -2800,6 +2800,7 @@ mockMedia.push(
})),
)
const weazelNewsCategoryIds = ['official', 'events', 'jobs', 'news', 'business']
const weazelNewsMaxImages = 6
let weazelNewsSequence = 8
let weazelNewsArticles = [
{
@@ -2809,8 +2810,7 @@ let weazelNewsArticles = [
excerpt:
'Temporary navigation restrictions are in effect around the southern harbor while crews inspect the main shipping channel.',
category: 'official',
imageUrl: 'https://picsum.photos/seed/weazel-harbor/1200/760',
imageMediaId: null,
images: weazelNewsImages([15, 13, 20]),
authorName: 'Avery Brooks',
createdAt: Date.now() - 35 * 60 * 1000,
updatedAt: Date.now() - 28 * 60 * 1000,
@@ -2825,8 +2825,7 @@ let weazelNewsArticles = [
excerpt:
'Food stands, live performers, and classic cars are coming to Vinewood Boulevard this weekend.',
category: 'events',
imageUrl: 'https://picsum.photos/seed/weazel-vinewood/1200/760',
imageMediaId: null,
images: weazelNewsImages([11]),
authorName: 'Maya Chen',
createdAt: Date.now() - 2 * 60 * 60 * 1000,
updatedAt: Date.now() - 2 * 60 * 60 * 1000,
@@ -2841,8 +2840,7 @@ let weazelNewsArticles = [
excerpt:
'City departments are recruiting new staff across emergency response, transport, and public administration.',
category: 'jobs',
imageUrl: null,
imageMediaId: null,
images: [],
authorName: 'Jordan Hayes',
createdAt: Date.now() - 4 * 60 * 60 * 1000,
updatedAt: Date.now() - 3 * 60 * 60 * 1000,
@@ -2857,8 +2855,7 @@ let weazelNewsArticles = [
excerpt:
'Every lane through Del Perro has reopened after crews cleared an earlier road obstruction.',
category: 'news',
imageUrl: 'https://picsum.photos/seed/weazel-del-perro/1200/760',
imageMediaId: null,
images: weazelNewsImages([9]),
authorName: 'Avery Brooks',
createdAt: Date.now() - 7 * 60 * 60 * 1000,
updatedAt: Date.now() - 6 * 60 * 60 * 1000,
@@ -2873,8 +2870,7 @@ let weazelNewsArticles = [
excerpt:
'Independent downtown retailers are seeing stronger evening trade during a trial of extended opening hours.',
category: 'business',
imageUrl: 'https://picsum.photos/seed/weazel-downtown/1200/760',
imageMediaId: null,
images: weazelNewsImages([5]),
authorName: 'Maya Chen',
createdAt: Date.now() - 26 * 60 * 60 * 1000,
updatedAt: Date.now() - 25 * 60 * 60 * 1000,
@@ -2889,8 +2885,7 @@ let weazelNewsArticles = [
excerpt:
'Local racing teams are preparing vehicles and reviewing safety procedures for the next sanctioned season.',
category: 'events',
imageUrl: 'https://picsum.photos/seed/sky-phone-3/800/600',
imageMediaId: 3,
images: weazelNewsImages([3, 7]),
authorName: 'Jordan Hayes',
createdAt: Date.now() - 55 * 60 * 1000,
updatedAt: Date.now() - 12 * 60 * 1000,
@@ -2905,8 +2900,7 @@ let weazelNewsArticles = [
excerpt:
'The editorial desk is collecting confirmed service notices and transport updates for Monday morning.',
category: 'official',
imageUrl: null,
imageMediaId: null,
images: [],
authorName: 'Jordan Hayes',
createdAt: Date.now() - 18 * 60 * 1000,
updatedAt: Date.now() - 8 * 60 * 1000,
@@ -2914,7 +2908,7 @@ let weazelNewsArticles = [
status: 'draft',
revision: 2,
},
]
].map(syncWeazelNewsArticleImages)
function weazelNewsExcerpt(body) {
const normalized = body.replace(/\s+/g, ' ').trim()
@@ -2931,6 +2925,24 @@ function weazelNewsImageUrl(imageMediaId) {
return media?.url ?? null
}
function weazelNewsImages(imageMediaIds) {
return imageMediaIds.map((mediaId) => ({
mediaId,
url: weazelNewsImageUrl(mediaId),
}))
}
function syncWeazelNewsArticleImages(article) {
const images = Array.isArray(article.images) ? article.images : []
const firstImage = images[0] ?? null
return {
...article,
imageMediaId: firstImage?.mediaId ?? null,
imageUrl: firstImage?.url ?? null,
images,
}
}
function validateWeazelNewsDraft(data) {
const title = typeof data.title === 'string' ? data.title.trim() : ''
const body = typeof data.body === 'string' ? data.body.trim() : ''
@@ -2948,24 +2960,45 @@ function validateWeazelNewsDraft(data) {
return { error: status === 'draft' ? 'invalid_draft' : 'invalid_publish' }
}
let imageMediaId = null
if (data.imageMediaId !== null && data.imageMediaId !== undefined) {
imageMediaId = Number(data.imageMediaId)
const requestedImageMediaIds =
data.imageMediaIds === undefined
? data.imageMediaId === null || data.imageMediaId === undefined
? []
: [data.imageMediaId]
: data.imageMediaIds
if (
!Array.isArray(requestedImageMediaIds) ||
requestedImageMediaIds.length > weazelNewsMaxImages
) {
return { error: 'invalid_attachment' }
}
const imageMediaIds = []
const seenImageMediaIds = new Set()
for (const requestedImageMediaId of requestedImageMediaIds) {
const imageMediaId = Number(requestedImageMediaId)
if (
!Number.isSafeInteger(imageMediaId) ||
seenImageMediaIds.has(imageMediaId) ||
!weazelNewsImageUrl(imageMediaId)
) {
return { error: 'invalid_attachment' }
}
seenImageMediaIds.add(imageMediaId)
imageMediaIds.push(imageMediaId)
}
const images = weazelNewsImages(imageMediaIds)
const firstImage = images[0] ?? null
return {
article: {
body,
category: data.category,
excerpt: weazelNewsExcerpt(body),
imageMediaId,
imageUrl: weazelNewsImageUrl(imageMediaId),
imageMediaId: firstImage?.mediaId ?? null,
imageUrl: firstImage?.url ?? null,
images,
status,
title,
},
@@ -4822,6 +4855,7 @@ app.post('/api/:endpoint', (request, response) => {
...(canManageWeazelNews
? { jobGradeLabel: 'Senior Reporter', jobLabel: 'Weazel News' }
: {}),
maximumImages: weazelNewsMaxImages,
},
})
return
@@ -7730,9 +7764,7 @@ app.post('/api/:endpoint', (request, response) => {
const property = mockHousingOverview.properties.find(
(item) => item.id === request.body.propertyId,
)
const existingNames = new Set(
(property?.keys ?? []).map((key) => key.name),
)
const existingNames = new Set((property?.keys ?? []).map((key) => key.name))
response.json({
success: true,
data: {
@@ -7774,8 +7806,7 @@ app.post('/api/:endpoint', (request, response) => {
if (request.body.action === 'revoke_key') {
property.keys = (property.keys ?? []).filter(
(key) =>
key.identifier !== request.body.identifier ||
key.revocable === false,
key.identifier !== request.body.identifier || key.revocable === false,
)
}
response.json({ success: true, data: { accepted: true } })
+85 -1
View File
@@ -65,6 +65,8 @@ const browserDataRequests = [
['skyride:history', {}],
['skyride:get-player-coords', {}],
['weather:get', {}],
['weazel-news:context', {}],
['weazel-news:list', { category: null, offset: 0, search: '' }],
]
async function post(baseUrl, endpoint, body = {}) {
@@ -104,7 +106,89 @@ async function verifyStatefulActions(baseUrl) {
true,
)
gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true)
assert.equal(gallery.find((item) => item.id === gallery[0].id)?.favorite, true)
assert.equal(
gallery.find((item) => item.id === gallery[0].id)?.favorite,
true,
)
const articlePhotos = gallery
.filter((item) => item.mediaType === 'photo')
.slice(0, 7)
assert.equal(
articlePhotos.length,
7,
'gallery:list did not include enough photos for Weazel News',
)
const weazelContext = await expectSuccess(
baseUrl,
'weazel-news:context',
{},
true,
)
assert.equal(weazelContext.maximumImages, 6)
const createdArticleResponse = await expectSuccess(
baseUrl,
'weazel-news:create',
{
body: 'Created by the browser mock smoke test with several photos.',
category: 'news',
imageMediaIds: articlePhotos.slice(0, 3).map((item) => item.id),
status: 'published',
title: 'Browser test Weazel article',
},
true,
)
const createdArticle = createdArticleResponse.article
assert.deepEqual(
createdArticle.images.map((image) => image.mediaId),
articlePhotos.slice(0, 3).map((item) => item.id),
'weazel-news:create did not preserve image order',
)
assert.equal(createdArticle.imageMediaId, articlePhotos[0].id)
const updatedArticleResponse = await expectSuccess(
baseUrl,
'weazel-news:update',
{
body: createdArticle.body,
category: 'business',
id: createdArticle.id,
imageMediaIds: [articlePhotos[2].id, articlePhotos[0].id],
revision: createdArticle.revision,
status: 'draft',
title: 'Updated browser test Weazel article',
},
true,
)
const updatedArticle = updatedArticleResponse.article
assert.deepEqual(
updatedArticle.images.map((image) => image.mediaId),
[articlePhotos[2].id, articlePhotos[0].id],
'weazel-news:update did not preserve the reordered images',
)
assert.equal(updatedArticle.imageMediaId, articlePhotos[2].id)
const loadedArticleResponse = await expectSuccess(
baseUrl,
'weazel-news:get',
{ id: updatedArticle.id, manage: true },
true,
)
assert.deepEqual(loadedArticleResponse.article.images, updatedArticle.images)
const tooManyImages = await post(baseUrl, 'weazel-news:create', {
body: 'This article must be rejected because it has too many photos.',
category: 'news',
imageMediaIds: articlePhotos.map((item) => item.id),
status: 'draft',
title: 'Invalid Weazel article',
})
assert.equal(tooManyImages.success, false)
assert.equal(tooManyImages.error, 'invalid_attachment')
await expectSuccess(baseUrl, 'weazel-news:delete', {
id: updatedArticle.id,
revision: updatedArticle.revision,
})
const memoBootstrap = await expectSuccess(
baseUrl,
+10
View File
@@ -302,6 +302,14 @@ Locales["en"] = {
category = "Category",
status = "Publication Status",
cover = "Cover Image",
photos = "Article Photos",
photoCount = "{count} of {maximum} photos",
addPhotos = "Add Photos",
choosePhotos = "Choose from Photos",
takePhoto = "Take Photo",
photoSourceHint = "Choose several photos from your library or take a new one.",
primaryPhoto = "Cover",
makePrimary = "Use as Cover",
chooseCover = "Choose from Photos",
changeCover = "Change Cover",
removeCover = "Remove Cover",
@@ -335,8 +343,10 @@ Locales["en"] = {
deleteArticle = "Delete {title}",
search = "Search Weazel News",
clearSearch = "Clear article search",
articleImages = "Article photos",
coverPreview = "Article cover preview",
removeCover = "Remove the selected cover image",
removePhoto = "Remove this article photo",
status = "Article status: {status}",
},
errors = {
+1
View File
@@ -2,6 +2,7 @@ Config.WeazelNews = {
Enabled = true,
PageSize = 20,
MaximumOffset = 10000,
MaximumImages = 6,
SearchMaxLength = 80,
DraftTitleMinLength = 1,
DraftBodyMinLength = 1,
+25
View File
@@ -2663,9 +2663,34 @@ local schema = {
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
{
name = "sky_phone_weazel_article_media",
columns = {
{ name = "id", type = "BIGINT UNSIGNED NOT NULL AUTO_INCREMENT" },
{ name = "article_id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
{ name = "media_id", type = "BIGINT UNSIGNED NOT NULL" },
{ name = "position", type = "TINYINT UNSIGNED NOT NULL" },
},
primaryKey = "id",
uniqueKeys = {
{ name = "uniq_sky_phone_weazel_article_position", columns = "(`article_id`, `position`)" },
{ name = "uniq_sky_phone_weazel_article_media", columns = "(`article_id`, `media_id`)" },
},
foreignKeys = {
{ column = "article_id", references = "`sky_phone_weazel_articles` (`id`) ON DELETE CASCADE" },
{ column = "media_id", references = "`sky_phone_media` (`id`) ON DELETE CASCADE" },
},
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
},
}
Bridge.Database.Migrate("sky_phone", schema)
Bridge.Database.Query([[
INSERT IGNORE INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`)
SELECT `id`, `image_media_id`, 1
FROM `sky_phone_weazel_articles`
WHERE `image_media_id` IS NOT NULL
]], {})
Bridge.Database.Query("DELETE FROM `sky_phone_feather_notifications` WHERE `kind` = 'repost'", {})
Bridge.Database.Query("DELETE FROM `sky_phone_feather_reactions` WHERE `kind` = 'repost'", {})
Bridge.Database.Query([[
+232 -61
View File
@@ -17,6 +17,7 @@ if type(config.Enabled) ~= "boolean" then
end
require_integer_config("PageSize", 1, 100)
require_integer_config("MaximumOffset", 0, 1000000)
require_integer_config("MaximumImages", 1, 6)
require_integer_config("SearchMaxLength", 1, 256)
require_integer_config("DraftTitleMinLength", 1, 160)
require_integer_config("DraftBodyMinLength", 1, 12000)
@@ -219,6 +220,7 @@ local function article_dto(row)
category = row.category,
imageUrl = row.image_url,
imageMediaId = row.image_media_id and tonumber(row.image_media_id) or nil,
images = {},
authorName = row.author_name,
createdAt = created_at * 1000,
updatedAt = updated_at * 1000,
@@ -228,6 +230,54 @@ local function article_dto(row)
}
end
local function attach_article_images(articles)
if #articles < 1 then
return articles
end
local placeholders = {}
local article_ids = {}
local articles_by_id = {}
for index, article in ipairs(articles) do
placeholders[index] = "?"
article_ids[index] = article.id
article.images = {}
articles_by_id[article.id] = article
end
local rows = Bridge.Database.Query(([[
SELECT relation.`article_id`, relation.`media_id`, relation.`position`, media.`url`
FROM `sky_phone_weazel_article_media` relation
JOIN `sky_phone_media` media
ON media.`id` = relation.`media_id` AND media.`media_type` = 'photo'
WHERE relation.`article_id` IN (%s)
ORDER BY relation.`article_id`, relation.`position`, relation.`id`
]]):format(table.concat(placeholders, ", ")), article_ids)
for _, row in ipairs(rows) do
local article = articles_by_id[row.article_id]
local media_id = tonumber(row.media_id)
if article and media_id and type(row.url) == "string" then
article.images[#article.images + 1] = {
mediaId = media_id,
url = row.url,
}
end
end
for _, article in ipairs(articles) do
if #article.images < 1 and article.imageMediaId and article.imageUrl then
article.images[1] = {
mediaId = article.imageMediaId,
url = article.imageUrl,
}
end
local cover = article.images[1]
article.imageMediaId = cover and cover.mediaId or nil
article.imageUrl = cover and cover.url or nil
end
return articles
end
local function load_article(id, include_drafts)
local visibility = include_drafts and "" or " AND article.`status` = 'published'"
local rows = Bridge.Database.Query(([[
@@ -237,7 +287,8 @@ local function load_article(id, include_drafts)
WHERE article.`id` = ? AND article.`deleted_at` IS NULL%s
LIMIT 1
]]):format(article_detail_columns, visibility), { id })
return article_dto(rows[1])
local article = article_dto(rows[1])
return article and attach_article_images({ article })[1] or nil
end
local function query_articles(where_clause, parameters, order_clause, offset)
@@ -264,10 +315,42 @@ local function query_articles(where_clause, parameters, order_clause, offset)
for _, row in ipairs(rows) do
articles[#articles + 1] = article_dto(row)
end
return articles, has_more
return attach_article_images(articles), has_more
end
local function validate_article(source, data, retained_media_id)
local function validate_article_media(source, data, retained_media_ids)
local values = data.imageMediaIds
if values == nil then
values = data.imageMediaId ~= nil and { data.imageMediaId } or {}
end
if type(values) ~= "table" or #values > config.MaximumImages then
return nil
end
for key in pairs(values) do
if type(key) ~= "number" or key < 1 or key > #values or key ~= math.floor(key) then
return nil
end
end
local media_ids = {}
local seen = {}
for index, value in ipairs(values) do
local media_id = valid_integer(value, 1, 9007199254740991)
if not media_id or seen[media_id] then
return nil
end
if not retained_media_ids[media_id]
and not SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo")
then
return nil
end
seen[media_id] = true
media_ids[index] = media_id
end
return media_ids
end
local function validate_article(source, data, retained_media_ids)
if type(data) ~= "table" then
return nil, "invalid_article"
end
@@ -287,29 +370,40 @@ local function validate_article(source, data, retained_media_id)
return nil, status == "draft" and "invalid_draft" or "invalid_publish"
end
local media_id
if data.imageMediaId ~= nil then
media_id = valid_integer(data.imageMediaId, 1, 9007199254740991)
if not media_id then
return nil, "invalid_attachment"
end
if media_id ~= retained_media_id then
local url = SkyPhoneMedia.ResolveOwnedMedia(source, tostring(media_id), "photo")
if not url then
return nil, "invalid_attachment"
end
end
local media_ids = validate_article_media(source, data, retained_media_ids or {})
if not media_ids then
return nil, "invalid_attachment"
end
return {
title = title,
body = body,
excerpt = make_excerpt(body),
category = data.category,
image_media_id = media_id,
image_media_id = media_ids[1],
image_media_ids = media_ids,
status = status,
}
end
local function article_matches(article, expected, revision)
if not article or article.revision ~= revision
or article.title ~= expected.title
or article.body ~= expected.body
or article.excerpt ~= expected.excerpt
or article.category ~= expected.category
or article.status ~= expected.status
or #article.images ~= #expected.image_media_ids
then
return false
end
for index, media_id in ipairs(expected.image_media_ids) do
if article.images[index].mediaId ~= media_id then
return false
end
end
return true
end
Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source)
local _, error_response = require_phone(source, "read", config.RateLimits.Read)
if error_response then
@@ -340,6 +434,7 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:context", function(source)
jobLabel = access and access.job_label or nil,
jobGradeLabel = access and access.grade_label or nil,
categories = category_context,
maximumImages = config.MaximumImages,
},
}
end)
@@ -466,24 +561,39 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:create", function(source, data)
if not valid_uuid(id) then
error("[sky_phone] Database did not generate a Weazel News article id.")
end
Bridge.Database.Query([[
INSERT INTO `sky_phone_weazel_articles`
(`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`,
`author_name`, `updated_by_identifier`, `status`, `published_at`)
VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL))
]], {
id,
article.title,
article.body,
article.excerpt,
article.category,
article.image_media_id or 0,
actor.identifier,
actor.name,
actor.identifier,
article.status,
article.status,
})
local statements = {{
query = [[
INSERT INTO `sky_phone_weazel_articles`
(`id`, `title`, `body`, `excerpt`, `category`, `image_media_id`, `author_identifier`,
`author_name`, `updated_by_identifier`, `status`, `published_at`)
VALUES (?, ?, ?, ?, ?, NULLIF(?, 0), ?, ?, ?, ?, IF(? = 'published', CURRENT_TIMESTAMP, NULL))
]],
params = {
id,
article.title,
article.body,
article.excerpt,
article.category,
article.image_media_id or 0,
actor.identifier,
actor.name,
actor.identifier,
article.status,
article.status,
},
}}
for position, media_id in ipairs(article.image_media_ids) do
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`)
VALUES (?, ?, ?)
]],
params = { id, media_id, position },
}
end
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local created = load_article(id, true)
if not created then
error(("[sky_phone] Could not reload created Weazel News article '%s'."):format(id))
@@ -517,8 +627,23 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data)
if tonumber(current.revision) ~= revision then
return { success = false, error = "revision_conflict" }
end
local retained_media_id = current.image_media_id and tonumber(current.image_media_id) or nil
local article, validation_error = validate_article(source, data, retained_media_id)
local retained_media_ids = {}
local retained_cover_id = current.image_media_id and tonumber(current.image_media_id) or nil
if retained_cover_id then
retained_media_ids[retained_cover_id] = true
end
local retained_rows = Bridge.Database.Query([[
SELECT `media_id`
FROM `sky_phone_weazel_article_media`
WHERE `article_id` = ?
]], { data.id })
for _, row in ipairs(retained_rows) do
local media_id = tonumber(row.media_id)
if media_id then
retained_media_ids[media_id] = true
end
end
local article, validation_error = validate_article(source, data, retained_media_ids)
if not article then
return { success = false, error = validation_error }
end
@@ -526,34 +651,80 @@ Bridge.Callbacks.Register("sky_phone:weazel-news:update", function(source, data)
if not actor then
return { success = false, error = "request_failed" }
end
local result = Bridge.Database.Query([[
UPDATE `sky_phone_weazel_articles`
SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?, `image_media_id` = NULLIF(?, 0),
`published_at` = CASE
WHEN ? = 'draft' THEN NULL
WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP
ELSE `published_at`
END,
`status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1
WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL
]], {
article.title,
article.body,
article.excerpt,
article.category,
article.image_media_id or 0,
article.status,
article.status,
actor.identifier,
data.id,
revision,
})
if affected_rows(result) ~= 1 then
return { success = false, error = "revision_conflict" }
local mutation_rows = Bridge.Database.Query("SELECT UUID() AS `id`", {})
local mutation_id = mutation_rows[1] and mutation_rows[1].id
if not valid_uuid(mutation_id) then
error("[sky_phone] Database did not generate a Weazel News mutation id.")
end
local mutation_token = "weazel:" .. mutation_id
local next_revision = revision + 1
local statements = {
{
query = [[
UPDATE `sky_phone_weazel_articles`
SET `title` = ?, `body` = ?, `excerpt` = ?, `category` = ?,
`image_media_id` = NULLIF(?, 0),
`published_at` = CASE
WHEN ? = 'draft' THEN NULL
WHEN `status` = 'draft' THEN CURRENT_TIMESTAMP
ELSE `published_at`
END,
`status` = ?, `updated_by_identifier` = ?, `revision` = `revision` + 1
WHERE `id` = ? AND `revision` = ? AND `deleted_at` IS NULL
]],
params = {
article.title,
article.body,
article.excerpt,
article.category,
article.image_media_id or 0,
article.status,
article.status,
mutation_token,
data.id,
revision,
},
},
{
query = [[
DELETE relation
FROM `sky_phone_weazel_article_media` relation
JOIN `sky_phone_weazel_articles` article ON article.`id` = relation.`article_id`
WHERE article.`id` = ? AND article.`revision` = ?
AND article.`updated_by_identifier` = ?
]],
params = { data.id, next_revision, mutation_token },
},
}
for position, media_id in ipairs(article.image_media_ids) do
statements[#statements + 1] = {
query = [[
INSERT INTO `sky_phone_weazel_article_media` (`article_id`, `media_id`, `position`)
SELECT article.`id`, ?, ?
FROM `sky_phone_weazel_articles` article
WHERE article.`id` = ? AND article.`revision` = ?
AND article.`updated_by_identifier` = ? AND article.`deleted_at` IS NULL
]],
params = { media_id, position, data.id, next_revision, mutation_token },
}
end
statements[#statements + 1] = {
query = [[
UPDATE `sky_phone_weazel_articles`
SET `updated_by_identifier` = ?
WHERE `id` = ? AND `revision` = ? AND `updated_by_identifier` = ?
]],
params = { actor.identifier, data.id, next_revision, mutation_token },
}
if not Bridge.Database.Transaction(statements) then
return { success = false, error = "request_failed" }
end
local updated = load_article(data.id, true)
if not updated then
error(("[sky_phone] Could not reload updated Weazel News article '%s'."):format(data.id))
return { success = false, error = "not_found" }
end
if not article_matches(updated, article, next_revision) then
return { success = false, error = "revision_conflict" }
end
return { success = true, data = { article = updated } }
end)
+12
View File
@@ -1244,3 +1244,15 @@ CREATE TABLE IF NOT EXISTS `sky_phone_weazel_articles` (
KEY `idx_sky_phone_weazel_media` (`image_media_id`),
FOREIGN KEY (`image_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `sky_phone_weazel_article_media` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`article_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
`media_id` BIGINT UNSIGNED NOT NULL,
`position` TINYINT UNSIGNED NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sky_phone_weazel_article_position` (`article_id`,`position`),
UNIQUE KEY `uniq_sky_phone_weazel_article_media` (`article_id`,`media_id`),
FOREIGN KEY (`article_id`) REFERENCES `sky_phone_weazel_articles` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;