ReviewOS

also looking at this

stacks/postline

Queued posts carry title, link card, and image through to publish

#13
Merged glennmichael123 wants to merge feat/queue-content into main
8 files +158 -11

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.gitignoremodified+1-0
Changes to .gitignore
@@ -80,3 +80,4 @@ storage/framework/cache/
8080# kill -9'd run) out of commits.
8181.tmp-upgrade/
8282.claude/scheduled_tasks.lock
83storage/app/postline-media/
app/Actions/Postline/CrosspostPublishAction.tsmodified+4-8
Changes to app/Actions/Postline/CrosspostPublishAction.ts
@@ -3,6 +3,7 @@ import type { RequestInstance } from '@stacksjs/types'
33import { Action } from '@stacksjs/actions'
44import { response } from '@stacksjs/router'
55import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService'
6import { readUploadedImage } from '../../Support/Social/uploads'
67
78export default new Action({
89 name: 'Postline Crosspost Publish',
@@ -34,14 +35,9 @@ export default new Action({
3435 const imageAlt = String(request.get('image_alt') || '').trim()
3536
3637 const media: NonNullable<Parameters<typeof crosspost.publish>[2]>['media'] = []
37 const uploaded = request.file?.('image')
38 if (uploaded?.buffer && uploaded.buffer.byteLength > 0) {
39 media.push({
40 bytes: new Uint8Array(uploaded.buffer),
41 mimeType: uploaded.mimetype || 'image/jpeg',
42 altText: imageAlt || undefined,
43 })
44 }
38 const uploadedImage = await readUploadedImage(request.file?.('image'))
39 if (uploadedImage)
40 media.push({ ...uploadedImage, altText: imageAlt || undefined })
4541 if (imageUrl)
4642 media.push({ url: imageUrl, altText: imageAlt || undefined })
4743
app/Actions/Postline/QueueSaveAction.tsmodified+21-1
Changes to app/Actions/Postline/QueueSaveAction.ts
@@ -4,6 +4,7 @@ import { Action } from '@stacksjs/actions'
44import { response } from '@stacksjs/router'
55import { crosspostProviders } from '../../Services/Social/CrosspostService'
66import { postQueue } from '../../Services/Social/QueueService'
7import { readUploadedImage } from '../../Support/Social/uploads'
78
89export default new Action({
910 name: 'Postline Queue Save',
@@ -22,8 +23,27 @@ export default new Action({
2223 const scheduledAt = String(request.get('scheduled_at') || '').trim() || null
2324 const title = String(request.get('title') || '').trim() || null
2425
26 const externalUri = String(request.get('external_uri') || '').trim()
27 const externalTitle = String(request.get('external_title') || '').trim()
28 const external = externalUri && externalTitle
29 ? {
30 uri: externalUri,
31 title: externalTitle,
32 description: String(request.get('external_description') || '').trim() || undefined,
33 }
34 : null
35
36 const imageAlt = String(request.get('image_alt') || '').trim() || undefined
37 const uploadedImage = await readUploadedImage(request.file?.('image'))
38 const imageUrl = String(request.get('image_url') || '').trim()
39 const image = uploadedImage
40 ? { ...uploadedImage, altText: imageAlt }
41 : imageUrl
42 ? { url: imageUrl, altText: imageAlt }
43 : null
44
2545 try {
26 const data = await postQueue.save({ text, providers, title, scheduledAt })
46 const data = await postQueue.save({ text, providers, title, scheduledAt, external, image })
2747 return response.json({ ok: true, data })
2848 }
2949 catch (error) {
app/Services/Social/QueueService.tsmodified+95-1
Changes to app/Services/Social/QueueService.ts
@@ -1,4 +1,7 @@
1import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types'
1import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types'
2import { mkdir, unlink } from 'node:fs/promises'
3import { join } from 'node:path'
4import process from 'node:process'
25import { db } from '@stacksjs/database'
36import { env } from '@stacksjs/env'
47import { crosspost, crosspostProviders } from './CrosspostService'
@@ -6,6 +9,28 @@ import { ensureAccount, now, uuid } from './support'
69
710const database = db as any
811
12/** Uploaded images for queued posts live here until publish. */
13const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media')
14
15/** Serialized into posts.content — everything beyond the text itself. */
16interface StoredContent {
17 /** Only set when the composer provided an explicit title. */
18 title?: string
19 external?: { uri: string, title: string, description?: string }
20 media?: Array<{ url?: string, path?: string, mimeType?: string, altText?: string }>
21}
22
23function parseStoredContent(raw: unknown): StoredContent | null {
24 if (!raw) return null
25 try {
26 const parsed = JSON.parse(String(raw))
27 return parsed && typeof parsed === 'object' ? parsed as StoredContent : null
28 }
29 catch {
30 return null
31 }
32}
33
934/** Post statuses a user may still act on (publish now, delete). */
1035const ACTIONABLE = new Set(['draft', 'scheduled', 'failed'])
1136
@@ -25,6 +50,8 @@ export interface QueueItemView {
2550 publishedAt: string | null
2651 createdAt: string
2752 providers: QueueTargetView[]
53 hasImage: boolean
54 hasLink: boolean
2855}
2956
3057export interface SaveQueueInput {
@@ -34,6 +61,9 @@ export interface SaveQueueInput {
3461 title?: string | null
3562 /** UTC `YYYY-MM-DD HH:MM:SS`; omitted → saved as a draft. */
3663 scheduledAt?: string | null
64 external?: { uri: string, title: string, description?: string } | null
65 /** Attached image: raw bytes (stored to disk until publish) or a URL. */
66 image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null
3767}
3868
3969export class QueueService {
@@ -67,11 +97,26 @@ export class QueueService {
6797 const postUuid = uuid()
6898 const createdAt = now()
6999
100 const stored: StoredContent = {}
101 if (input.title?.trim()) stored.title = input.title.trim()
102 if (input.external?.uri && input.external.title) stored.external = input.external
103 if (input.image?.bytes?.length) {
104 const extension = (input.image.mimeType || 'image/jpeg').split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'jpg'
105 const filename = `${postUuid}.${extension}`
106 await mkdir(MEDIA_DIR, { recursive: true })
107 await Bun.write(join(MEDIA_DIR, filename), input.image.bytes)
108 stored.media = [{ path: filename, mimeType: input.image.mimeType, altText: input.image.altText }]
109 }
110 else if (input.image?.url) {
111 stored.media = [{ url: input.image.url, altText: input.image.altText }]
112 }
113
70114 await database.insertInto('posts').values({
71115 uuid: postUuid,
72116 title: input.title?.trim() || body.slice(0, 80),
73117 body,
74118 status,
119 content: Object.keys(stored).length ? JSON.stringify(stored) : null,
75120 scheduled_at: scheduledAt,
76121 timezone: env.TZ || 'America/Los_Angeles',
77122 source: 'composer',
@@ -128,6 +173,8 @@ export class QueueService {
128173 scheduledAt: post.scheduled_at || null,
129174 publishedAt: post.published_at || null,
130175 createdAt: String(post.created_at),
176 hasImage: Boolean(parseStoredContent(post.content)?.media?.length),
177 hasLink: Boolean(parseStoredContent(post.content)?.external),
131178 providers: targets
132179 .filter((target: any) => Number(target.post_id) === Number(post.id))
133180 .map((target: any): QueueTargetView => ({
@@ -142,6 +189,7 @@ export class QueueService {
142189 /** Delete a draft, scheduled, or failed post along with its targets. */
143190 async remove(id: number): Promise<void> {
144191 const post = await this.findActionable(id)
192 await this.removeStoredMedia(post)
145193 await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute()
146194 await database.deleteFrom('posts').where('id', '=', post.id).execute()
147195 }
@@ -176,6 +224,7 @@ export class QueueService {
176224 const results = await crosspost.publishExisting(
177225 { id: Number(post.id), body: String(post.body) },
178226 providers,
227 await this.hydrateContent(post),
179228 )
180229
181230 const anyOk = results.some(result => result.ok)
@@ -186,9 +235,54 @@ export class QueueService {
186235 updated_at: finishedAt,
187236 }).where('id', '=', post.id).execute()
188237
238 if (anyOk) await this.removeStoredMedia(post)
239
189240 return { postId: Number(post.id), results }
190241 }
191242
243 /** Rebuild PublishContent from a queued post's stored content JSON. */
244 private async hydrateContent(post: any): Promise<PublishContent | undefined> {
245 const stored = parseStoredContent(post.content)
246 if (!stored) return undefined
247
248 const content: PublishContent = {}
249 if (stored.title) content.title = stored.title
250 if (stored.external) content.external = stored.external
251
252 if (stored.media?.length) {
253 const media: NonNullable<PublishContent['media']> = []
254 for (const item of stored.media) {
255 if (item.path) {
256 try {
257 const file = Bun.file(join(MEDIA_DIR, item.path))
258 if (await file.exists()) {
259 media.push({
260 bytes: new Uint8Array(await file.arrayBuffer()),
261 mimeType: item.mimeType,
262 altText: item.altText,
263 })
264 }
265 }
266 catch {}
267 }
268 else if (item.url) {
269 media.push({ url: item.url, altText: item.altText })
270 }
271 }
272 if (media.length) content.media = media
273 }
274
275 return Object.keys(content).length ? content : undefined
276 }
277
278 private async removeStoredMedia(post: any): Promise<void> {
279 const stored = parseStoredContent(post.content)
280 for (const item of stored?.media || []) {
281 if (!item.path) continue
282 await unlink(join(MEDIA_DIR, item.path)).catch(() => {})
283 }
284 }
285
192286 /**
193287 * Publish every scheduled post whose time has come. Called by the
194288 * PublishScheduledPosts job every minute; failures on one post never block
app/Support/Social/uploads.tsadded+21-0
Changes to app/Support/Social/uploads.ts
@@ -0,0 +1,21 @@
1/**
2 * Read an uploaded image regardless of shape: the router's multipart
3 * parser stores raw Web `File` objects on `request.files`, while the
4 * typed `FileInfo` contract (`buffer`/`mimetype`) describes the upload
5 * middleware's processed form. Accept both.
6 */
7export async function readUploadedImage(uploaded: unknown): Promise<{ bytes: Uint8Array, mimeType: string } | null> {
8 if (!uploaded || typeof uploaded !== 'object') return null
9 const candidate = uploaded as { buffer?: ArrayBuffer, mimetype?: string, type?: string, arrayBuffer?: () => Promise<ArrayBuffer> }
10
11 if (candidate.buffer && candidate.buffer.byteLength > 0)
12 return { bytes: new Uint8Array(candidate.buffer), mimeType: candidate.mimetype || 'image/jpeg' }
13
14 if (typeof candidate.arrayBuffer === 'function') {
15 const buffer = await candidate.arrayBuffer()
16 if (buffer.byteLength > 0)
17 return { bytes: new Uint8Array(buffer), mimeType: candidate.type || 'image/jpeg' }
18 }
19
20 return null
21}
database/migrations/0000000031-add-content-to-posts.sqladded+4-0
Changes to database/migrations/0000000031-add-content-to-posts.sql
@@ -0,0 +1,4 @@
1-- Persisted publish content (explicit title, link card, media refs) so
2-- queued posts publish with everything the composer attached, not just
3-- their text.
4ALTER TABLE "posts" ADD COLUMN "content" TEXT;
resources/views/index.stxmodified+10-0
Changes to resources/views/index.stx
@@ -536,6 +536,15 @@
536536 const queueTitle = composedTitle()
537537 if (queueTitle) form.set('title', queueTitle)
538538 if (scheduleValue) form.set('scheduled_at', toUtcTimestamp(scheduleValue))
539 if (activePost?.dataset.externalUri && activePost.dataset.externalTitle) {
540 form.set('external_uri', activePost.dataset.externalUri)
541 form.set('external_title', activePost.dataset.externalTitle)
542 form.set('external_description', activePost.dataset.externalDescription || '')
543 }
544 const queueImageUrl = composeImage?.value.trim()
545 if (queueImageUrl) form.set('image_url', queueImageUrl)
546 const queueFile = attachedFile()
547 if (queueFile) form.set('image', queueFile, queueFile.name)
539548
540549 buttons.forEach(button => button.setAttribute('disabled', 'disabled'))
541550 try {
@@ -548,6 +557,7 @@
548557 if (scheduleInput) scheduleInput.value = ''
549558 if (composeTitle) composeTitle.value = ''
550559 scheduleInput?.classList.add('hidden')
560 clearAttachment()
551561 renderLinkPreview(activePost, null)
552562 updateCount()
553563 updatePublishLabel()
resources/views/queue.stxmodified+2-1
Changes to resources/views/queue.stx
@@ -92,7 +92,8 @@
9292 : item.status === 'published'
9393 ? `Published · ${formatTime(item.publishedAt || item.createdAt)}`
9494 : `${item.status[0].toUpperCase()}${item.status.slice(1)} · ${formatTime(item.createdAt)}`
95 const channels = item.providers.map(target => providerLabel(target.provider)).join(', ') || '—'
95 const extras = [item.hasImage ? 'image' : '', item.hasLink ? 'link card' : ''].filter(Boolean).join(' · ')
96 const channels = (item.providers.map(target => providerLabel(target.provider)).join(', ') || '—') + (extras ? ` · ${extras}` : '')
9697 const failure = item.providers.find(target => target.failureReason)?.failureReason
9798 const liveUri = item.providers.find(target => target.remoteUri)?.remoteUri
9899 const liveUrl = liveUrlFor(liveUri)