diff --git a/CHANGELOG.md b/CHANGELOG.md index 561f924..05b24d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to this project are documented here. The format follows ### Added +- `blog().suggestSeeds(workspaceId[, seeds, prompt])` suggests about 10 search terms from the + blog goal and topics (needs `ai`, 402 on an empty balance), and `blog().submitArticle` moves a + draft, failed or rejected article to `in_review` (needs `posts`, 409 otherwise). +- `BlogSettings` gains `goal`, `focusTopics`, `avoidTopics` and `planRun` (`BlogPlanRun`: + `status`, `error`, `added`, `finishedAt`); `BlogParams.Settings` gains `goal`, `focusTopics` + and `avoidTopics`, with the `BlogGoal` constants. `BlogKeyword.fitReason` says why a keyword + serves the goal. +- `BlogArticleStatus.IN_REVIEW`. - `whatsapp()` for a WhatsApp Business connection: the business profile (`getProfile`, `updateProfile`, `requestDisplayName`, `setUsername`), message templates including the platform's own library (`listTemplates`, @@ -88,12 +96,27 @@ All notable changes to this project are documented here. The format follows ### Changed +- `blog()` matches the live API contract: the workspace travels as `workspace_id` on the query + string, single objects are read bare and lists from `{data}`, and responses are read in + snake_case. `BlogWebsiteTest.status` is now `httpStatus`, read from the API's `http_status`; + a failed test still comes back with `ok` false rather than as an exception. +- `blog().planKeywords` no longer fills the calendar: results land as `suggested` keywords, and + setting one to `planned` with `updateKeyword` picks it and gives it the next free date. +- `blog().publishArticle` and `publishAt` on `updateArticle` need an article in review (or + already scheduled or failed); a draft answers 409 until it is submitted. +- `BlogSettings.reviewHoldDays` is now the number of days before its planned date that a picked + keyword starts being written. - Presign and direct upload accept sizes up to 5 GB; there is no 50 MB ceiling in the SDK. - An oversize file is a `413` with code `file_too_large`. It surfaces as a `FoPostException` with `status()` 413 and `max_file_bytes`, `file_bytes` and `upgrade_url` on `body()`. - Multipart bodies and direct uploads stream from their source. `HttpRequestData` gained `bodyStream` and `bodyLength` beside `body`; a custom `Transport` must send whichever is set. +### Removed + +- **Breaking:** `BlogSettings.autoPublish` and `BlogParams.Settings.autoPublish(...)`. Every + article now goes through `submitArticle` before it publishes. + ## [0.3.0] - 2026-09-19 ### Added diff --git a/README.md b/README.md index b7715f0..50b5bf6 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` | | `labels()` | `list`, `get`, `create`, `update`, `delete` | | `webhooks()` | `list`, `create`, `update`, `delete`, `test`, `verify` | -| `blog()` | `getSettings`, `updateSettings`, `rotateWebsiteSecret`, `testWebsite`, `listKeywords`, `planKeywords`, `addKeyword`, `updateKeyword`, `deleteKeyword`, `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, `regenerateArticle`, `publishArticle`, `rejectArticle`, `deleteArticle` | +| `blog()` | `getSettings`, `updateSettings`, `rotateWebsiteSecret`, `testWebsite`, `listKeywords`, `suggestSeeds`, `planKeywords`, `addKeyword`, `updateKeyword`, `deleteKeyword`, `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, `regenerateArticle`, `submitArticle`, `publishArticle`, `rejectArticle`, `deleteArticle` | | `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collectPost`, `nativePosts` | | `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats` | | `media()` | `list`, `limits`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | @@ -266,6 +266,27 @@ the articles the writer produces, and the settings that say where they are publi `BlogTargetKind.PRODUCT_BLOG`, a connected `ACCOUNT`, or your own `WEBSITE`, which receives each published article as a signed webhook. +Planning starts from the blog's goal. Suggest search terms, research them, then pick the keywords +worth writing; a picked keyword takes the next free date on the calendar. + +```java +client.blog().updateSettings( + workspace.id(), + BlogParams.Settings.create().goal(BlogGoal.LEADS).focusTopics("Scheduling for small teams")); + +List seeds = client.blog().suggestSeeds(workspace.id(), List.of(), "Beginners, not agencies"); +client.blog().planKeywords(workspace.id(), seeds); + +for (BlogKeyword keyword : client.blog().listKeywords( + workspace.id(), BlogParams.Filter.create().status(BlogKeywordStatus.SUGGESTED).limit(3))) { + client.blog().updateKeyword( + workspace.id(), keyword.id(), BlogParams.KeywordUpdate.create().status(BlogKeywordStatus.PLANNED)); +} +``` + +Every article is approved before it publishes: submit a draft for review, then publish it or give +it a `publishAt`. + ```java BlogArticle article = client.blog().requestArticle( workspace.id(), @@ -275,13 +296,14 @@ BlogArticle article = client.blog().requestArticle( // Poll until the status leaves queued and writing, then read the body. BlogArticleFull draft = client.blog().getArticle(workspace.id(), article.id()); +client.blog().submitArticle(workspace.id(), article.id()); client.blog().publishArticle(workspace.id(), article.id()); ``` -Reads, settings and keyword edits need `posts`; `planKeywords`, `requestArticle` and -`regenerateArticle` spend AI credits and need `ai` (an empty balance is a -`PaymentRequiredException`); `publishArticle` needs `publish` and answers 409 `no_target` until -a target is set. Setting `websiteUrl` issues the site's signing secret once, on +Reads, settings, keyword edits and `submitArticle` need `posts`; `suggestSeeds`, `planKeywords`, +`requestArticle` and `regenerateArticle` spend AI credits and need `ai` (an empty balance is a +`PaymentRequiredException`); `publishArticle` needs `publish`, answers 409 for a draft that was +never submitted, and 409 `no_target` until a target is set. Setting `websiteUrl` issues the site's signing secret once, on `BlogSettingsUpdate.websiteSecret()`; `rotateWebsiteSecret` issues a new one. To verify a delivery, hand `WebhookVerifier` the raw request bytes, the headers and the secret. diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java index c9fc2d9..02fe820 100644 --- a/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java +++ b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java @@ -10,6 +10,7 @@ private BlogArticleStatus() {} public static final String QUEUED = "queued"; public static final String WRITING = "writing"; public static final String DRAFT = "draft"; + public static final String IN_REVIEW = "in_review"; public static final String SCHEDULED = "scheduled"; public static final String PUBLISHING = "publishing"; public static final String PUBLISHED = "published"; @@ -17,5 +18,5 @@ private BlogArticleStatus() {} public static final String REJECTED = "rejected"; public static final List ALL = - List.of(QUEUED, WRITING, DRAFT, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED); + List.of(QUEUED, WRITING, DRAFT, IN_REVIEW, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED); } diff --git a/src/main/java/com/fopost/sdk/model/BlogGoal.java b/src/main/java/com/fopost/sdk/model/BlogGoal.java new file mode 100644 index 0000000..db58dd0 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogGoal.java @@ -0,0 +1,17 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** What the blog is for; steers suggested search terms and keyword ranking. */ +public final class BlogGoal { + + private BlogGoal() {} + + public static final String TRAFFIC = "traffic"; + public static final String LEADS = "leads"; + public static final String SALES = "sales"; + public static final String SIGNUPS = "signups"; + public static final String AUTHORITY = "authority"; + + public static final List ALL = List.of(TRAFFIC, LEADS, SALES, SIGNUPS, AUTHORITY); +} diff --git a/src/main/java/com/fopost/sdk/model/BlogKeyword.java b/src/main/java/com/fopost/sdk/model/BlogKeyword.java index 9c3feaf..245a31d 100644 --- a/src/main/java/com/fopost/sdk/model/BlogKeyword.java +++ b/src/main/java/com/fopost/sdk/model/BlogKeyword.java @@ -6,6 +6,7 @@ /** * A keyword in the plan. {@code status} is one of {@link BlogKeywordStatus}; {@code source} is * {@code provider}, {@code user} or {@code agent}. {@code difficulty} runs 0 to 100. + * {@code fitReason} says why the keyword serves the blog goal. */ public record BlogKeyword( String id, @@ -17,4 +18,5 @@ public record BlogKeyword( String source, String status, LocalDate plannedFor, + String fitReason, Instant createdAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogPlanRun.java b/src/main/java/com/fopost/sdk/model/BlogPlanRun.java new file mode 100644 index 0000000..9c42f39 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogPlanRun.java @@ -0,0 +1,9 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * The last keyword research run. {@code status} is {@code queued}, {@code running}, {@code done} or + * {@code failed}; {@code added} is how many new keywords it found. + */ +public record BlogPlanRun(String status, String error, Integer added, Instant finishedAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogSettings.java b/src/main/java/com/fopost/sdk/model/BlogSettings.java index 6dedc11..c2e064b 100644 --- a/src/main/java/com/fopost/sdk/model/BlogSettings.java +++ b/src/main/java/com/fopost/sdk/model/BlogSettings.java @@ -5,10 +5,12 @@ /** * A workspace's blog configuration. * - *

{@code cadencePerWeek} is how many articles the planner queues each week; 0 turns planning - * off. {@code reviewHoldDays} is how long a draft waits for review before it may auto-publish. - * {@code targetKind} is one of {@link BlogTargetKind}. {@code hasWebsiteSecret} says whether a + *

{@code goal} is one of {@link BlogGoal}, or null. {@code cadencePerWeek} is how many articles + * the planner queues each week; 0 turns planning off. {@code reviewHoldDays} is how many days before + * its planned date a picked keyword starts being written. {@code targetKind} is one of + * {@link BlogTargetKind}. {@code hasWebsiteSecret} says whether a * website target has a signing secret; the secret itself is only ever returned when issued. + * {@code planRun} is the last keyword research run, null before the first. * {@code keywordDataAvailable} says whether search volume and difficulty can be fetched on this * deployment. */ @@ -16,11 +18,13 @@ public record BlogSettings( String siteUrl, String topic, String audience, + String goal, + String focusTopics, + String avoidTopics, String language, Integer locationCode, int cadencePerWeek, int reviewHoldDays, - boolean autoPublish, String imageTier, Integer inBodyImages, Integer minWords, @@ -31,5 +35,6 @@ public record BlogSettings( String targetPlatform, String websiteUrl, boolean hasWebsiteSecret, + BlogPlanRun planRun, Boolean keywordDataAvailable, Instant updatedAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java index f745c86..72e7658 100644 --- a/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java +++ b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java @@ -1,4 +1,7 @@ package com.fopost.sdk.model; -/** The outcome of a test delivery to the website target. {@code status} is the site's HTTP answer. */ -public record BlogWebsiteTest(boolean ok, Integer status, String error) {} +/** + * The outcome of a test delivery to the website target. {@code httpStatus} is the site's HTTP + * answer; {@code error} says why a failed test failed. + */ +public record BlogWebsiteTest(boolean ok, Integer httpStatus, String error) {} diff --git a/src/main/java/com/fopost/sdk/param/BlogParams.java b/src/main/java/com/fopost/sdk/param/BlogParams.java index b779bc7..4d47a62 100644 --- a/src/main/java/com/fopost/sdk/param/BlogParams.java +++ b/src/main/java/com/fopost/sdk/param/BlogParams.java @@ -55,6 +55,24 @@ public Settings audience(String audience) { return this; } + /** One of {@link com.fopost.sdk.model.BlogGoal}; null clears it. */ + public Settings goal(String goal) { + body.put("goal", goal); + return this; + } + + /** What the blog wants to write about; null clears it. */ + public Settings focusTopics(String focusTopics) { + body.put("focusTopics", focusTopics); + return this; + } + + /** What the blog never writes about; null clears it. */ + public Settings avoidTopics(String avoidTopics) { + body.put("avoidTopics", avoidTopics); + return this; + } + public Settings language(String language) { body.put("language", language); return this; @@ -71,17 +89,12 @@ public Settings cadencePerWeek(int cadencePerWeek) { return this; } - /** Days a draft waits for review before it may auto-publish, 0 to 14. */ + /** Days before its planned date that a picked keyword starts being written, 0 to 14. */ public Settings reviewHoldDays(int reviewHoldDays) { body.put("reviewHoldDays", reviewHoldDays); return this; } - public Settings autoPublish(boolean autoPublish) { - body.put("autoPublish", autoPublish); - return this; - } - /** {@code draft}, {@code standard} or {@code best}. */ public Settings imageTier(String imageTier) { body.put("imageTier", imageTier); @@ -191,7 +204,10 @@ public static KeywordUpdate create() { return new KeywordUpdate(); } - /** One of {@link com.fopost.sdk.model.BlogKeywordStatus}. */ + /** + * One of {@link com.fopost.sdk.model.BlogKeywordStatus}. {@code planned} picks the keyword and + * gives it the next free date. + */ public KeywordUpdate status(String status) { body.put("status", status); return this; @@ -244,7 +260,7 @@ public Map toMap() { } } - /** Edits a draft. Only a draft or scheduled article is editable; anything else answers 409. */ + /** Edits an article. One still being written or already publishing answers 409. */ public static final class ArticleUpdate { private final Map body = new LinkedHashMap<>(); @@ -289,7 +305,10 @@ public ArticleUpdate tags(List tags) { return this; } - /** Puts the draft on the calendar; null returns it to review. */ + /** + * Puts an article in review on the calendar; a draft answers 409 until it is submitted. Null + * returns it to draft. + */ public ArticleUpdate publishAt(Instant publishAt) { body.put("publishAt", Params.iso(publishAt)); return this; diff --git a/src/main/java/com/fopost/sdk/resource/BlogResource.java b/src/main/java/com/fopost/sdk/resource/BlogResource.java index f49d25e..c6583b4 100644 --- a/src/main/java/com/fopost/sdk/resource/BlogResource.java +++ b/src/main/java/com/fopost/sdk/resource/BlogResource.java @@ -20,17 +20,19 @@ * Articles for a website: the keyword plan, the drafts the writer produces, and where they are * published. A post goes to a social network; an article goes to a website. * - *

Reads, settings and keyword edits need the {@code posts} scope. {@code planKeywords}, - * {@code requestArticle} and {@code regenerateArticle} spend AI credits and need {@code ai}; an - * empty balance answers 402. {@code publishArticle} needs {@code publish} and answers 409 - * {@code no_target} until the settings name a target. Every call names the workspace: on the query - * string for a read, an update or a delete, in the body for a create. + *

Reads, settings, keyword edits and {@code submitArticle} need the {@code posts} scope. + * {@code suggestSeeds}, {@code planKeywords}, {@code requestArticle} and {@code regenerateArticle} + * spend AI credits and need {@code ai}; an empty balance answers 402. {@code publishArticle} needs + * {@code publish}, answers 409 for a draft that was never submitted, and 409 {@code no_target} + * until the settings name a target. Every call names the workspace: as {@code workspace_id} on the + * query string for a read, an update or a delete, as {@code workspaceId} in the body for a create. * *

{@code
  * BlogArticle article = fopost.blog().requestArticle(
  *         workspace.id(), BlogParams.Article.create().keyword("how to schedule posts"));
  *
  * BlogArticleFull full = fopost.blog().getArticle(workspace.id(), article.id());
+ * fopost.blog().submitArticle(workspace.id(), article.id());
  * fopost.blog().publishArticle(workspace.id(), article.id());
  * }
*/ @@ -46,7 +48,7 @@ public BlogResource(ApiClient http) { public BlogSettings getSettings(String workspaceId) { return http.convert( - ApiClient.unwrap(http.get("/v1/blog/settings", workspace(workspaceId))), BlogSettings.class); + ApiClient.unwrap(http.get("/v1/blog/settings", query(workspaceId))), BlogSettings.class); } /** @@ -57,34 +59,39 @@ public BlogSettings getSettings(String workspaceId) { public BlogSettingsUpdate updateSettings(String workspaceId, BlogParams.Settings params) { JsonNode body = http.patch("/v1/blog/settings", withWorkspace(workspaceId, params.toNode())); BlogSettings settings = http.convert(ApiClient.unwrap(body), BlogSettings.class); - JsonNode secret = body.path("websiteSecret"); + JsonNode secret = ApiClient.unwrap(body).path("website_secret"); return new BlogSettingsUpdate(settings, secret.isTextual() ? secret.asText() : null); } /** Issue a new website signing secret. The old one stops verifying at once. */ public String rotateWebsiteSecret(String workspaceId) { - JsonNode body = ApiClient.unwrap(http.post("/v1/blog/settings/website-secret", workspace(workspaceId))); - return body.path("websiteSecret").asText(null); + JsonNode body = ApiClient.unwrap(http.post("/v1/blog/settings/website-secret", body(workspaceId))); + return body.path("website_secret").asText(null); } /** * Send a signed {@code article.test} delivery to the website target. A site that does not - * answer 2xx comes back with {@code ok} false and its status, rather than as an exception. + * answer 2xx comes back with {@code ok} false, its {@code httpStatus} and the reason, rather + * than as an exception. */ public BlogWebsiteTest testWebsite(String workspaceId) { try { - return http.convert( - ApiClient.unwrap(http.post("/v1/blog/settings/website-test", workspace(workspaceId))), - BlogWebsiteTest.class); + JsonNode body = ApiClient.unwrap(http.post("/v1/blog/settings/website-test", body(workspaceId))); + return new BlogWebsiteTest(true, httpStatus(body), null); } catch (FoPostException e) { - // The API answers 502 with the same {data: {ok, status, error}} body when the site fails. - if (e.status() == 502 && e.body() instanceof JsonNode body && body.has("data")) { - return http.convert(body.get("data"), BlogWebsiteTest.class); + // The API answers 502 website_test_failed, with the site's http_status, when the site fails. + if (e.status() == 502 && e.body() instanceof JsonNode body && body.has("http_status")) { + return new BlogWebsiteTest(false, httpStatus(body), e.getMessage()); } throw e; } } + private static Integer httpStatus(JsonNode body) { + JsonNode status = body.path("http_status"); + return status.isNumber() ? status.asInt() : null; + } + // ── Keywords ── public List listKeywords(String workspaceId) { @@ -92,19 +99,49 @@ public List listKeywords(String workspaceId) { } public List listKeywords(String workspaceId, BlogParams.Filter filter) { - Map query = workspace(workspaceId); + Map query = query(workspaceId); query.putAll(filter.toQuery()); return http.convertList(ApiClient.unwrap(http.get("/v1/blog/keywords", query)), BlogKeyword.class); } - /** Queue a planning run from the settings' topic and audience. Spends AI credits. */ + /** About 10 search terms from the blog goal and topics. Spends AI credits. */ + public List suggestSeeds(String workspaceId) { + return suggestSeeds(workspaceId, List.of(), null); + } + + /** + * About 10 search terms from the blog goal and topics, to edit before {@link #planKeywords}. + * Pass the current {@code seeds} (at most 20) to refine them and a {@code prompt} (up to 500 + * characters, or null) to steer. Spends AI credits. + */ + public List suggestSeeds(String workspaceId, List seeds, String prompt) { + Map body = body(workspaceId); + if (!seeds.isEmpty()) { + body.put("seeds", seeds); + } + if (prompt != null) { + body.put("prompt", prompt); + } + JsonNode data = ApiClient.unwrap(http.post("/v1/blog/keywords/suggest", body)); + return http.convertList(data.path("seeds"), String.class); + } + + /** + * Queue a research run with terms derived from the blog settings. Results land as + * {@code suggested} keywords; follow the run in {@link BlogSettings#planRun()}. Spends AI + * credits. + */ public void planKeywords(String workspaceId) { planKeywords(workspaceId, List.of()); } - /** Queue a planning run around these seed terms. Spends AI credits. */ + /** + * Queue a research run around these seed terms. Results land as {@code suggested} keywords and + * never fill the calendar; set one to {@code planned} with {@link #updateKeyword} to pick it. + * Spends AI credits. + */ public void planKeywords(String workspaceId, List seeds) { - Map body = workspace(workspaceId); + Map body = body(workspaceId); if (!seeds.isEmpty()) { body.put("seeds", seeds); } @@ -112,7 +149,7 @@ public void planKeywords(String workspaceId, List seeds) { } public BlogKeyword addKeyword(String workspaceId, BlogParams.Keyword params) { - Map body = workspace(workspaceId); + Map body = body(workspaceId); body.putAll(params.toMap()); return http.convert(ApiClient.unwrap(http.post("/v1/blog/keywords", body)), BlogKeyword.class); } @@ -120,12 +157,12 @@ public BlogKeyword addKeyword(String workspaceId, BlogParams.Keyword params) { public BlogKeyword updateKeyword(String workspaceId, String keywordId, BlogParams.KeywordUpdate params) { return http.convert( ApiClient.unwrap(http.request( - "PATCH", "/v1/blog/keywords/" + keywordId, params.toNode(), workspace(workspaceId))), + "PATCH", "/v1/blog/keywords/" + keywordId, params.toNode(), query(workspaceId))), BlogKeyword.class); } public void deleteKeyword(String workspaceId, String keywordId) { - http.request("DELETE", "/v1/blog/keywords/" + keywordId, null, workspace(workspaceId)); + http.request("DELETE", "/v1/blog/keywords/" + keywordId, null, query(workspaceId)); } // ── Articles ── @@ -135,7 +172,7 @@ public List listArticles(String workspaceId) { } public List listArticles(String workspaceId, BlogParams.Filter filter) { - Map query = workspace(workspaceId); + Map query = query(workspaceId); query.putAll(filter.toQuery()); return http.convertList(ApiClient.unwrap(http.get("/v1/blog/articles", query)), BlogArticle.class); } @@ -145,22 +182,25 @@ public List listArticles(String workspaceId, BlogParams.Filter filt * its status leaves {@code queued} and {@code writing}. Spends AI credits. */ public BlogArticle requestArticle(String workspaceId, BlogParams.Article params) { - Map body = workspace(workspaceId); + Map body = body(workspaceId); body.putAll(params.toMap()); return http.convert(ApiClient.unwrap(http.post("/v1/blog/articles", body)), BlogArticle.class); } public BlogArticleFull getArticle(String workspaceId, String articleId) { return http.convert( - ApiClient.unwrap(http.get("/v1/blog/articles/" + articleId, workspace(workspaceId))), + ApiClient.unwrap(http.get("/v1/blog/articles/" + articleId, query(workspaceId))), BlogArticleFull.class); } - /** Edit a draft. An article that is not editable answers 409 {@code invalid_state}. */ + /** + * Edit an article. One that is not editable answers 409 {@code invalid_state}, as does a + * {@code publishAt} on a draft that was never submitted with {@link #submitArticle}. + */ public BlogArticleFull updateArticle(String workspaceId, String articleId, BlogParams.ArticleUpdate params) { return http.convert( ApiClient.unwrap(http.request( - "PATCH", "/v1/blog/articles/" + articleId, params.toNode(), workspace(workspaceId))), + "PATCH", "/v1/blog/articles/" + articleId, params.toNode(), query(workspaceId))), BlogArticleFull.class); } @@ -170,29 +210,41 @@ public BlogArticle regenerateArticle(String workspaceId, String articleId) { } /** - * Publish a draft to the configured target. Needs {@code publish}; answers 409 - * {@code no_target} when the settings name none. + * Publish an article in review to the configured target. Needs {@code publish}. A draft answers + * 409 until it is submitted with {@link #submitArticle}; 409 {@code no_target} means the + * settings name none. */ public BlogArticle publishArticle(String workspaceId, String articleId) { return action(workspaceId, articleId, "publish"); } - /** Take a draft out of review without publishing it. */ + /** Move a draft, failed or rejected article to {@code in_review}. Any other state answers 409. */ + public BlogArticle submitArticle(String workspaceId, String articleId) { + return action(workspaceId, articleId, "submit"); + } + + /** Take an article out of review without publishing it. */ public BlogArticle rejectArticle(String workspaceId, String articleId) { return action(workspaceId, articleId, "reject"); } public void deleteArticle(String workspaceId, String articleId) { - http.request("DELETE", "/v1/blog/articles/" + articleId, null, workspace(workspaceId)); + http.request("DELETE", "/v1/blog/articles/" + articleId, null, query(workspaceId)); } private BlogArticle action(String workspaceId, String articleId, String action) { return http.convert( - ApiClient.unwrap(http.post("/v1/blog/articles/" + articleId + "/" + action, null, workspace(workspaceId))), + ApiClient.unwrap(http.post("/v1/blog/articles/" + articleId + "/" + action, null, query(workspaceId))), BlogArticle.class); } - private static Map workspace(String workspaceId) { + private static Map query(String workspaceId) { + Map map = new LinkedHashMap<>(); + map.put("workspace_id", workspaceId); + return map; + } + + private static Map body(String workspaceId) { Map map = new LinkedHashMap<>(); map.put("workspaceId", workspaceId); return map; diff --git a/src/test/java/com/fopost/sdk/BlogTest.java b/src/test/java/com/fopost/sdk/BlogTest.java index 30e2b44..fc7274f 100644 --- a/src/test/java/com/fopost/sdk/BlogTest.java +++ b/src/test/java/com/fopost/sdk/BlogTest.java @@ -9,6 +9,7 @@ import com.fopost.sdk.model.BlogArticle; import com.fopost.sdk.model.BlogArticleFull; import com.fopost.sdk.model.BlogArticleStatus; +import com.fopost.sdk.model.BlogGoal; import com.fopost.sdk.model.BlogKeyword; import com.fopost.sdk.model.BlogKeywordStatus; import com.fopost.sdk.model.BlogSettings; @@ -25,44 +26,54 @@ class BlogTest { private static final String SETTINGS = """ - {"siteUrl":"https://yourbrand.com","topic":"Scheduling","audience":"Marketers", - "language":"en","locationCode":2840,"cadencePerWeek":2,"reviewHoldDays":3, - "autoPublish":false,"imageTier":"standard","inBodyImages":2,"minWords":900, - "maxWords":1500,"brandVoiceId":null,"targetKind":"website","targetAccountId":null, - "targetPlatform":null,"websiteUrl":"https://yourbrand.com/hooks/fopost", - "hasWebsiteSecret":true,"keywordDataAvailable":true, - "updatedAt":"2026-10-11T09:00:00.000Z"}"""; + {"site_url":"https://yourbrand.com","topic":"Scheduling","audience":"Marketers", + "goal":"leads","focus_topics":"Scheduling tips","avoid_topics":"Competitor brands", + "language":"en","location_code":2840,"cadence_per_week":2,"review_hold_days":3, + "image_tier":"standard","in_body_images":2,"min_words":900, + "max_words":1500,"brand_voice_id":null,"target_kind":"website","target_account_id":null, + "target_platform":null,"website_url":"https://yourbrand.com/hooks/fopost", + "has_website_secret":true,"keyword_data_available":true, + "plan_run":{"status":"done","error":null,"added":12,"finished_at":"2026-10-11T09:05:00.000Z"}, + "updated_at":"2026-10-11T09:00:00.000Z"}"""; private static final String KEYWORD = """ - {"id":"kw_1","keyword":"how to schedule posts","searchVolume":1200,"difficulty":34, + {"id":"kw_1","keyword":"how to schedule posts","search_volume":1200,"difficulty":34, "cpc":1.25,"intent":"informational","source":"provider","status":"planned", - "plannedFor":"2026-10-14","createdAt":"2026-10-11T09:00:00.000Z"}"""; + "planned_for":"2026-10-14","fit_reason":"Readers who schedule posts need a tool", + "created_at":"2026-10-11T09:00:00.000Z"}"""; private static final String ARTICLE = """ {"id":"art_1","title":"How to schedule posts","slug":"how-to-schedule-posts", - "keyword":"how to schedule posts","excerpt":null,"metaTitle":null,"metaDescription":null, - "featuredImage":{"mediaId":"m_1","url":"https://cdn.example/f.png","alt":"Calendar"}, - "tags":["scheduling"],"wordCount":0,"status":"queued","origin":"user", - "reviewUntil":null,"publishAt":null,"publishedAt":null,"publishedUrl":null,"error":null, - "createdAt":"2026-10-11T09:00:00.000Z","updatedAt":"2026-10-11T09:00:00.000Z"}"""; + "keyword":"how to schedule posts","excerpt":null,"meta_title":null,"meta_description":null, + "featured_image":{"media_id":"m_1","url":"https://cdn.example/f.png","alt":"Calendar"}, + "tags":["scheduling"],"word_count":0,"status":"queued","origin":"user", + "review_until":null,"publish_at":null,"published_at":null,"published_url":null,"error":null, + "created_at":"2026-10-11T09:00:00.000Z","updated_at":"2026-10-11T09:00:00.000Z"}"""; @Test void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() { FakeTransport transport = new FakeTransport() - .enqueue(200, "{\"data\":" + SETTINGS + "}") - .enqueue(200, "{\"data\":" + SETTINGS + ",\"websiteSecret\":\"whsec_abc\"}") - .enqueue(200, "{\"data\":{\"websiteSecret\":\"whsec_def\"}}") - .enqueue(502, "{\"data\":{\"ok\":false,\"status\":500,\"error\":\"HTTP 500\"}}"); + .enqueue(200, SETTINGS) + .enqueue(200, SETTINGS.substring(0, SETTINGS.length() - 1) + ",\"website_secret\":\"whsec_abc\"}") + .enqueue(200, "{\"website_secret\":\"whsec_def\"}") + .enqueue(200, "{\"http_status\":204}") + .enqueue(502, "{\"error\":\"website_test_failed\",\"message\":\"HTTP 500\",\"http_status\":500}"); FoPost client = TestSupport.client(transport); BlogSettings settings = client.blog().getSettings("w1"); - assertEquals("https://api.fopost.test/v1/blog/settings?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/settings?workspace_id=w1", transport.last().url()); assertEquals(BlogTargetKind.WEBSITE, settings.targetKind()); assertEquals(2, settings.cadencePerWeek()); assertTrue(settings.hasWebsiteSecret()); assertEquals(Instant.parse("2026-10-11T09:00:00Z"), settings.updatedAt()); + assertEquals(BlogGoal.LEADS, settings.goal()); + assertEquals("Scheduling tips", settings.focusTopics()); + assertEquals("Competitor brands", settings.avoidTopics()); + assertEquals("done", settings.planRun().status()); + assertEquals(12, settings.planRun().added()); + assertEquals(Instant.parse("2026-10-11T09:05:00Z"), settings.planRun().finishedAt()); BlogSettingsUpdate updated = client.blog().updateSettings( "w1", @@ -70,12 +81,16 @@ void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() { .websiteUrl("https://yourbrand.com/hooks/fopost") .targetKind(BlogTargetKind.WEBSITE) .brandVoiceId(null) - .cadencePerWeek(2)); + .cadencePerWeek(2) + .goal(BlogGoal.LEADS) + .focusTopics("Scheduling tips") + .avoidTopics(null)); assertEquals("PATCH", transport.last().method()); assertEquals("https://api.fopost.test/v1/blog/settings", transport.last().url()); assertEquals( "{\"workspaceId\":\"w1\",\"websiteUrl\":\"https://yourbrand.com/hooks/fopost\"," - + "\"targetKind\":\"website\",\"brandVoiceId\":null,\"cadencePerWeek\":2}", + + "\"targetKind\":\"website\",\"brandVoiceId\":null,\"cadencePerWeek\":2," + + "\"goal\":\"leads\",\"focusTopics\":\"Scheduling tips\",\"avoidTopics\":null}", transport.lastBody()); assertEquals("whsec_abc", updated.websiteSecret()); assertEquals("en", updated.settings().language()); @@ -84,31 +99,37 @@ void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() { assertEquals("https://api.fopost.test/v1/blog/settings/website-secret", transport.last().url()); assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody()); - BlogWebsiteTest test = client.blog().testWebsite("w1"); + BlogWebsiteTest passed = client.blog().testWebsite("w1"); assertEquals("https://api.fopost.test/v1/blog/settings/website-test", transport.last().url()); - assertFalse(test.ok()); - assertEquals(500, test.status()); - assertEquals("HTTP 500", test.error()); + assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody()); + assertTrue(passed.ok()); + assertEquals(204, passed.httpStatus()); + + BlogWebsiteTest failed = client.blog().testWebsite("w1"); + assertFalse(failed.ok()); + assertEquals(500, failed.httpStatus()); + assertEquals("HTTP 500", failed.error()); } @Test void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() { FakeTransport transport = new FakeTransport() .enqueue(200, "{\"data\":[" + KEYWORD + "]}") - .enqueue(202, "{\"data\":{\"status\":\"queued\"}}") - .enqueue(201, "{\"data\":" + KEYWORD + "}") - .enqueue(200, "{\"data\":" + KEYWORD + "}") + .enqueue(202, "{\"status\":\"queued\"}") + .enqueue(201, KEYWORD) + .enqueue(200, KEYWORD) .enqueue(204, ""); FoPost client = TestSupport.client(transport); List keywords = client.blog().listKeywords( "w1", BlogParams.Filter.create().status(BlogKeywordStatus.SUGGESTED, BlogKeywordStatus.PLANNED).limit(20)); assertEquals( - "https://api.fopost.test/v1/blog/keywords?workspaceId=w1&status=suggested%2Cplanned&limit=20", + "https://api.fopost.test/v1/blog/keywords?workspace_id=w1&status=suggested%2Cplanned&limit=20", transport.last().url()); assertEquals(1200, keywords.get(0).searchVolume()); assertEquals(LocalDate.of(2026, 10, 14), keywords.get(0).plannedFor()); assertEquals(1.25, keywords.get(0).cpc()); + assertEquals("Readers who schedule posts need a tool", keywords.get(0).fitReason()); client.blog().planKeywords("w1", List.of("scheduling", "analytics")); assertEquals("https://api.fopost.test/v1/blog/keywords/plan", transport.last().url()); @@ -125,12 +146,12 @@ void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() { client.blog().updateKeyword( "w1", "kw_1", BlogParams.KeywordUpdate.create().status(BlogKeywordStatus.DISMISSED).plannedFor(null)); assertEquals("PATCH", transport.last().method()); - assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspace_id=w1", transport.last().url()); assertEquals("{\"status\":\"dismissed\",\"plannedFor\":null}", transport.lastBody()); client.blog().deleteKeyword("w1", "kw_1"); assertEquals("DELETE", transport.last().method()); - assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspace_id=w1", transport.last().url()); assertNull(transport.last().body()); } @@ -138,16 +159,16 @@ void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() { void articlesAreRequestedInTheBodyAndActedOnByPath() { String full = ARTICLE.substring(0, ARTICLE.length() - 1) + ",\"instructions\":\"Keep it short\",\"outline\":[\"Why\",\"How\"]," - + "\"brief\":{\"sources\":2},\"bodyMarkdown\":\"# Hi\",\"bodyHtml\":\"

Hi

\"," - + "\"images\":[{\"mediaId\":\"m_2\",\"url\":\"https://cdn.example/i.png\",\"alt\":\"Step\",\"position\":1}]}"; + + "\"brief\":{\"questions\":[\"Why\"],\"search_volume\":2},\"body_markdown\":\"# Hi\",\"body_html\":\"

Hi

\"," + + "\"images\":[{\"media_id\":\"m_2\",\"url\":\"https://cdn.example/i.png\",\"alt\":\"Step\",\"position\":1}]}"; FakeTransport transport = new FakeTransport() .enqueue(200, "{\"data\":[" + ARTICLE + "]}") - .enqueue(202, "{\"data\":" + ARTICLE + "}") - .enqueue(200, "{\"data\":" + full + "}") - .enqueue(200, "{\"data\":" + full + "}") - .enqueue(202, "{\"data\":" + ARTICLE + "}") - .enqueue(202, "{\"data\":" + ARTICLE + "}") - .enqueue(200, "{\"data\":" + ARTICLE + "}") + .enqueue(202, ARTICLE) + .enqueue(200, full) + .enqueue(200, full) + .enqueue(202, ARTICLE) + .enqueue(202, ARTICLE) + .enqueue(200, ARTICLE) .enqueue(204, "") .enqueue(402, "{\"error\":\"insufficient_credits\",\"message\":\"Balance\",\"upgrade_url\":\"https://fopost.com/billing\"}") .enqueue(409, "{\"error\":\"no_target\",\"message\":\"Pick a target first\"}"); @@ -155,7 +176,7 @@ void articlesAreRequestedInTheBodyAndActedOnByPath() { List articles = client.blog().listArticles( "w1", BlogParams.Filter.create().status(BlogArticleStatus.DRAFT)); - assertEquals("https://api.fopost.test/v1/blog/articles?workspaceId=w1&status=draft", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles?workspace_id=w1&status=draft", transport.last().url()); assertEquals("Calendar", articles.get(0).featuredImage().alt()); assertEquals(List.of("scheduling"), articles.get(0).tags()); @@ -168,29 +189,29 @@ void articlesAreRequestedInTheBodyAndActedOnByPath() { assertEquals(BlogArticleStatus.QUEUED, requested.status()); BlogArticleFull fetched = client.blog().getArticle("w1", "art_1"); - assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspace_id=w1", transport.last().url()); assertEquals("# Hi", fetched.bodyMarkdown()); assertEquals(List.of("Why", "How"), fetched.outline()); - assertEquals(2, fetched.brief().path("sources").asInt()); + assertEquals(2, fetched.brief().path("search_volume").asInt()); assertEquals(1, fetched.images().get(0).position()); client.blog().updateArticle( "w1", "art_1", BlogParams.ArticleUpdate.create().title("New title").tags(List.of("a", "b")).publishAt(null)); assertEquals("PATCH", transport.last().method()); - assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspace_id=w1", transport.last().url()); assertEquals("{\"title\":\"New title\",\"tags\":[\"a\",\"b\"],\"publishAt\":null}", transport.lastBody()); client.blog().regenerateArticle("w1", "art_1"); - assertEquals("https://api.fopost.test/v1/blog/articles/art_1/regenerate?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/regenerate?workspace_id=w1", transport.last().url()); client.blog().publishArticle("w1", "art_1"); - assertEquals("https://api.fopost.test/v1/blog/articles/art_1/publish?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/publish?workspace_id=w1", transport.last().url()); assertEquals("POST", transport.last().method()); assertNull(transport.last().body()); client.blog().rejectArticle("w1", "art_1"); - assertEquals("https://api.fopost.test/v1/blog/articles/art_1/reject?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/reject?workspace_id=w1", transport.last().url()); client.blog().deleteArticle("w1", "art_1"); assertEquals("DELETE", transport.last().method()); - assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspace_id=w1", transport.last().url()); PaymentRequiredException noCredits = assertThrows( PaymentRequiredException.class, @@ -202,4 +223,46 @@ void articlesAreRequestedInTheBodyAndActedOnByPath() { assertEquals(409, noTarget.status()); assertEquals("no_target", noTarget.code()); } + + @Test + void suggestSeedsSendsTheCurrentSeedsAndPromptAndReturnsTheTerms() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"seeds\":[\"schedule posts\",\"best time to post\"]}") + .enqueue(200, "{\"seeds\":[]}") + .enqueue(402, "{\"error\":\"insufficient_credits\",\"message\":\"Balance\"}"); + FoPost client = TestSupport.client(transport); + + List seeds = client.blog().suggestSeeds("w1", List.of("scheduling"), "Small teams"); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/keywords/suggest", transport.last().url()); + assertEquals( + "{\"workspaceId\":\"w1\",\"seeds\":[\"scheduling\"],\"prompt\":\"Small teams\"}", + transport.lastBody()); + assertEquals(List.of("schedule posts", "best time to post"), seeds); + + assertEquals(List.of(), client.blog().suggestSeeds("w1")); + assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody()); + + assertThrows(PaymentRequiredException.class, () -> client.blog().suggestSeeds("w1")); + } + + @Test + void submitArticleMovesItToReviewByPath() { + String inReview = ARTICLE.replace("\"status\":\"queued\"", "\"status\":\"in_review\""); + FakeTransport transport = new FakeTransport() + .enqueue(200, inReview) + .enqueue(409, "{\"error\":\"invalid_state\",\"message\":\"Only a draft can be submitted\"}"); + FoPost client = TestSupport.client(transport); + + BlogArticle submitted = client.blog().submitArticle("w1", "art_1"); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/submit?workspace_id=w1", transport.last().url()); + assertNull(transport.last().body()); + assertEquals(BlogArticleStatus.IN_REVIEW, submitted.status()); + assertTrue(BlogArticleStatus.ALL.contains(submitted.status())); + + FoPostException conflict = assertThrows(FoPostException.class, () -> client.blog().submitArticle("w1", "art_1")); + assertEquals(409, conflict.status()); + assertEquals("invalid_state", conflict.code()); + } }