also looking at this
Edit queued and scheduled posts (#21)
#22
6 files
+359
-7
| @@ -66,6 +66,23 @@ export interface SaveQueueInput { | ||
| 66 | 66 | image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /** Edit input. `image`: undefined = keep, null = remove, object = replace. */ | |
| 70 | export interface UpdateQueueInput extends Omit<SaveQueueInput, 'image'> { | |
| 71 | image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null | |
| 72 | } | |
| 73 | ||
| 74 | /** Editable snapshot of a queued post for prefilling the composer. */ | |
| 75 | export interface QueueEditView { | |
| 76 | id: number | |
| 77 | text: string | |
| 78 | title: string | null | |
| 79 | status: string | |
| 80 | scheduledAt: string | null | |
| 81 | providers: SocialProvider[] | |
| 82 | external: { uri: string, title: string, description?: string } | null | |
| 83 | image: { kind: 'file' | 'url', url: string | null } | null | |
| 84 | } | |
| 85 | ||
| 69 | 86 | export class QueueService { |
| 70 | 87 | /** |
| 71 | 88 | * Persist a post without publishing it. With `scheduledAt` the post is |
| @@ -194,6 +211,110 @@ export class QueueService { | ||
| 194 | 211 | await database.deleteFrom('posts').where('id', '=', post.id).execute() |
| 195 | 212 | } |
| 196 | 213 | |
| 214 | /** | |
| 215 | * Full editable state for one draft/scheduled/failed post, for prefilling | |
| 216 | * the composer. Media is described (present/kind), not returned as bytes — | |
| 217 | * the composer keeps the existing image unless the user replaces/removes it. | |
| 218 | */ | |
| 219 | async get(id: number): Promise<QueueEditView> { | |
| 220 | const post = await this.findActionable(id) | |
| 221 | const stored = parseStoredContent(post.content) | |
| 222 | const targets = await database | |
| 223 | .selectFrom('post_targets') | |
| 224 | .select(['provider']) | |
| 225 | .where('post_id', '=', post.id) | |
| 226 | .execute() | |
| 227 | ||
| 228 | const media = stored?.media?.[0] | |
| 229 | return { | |
| 230 | id: Number(post.id), | |
| 231 | text: String(post.body || ''), | |
| 232 | title: stored?.title || null, | |
| 233 | status: String(post.status), | |
| 234 | scheduledAt: post.scheduled_at || null, | |
| 235 | providers: [...new Set(targets.map((t: any) => t.provider))] as SocialProvider[], | |
| 236 | external: stored?.external || null, | |
| 237 | image: media ? { kind: media.path ? 'file' : 'url', url: media.url || null } : null, | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | /** | |
| 242 | * Update a draft/scheduled/failed post in place: text, title, providers, | |
| 243 | * schedule, link card, and image. `image` semantics — undefined keeps the | |
| 244 | * existing media, null removes it, an object replaces it. Placeholder | |
| 245 | * targets are rebuilt to match the new provider set. | |
| 246 | */ | |
| 247 | async update(id: number, input: UpdateQueueInput): Promise<{ postId: number, status: 'draft' | 'scheduled' }> { | |
| 248 | const post = await this.findActionable(id) | |
| 249 | ||
| 250 | const body = input.text.trim() | |
| 251 | if (!body) | |
| 252 | throw new Error('Post text is required.') | |
| 253 | ||
| 254 | const available = new Set<string>(crosspostProviders()) | |
| 255 | const providers = input.providers.filter(provider => available.has(provider)) | |
| 256 | if (providers.length === 0) | |
| 257 | throw new Error('Select at least one connected provider.') | |
| 258 | ||
| 259 | const scheduledAt = input.scheduledAt?.trim() || null | |
| 260 | if (scheduledAt) { | |
| 261 | if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(scheduledAt)) | |
| 262 | throw new Error('Scheduled time must be a UTC YYYY-MM-DD HH:MM:SS timestamp.') | |
| 263 | if (scheduledAt <= now()) | |
| 264 | throw new Error('Scheduled time must be in the future.') | |
| 265 | } | |
| 266 | ||
| 267 | const status = scheduledAt ? 'scheduled' as const : 'draft' as const | |
| 268 | const existing = parseStoredContent(post.content) | |
| 269 | const stored: StoredContent = {} | |
| 270 | if (input.title?.trim()) stored.title = input.title.trim() | |
| 271 | if (input.external?.uri && input.external.title) stored.external = input.external | |
| 272 | ||
| 273 | // Media: replace / remove / keep. Replacing or removing drops the old file. | |
| 274 | if (input.image === null || input.image?.bytes?.length || input.image?.url) { | |
| 275 | await this.removeStoredMedia(post) | |
| 276 | } | |
| 277 | if (input.image?.bytes?.length) { | |
| 278 | const extension = (input.image.mimeType || 'image/jpeg').split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'jpg' | |
| 279 | const filename = `${post.uuid}-${uuid().slice(0, 8)}.${extension}` | |
| 280 | await mkdir(MEDIA_DIR, { recursive: true }) | |
| 281 | await Bun.write(join(MEDIA_DIR, filename), input.image.bytes) | |
| 282 | stored.media = [{ path: filename, mimeType: input.image.mimeType, altText: input.image.altText }] | |
| 283 | } | |
| 284 | else if (input.image?.url) { | |
| 285 | stored.media = [{ url: input.image.url, altText: input.image.altText }] | |
| 286 | } | |
| 287 | else if (input.image === undefined && existing?.media?.length) { | |
| 288 | stored.media = existing.media | |
| 289 | } | |
| 290 | ||
| 291 | const updatedAt = now() | |
| 292 | await database.updateTable('posts').set({ | |
| 293 | title: input.title?.trim() || body.slice(0, 80), | |
| 294 | body, | |
| 295 | status, | |
| 296 | content: Object.keys(stored).length ? JSON.stringify(stored) : null, | |
| 297 | scheduled_at: scheduledAt, | |
| 298 | updated_at: updatedAt, | |
| 299 | }).where('id', '=', post.id).execute() | |
| 300 | ||
| 301 | // Rebuild placeholder targets to match the new provider set. | |
| 302 | await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute() | |
| 303 | for (const provider of providers) { | |
| 304 | await database.insertInto('post_targets').values({ | |
| 305 | uuid: uuid(), | |
| 306 | provider, | |
| 307 | status, | |
| 308 | scheduled_at: scheduledAt, | |
| 309 | post_id: post.id, | |
| 310 | created_at: updatedAt, | |
| 311 | updated_at: updatedAt, | |
| 312 | }).execute() | |
| 313 | } | |
| 314 | ||
| 315 | return { postId: Number(post.id), status } | |
| 316 | } | |
| 317 | ||
| 197 | 318 | /** Publish a draft/scheduled/failed post immediately. */ |
| 198 | 319 | async publishNow(id: number): Promise<{ postId: number, results: CrosspostTargetResult[] }> { |
| 199 | 320 | const post = await this.findActionable(id) |