also looking at this
Queued posts carry title, link card, and image through to publish
#13
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.
| @@ -3,6 +3,7 @@ import type { RequestInstance } from '@stacksjs/types' | ||
| 3 | 3 | import { Action } from '@stacksjs/actions' |
| 4 | 4 | import { response } from '@stacksjs/router' |
| 5 | 5 | import { crosspost, crosspostProviders } from '../../Services/Social/CrosspostService' |
| 6 | import { readUploadedImage } from '../../Support/Social/uploads' | |
| 6 | 7 | |
| 7 | 8 | export default new Action({ |
| 8 | 9 | name: 'Postline Crosspost Publish', |
| @@ -34,14 +35,9 @@ export default new Action({ | ||
| 34 | 35 | const imageAlt = String(request.get('image_alt') || '').trim() |
| 35 | 36 | |
| 36 | 37 | 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 }) | |
| 45 | 41 | if (imageUrl) |
| 46 | 42 | media.push({ url: imageUrl, altText: imageAlt || undefined }) |
| 47 | 43 | |
| @@ -4,6 +4,7 @@ import { Action } from '@stacksjs/actions' | ||
| 4 | 4 | import { response } from '@stacksjs/router' |
| 5 | 5 | import { crosspostProviders } from '../../Services/Social/CrosspostService' |
| 6 | 6 | import { postQueue } from '../../Services/Social/QueueService' |
| 7 | import { readUploadedImage } from '../../Support/Social/uploads' | |
| 7 | 8 | |
| 8 | 9 | export default new Action({ |
| 9 | 10 | name: 'Postline Queue Save', |
| @@ -22,8 +23,27 @@ export default new Action({ | ||
| 22 | 23 | const scheduledAt = String(request.get('scheduled_at') || '').trim() || null |
| 23 | 24 | const title = String(request.get('title') || '').trim() || null |
| 24 | 25 | |
| 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 | ||
| 25 | 45 | try { |
| 26 | const data = await postQueue.save({ text, providers, title, scheduledAt }) | |
| 46 | const data = await postQueue.save({ text, providers, title, scheduledAt, external, image }) | |
| 27 | 47 | return response.json({ ok: true, data }) |
| 28 | 48 | } |
| 29 | 49 | catch (error) { |
| @@ -1,4 +1,7 @@ | ||
| 1 | import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types' | |
| 1 | import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types' | |
| 2 | import { mkdir, unlink } from 'node:fs/promises' | |
| 3 | import { join } from 'node:path' | |
| 4 | import process from 'node:process' | |
| 2 | 5 | import { db } from '@stacksjs/database' |
| 3 | 6 | import { env } from '@stacksjs/env' |
| 4 | 7 | import { crosspost, crosspostProviders } from './CrosspostService' |
| @@ -6,6 +9,28 @@ import { ensureAccount, now, uuid } from './support' | ||
| 6 | 9 | |
| 7 | 10 | const database = db as any |
| 8 | 11 | |
| 12 | /** Uploaded images for queued posts live here until publish. */ | |
| 13 | const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media') | |
| 14 | ||
| 15 | /** Serialized into posts.content — everything beyond the text itself. */ | |
| 16 | interface 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 | ||
| 23 | function 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 | ||
| 9 | 34 | /** Post statuses a user may still act on (publish now, delete). */ |
| 10 | 35 | const ACTIONABLE = new Set(['draft', 'scheduled', 'failed']) |
| 11 | 36 | |
| @@ -25,6 +50,8 @@ export interface QueueItemView { | ||
| 25 | 50 | publishedAt: string | null |
| 26 | 51 | createdAt: string |
| 27 | 52 | providers: QueueTargetView[] |
| 53 | hasImage: boolean | |
| 54 | hasLink: boolean | |
| 28 | 55 | } |
| 29 | 56 | |
| 30 | 57 | export interface SaveQueueInput { |
| @@ -34,6 +61,9 @@ export interface SaveQueueInput { | ||
| 34 | 61 | title?: string | null |
| 35 | 62 | /** UTC `YYYY-MM-DD HH:MM:SS`; omitted → saved as a draft. */ |
| 36 | 63 | 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 | |
| 37 | 67 | } |
| 38 | 68 | |
| 39 | 69 | export class QueueService { |
| @@ -67,11 +97,26 @@ export class QueueService { | ||
| 67 | 97 | const postUuid = uuid() |
| 68 | 98 | const createdAt = now() |
| 69 | 99 | |
| 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 | ||
| 70 | 114 | await database.insertInto('posts').values({ |
| 71 | 115 | uuid: postUuid, |
| 72 | 116 | title: input.title?.trim() || body.slice(0, 80), |
| 73 | 117 | body, |
| 74 | 118 | status, |
| 119 | content: Object.keys(stored).length ? JSON.stringify(stored) : null, | |
| 75 | 120 | scheduled_at: scheduledAt, |
| 76 | 121 | timezone: env.TZ || 'America/Los_Angeles', |
| 77 | 122 | source: 'composer', |
| @@ -128,6 +173,8 @@ export class QueueService { | ||
| 128 | 173 | scheduledAt: post.scheduled_at || null, |
| 129 | 174 | publishedAt: post.published_at || null, |
| 130 | 175 | createdAt: String(post.created_at), |
| 176 | hasImage: Boolean(parseStoredContent(post.content)?.media?.length), | |
| 177 | hasLink: Boolean(parseStoredContent(post.content)?.external), | |
| 131 | 178 | providers: targets |
| 132 | 179 | .filter((target: any) => Number(target.post_id) === Number(post.id)) |
| 133 | 180 | .map((target: any): QueueTargetView => ({ |
| @@ -142,6 +189,7 @@ export class QueueService { | ||
| 142 | 189 | /** Delete a draft, scheduled, or failed post along with its targets. */ |
| 143 | 190 | async remove(id: number): Promise<void> { |
| 144 | 191 | const post = await this.findActionable(id) |
| 192 | await this.removeStoredMedia(post) | |
| 145 | 193 | await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute() |
| 146 | 194 | await database.deleteFrom('posts').where('id', '=', post.id).execute() |
| 147 | 195 | } |
| @@ -176,6 +224,7 @@ export class QueueService { | ||
| 176 | 224 | const results = await crosspost.publishExisting( |
| 177 | 225 | { id: Number(post.id), body: String(post.body) }, |
| 178 | 226 | providers, |
| 227 | await this.hydrateContent(post), | |
| 179 | 228 | ) |
| 180 | 229 | |
| 181 | 230 | const anyOk = results.some(result => result.ok) |
| @@ -186,9 +235,54 @@ export class QueueService { | ||
| 186 | 235 | updated_at: finishedAt, |
| 187 | 236 | }).where('id', '=', post.id).execute() |
| 188 | 237 | |
| 238 | if (anyOk) await this.removeStoredMedia(post) | |
| 239 | ||
| 189 | 240 | return { postId: Number(post.id), results } |
| 190 | 241 | } |
| 191 | 242 | |
| 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 | ||
| 192 | 286 | /** |
| 193 | 287 | * Publish every scheduled post whose time has come. Called by the |
| 194 | 288 | * PublishScheduledPosts job every minute; failures on one post never block |
| @@ -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 | */ | |
| 7 | export 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 | } | |
| @@ -536,6 +536,15 @@ | ||
| 536 | 536 | const queueTitle = composedTitle() |
| 537 | 537 | if (queueTitle) form.set('title', queueTitle) |
| 538 | 538 | 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) | |
| 539 | 548 | |
| 540 | 549 | buttons.forEach(button => button.setAttribute('disabled', 'disabled')) |
| 541 | 550 | try { |
| @@ -548,6 +557,7 @@ | ||
| 548 | 557 | if (scheduleInput) scheduleInput.value = '' |
| 549 | 558 | if (composeTitle) composeTitle.value = '' |
| 550 | 559 | scheduleInput?.classList.add('hidden') |
| 560 | clearAttachment() | |
| 551 | 561 | renderLinkPreview(activePost, null) |
| 552 | 562 | updateCount() |
| 553 | 563 | updatePublishLabel() |
| @@ -92,7 +92,8 @@ | ||
| 92 | 92 | : item.status === 'published' |
| 93 | 93 | ? `Published · ${formatTime(item.publishedAt || item.createdAt)}` |
| 94 | 94 | : `${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}` : '') | |
| 96 | 97 | const failure = item.providers.find(target => target.failureReason)?.failureReason |
| 97 | 98 | const liveUri = item.providers.find(target => target.remoteUri)?.remoteUri |
| 98 | 99 | const liveUrl = liveUrlFor(liveUri) |