Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
}
]
Expand Down
11 changes: 10 additions & 1 deletion src/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* }
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/commands/drafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface ContentFlags {
allPlatforms?: boolean;
media?: string;
mediaFile?: string[];
mediaPerPost?: boolean;
replyTo?: string;
quote?: string;
community?: string;
Expand Down Expand Up @@ -97,6 +98,7 @@ async function contentToPlatforms(
community: flags.community,
hideLinkPreview: Boolean(flags.hideLinkPreview),
checkLength: flags.lengthCheck !== false,
mediaPerPost: Boolean(flags.mediaPerPost),
});
}

Expand All @@ -108,6 +110,7 @@ function addContentOptions(cmd: Command): Command {
.option("--all-platforms", "post to every connected platform")
.option("--media <ids>", "comma-separated media ids to attach to the first post")
.option("--media-file <path>", "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 <url>", "X only: reply to this post URL")
.option("--quote <url>", "X only: quote this post URL")
.option("--community <id>", "X only: post into this community id")
Expand Down
13 changes: 12 additions & 1 deletion src/commands/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
});
Expand Down
7 changes: 7 additions & 0 deletions test/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading