From 5d3af15a67ef6100e0f86dd2ea6b534e54ba5ba1 Mon Sep 17 00:00:00 2001 From: acossta Date: Wed, 16 Sep 2026 16:27:24 -0700 Subject: [PATCH] feat(drafts): --media-per-post attaches one media per post in order --- CHANGELOG.md | 8 ++++++++ README.md | 3 +++ src/commands/batch.ts | 11 ++++++++++- src/commands/drafts.ts | 3 +++ src/commands/helpers.ts | 13 ++++++++++++- test/content.test.ts | 7 +++++++ 6 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 596fb4c..a1c41bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- `drafts create --media-per-post` and batch `media_per_post: true`: attach media one per + post in order (thread of N posts, N files) instead of all on the first post. More media + than posts is refused. + ## [0.1.0] - 2026-09-16 Initial release. diff --git a/README.md b/README.md index 6b4c869..74ef7bc 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ printf 'first\n---\nsecond' | typefully drafts create # platforms, media, scheduling, metadata typefully drafts create "Hi" -p x,linkedin --media-file card.png --title "Wed post" --tags ship +# a thread with one image per post: files in post order, one each +typefully drafts create -f thread.md --media-file 1.png --media-file 2.png --media-file 3.png --media-per-post typefully drafts create "Hi" --schedule next-free-slot typefully drafts create "Hi" --schedule 2026-09-24T15:00:00Z typefully drafts create "Hi" --schedule now # publish immediately @@ -153,6 +155,7 @@ recording the resulting draft ids so analytics can be matched back to campaigns. "publish_at": "2026-09-20T15:57:00Z", "tweets": ["main tweet", "reply with the link"], "media_files": ["content/clips/demo.mp4"], + "media_per_post": false, "platforms": ["x"] } ] diff --git a/src/commands/batch.ts b/src/commands/batch.ts index ca4bf9e..25c449d 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -24,6 +24,7 @@ import { buildPlatforms, ctxOf, parsePlatforms, uploadMediaFile } from "./helper * "tweets": ["main", "reply"], // or "posts"; one string per post in the thread * "platforms": ["x"], // optional, default x * "media_file": "path.mp4", "media_files": ["a.png"], // uploaded on push + * "media_per_post": true, // one media_files entry per tweet, in order * "media": true, // legacy flag: attach by hand in Typefully * "tags": ["ship"], "title": "…" // optional overrides * } @@ -43,6 +44,8 @@ export interface BatchPost { platforms?: string[] | string; media_file?: string; media_files?: string[]; + /** Attach media_files one per tweet, in order, instead of all on the first. */ + media_per_post?: boolean; media?: boolean; tags?: string[]; draft_id?: number | string; @@ -281,7 +284,13 @@ export function registerBatchCommands(program: Command): void { for (const f of media) mediaIds.push(await uploadMediaFile(ctx, set, f, { quiet: ctx.format === "json" })); } const body: CreateDraftRequest = { - platforms: buildPlatforms({ platforms, posts: texts, mediaIds, checkLength: opts.lengthCheck !== false }), + platforms: buildPlatforms({ + platforms, + posts: texts, + mediaIds, + checkLength: opts.lengthCheck !== false, + mediaPerPost: Boolean(p.media_per_post), + }), draft_title: title, }; if (when) body.publish_at = when; diff --git a/src/commands/drafts.ts b/src/commands/drafts.ts index 1fdfa33..27a99c0 100644 --- a/src/commands/drafts.ts +++ b/src/commands/drafts.ts @@ -27,6 +27,7 @@ interface ContentFlags { allPlatforms?: boolean; media?: string; mediaFile?: string[]; + mediaPerPost?: boolean; replyTo?: string; quote?: string; community?: string; @@ -97,6 +98,7 @@ async function contentToPlatforms( community: flags.community, hideLinkPreview: Boolean(flags.hideLinkPreview), checkLength: flags.lengthCheck !== false, + mediaPerPost: Boolean(flags.mediaPerPost), }); } @@ -108,6 +110,7 @@ function addContentOptions(cmd: Command): Command { .option("--all-platforms", "post to every connected platform") .option("--media ", "comma-separated media ids to attach to the first post") .option("--media-file ", "upload a local image/video and attach it (repeatable)", collect, []) + .option("--media-per-post", "attach media one per post in order (--media ids first, then --media-file in the order given) instead of all on the first post") .option("--reply-to ", "X only: reply to this post URL") .option("--quote ", "X only: quote this post URL") .option("--community ", "X only: post into this community id") diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index ec47596..56d96b1 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -103,6 +103,8 @@ export interface BuildPlatformsOptions { hideLinkPreview?: boolean; /** Throw when an X post exceeds 280 weighted chars. Default true. */ checkLength?: boolean; + /** Attach mediaIds one per post, in order (post 1 gets the first id, …) instead of all on the first post. */ + mediaPerPost?: boolean; } export function buildPlatforms(options: BuildPlatformsOptions): PlatformsInput { @@ -129,9 +131,18 @@ export function buildPlatforms(options: BuildPlatformsOptions): PlatformsInput { } } + if (options.mediaPerPost && (options.mediaIds?.length ?? 0) > posts.length) { + throw new TypefullyConfigError( + `--media-per-post: ${options.mediaIds?.length} media for ${posts.length} post(s); pass at most one per post, in post order.`, + ); + } + const basePosts: PostInput[] = posts.map((text, index) => { const post: PostInput = { text }; - if (index === 0 && options.mediaIds?.length) post.media_ids = options.mediaIds; + if (options.mediaPerPost) { + const id = options.mediaIds?.[index]; + if (id) post.media_ids = [id]; + } else if (index === 0 && options.mediaIds?.length) post.media_ids = options.mediaIds; if (options.hideLinkPreview) post.hide_link_preview = true; return post; }); diff --git a/test/content.test.ts b/test/content.test.ts index 240d8ab..97ddbf6 100644 --- a/test/content.test.ts +++ b/test/content.test.ts @@ -50,6 +50,13 @@ describe("buildPlatforms", () => { expect(body.x).toEqual({ enabled: true, posts: [{ text: "a", media_ids: ["m1"] }, { text: "b" }] }); expect(body.linkedin).toEqual({ enabled: true, posts: [{ text: "a", media_ids: ["m1"] }, { text: "b" }] }); }); + it("attaches media one per post in order with mediaPerPost", () => { + const body = buildPlatforms({ platforms: ["x"], posts: ["a", "b", "c"], mediaIds: ["m1", "m2"], mediaPerPost: true }); + expect(body.x).toEqual({ enabled: true, posts: [{ text: "a", media_ids: ["m1"] }, { text: "b", media_ids: ["m2"] }, { text: "c" }] }); + }); + it("refuses more media than posts with mediaPerPost", () => { + expect(() => buildPlatforms({ platforms: ["x"], posts: ["a"], mediaIds: ["m1", "m2"], mediaPerPost: true })).toThrow(/at most one per post/); + }); it("puts reply/community in X settings and quote on the first X post only", () => { const body = buildPlatforms({ platforms: ["x", "bluesky"], posts: ["a"], replyTo: "https://x.com/u/status/1", quote: "https://x.com/u/status/2", community: "c1" }); expect(body.x).toEqual({