planvortex 0.3.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {planvortex-0.3.0 → planvortex-0.5.0}/CHANGELOG.md +56 -0
  2. {planvortex-0.3.0 → planvortex-0.5.0}/PKG-INFO +1 -1
  3. {planvortex-0.3.0 → planvortex-0.5.0}/openapi/planvortex.openapi.json +133 -14
  4. {planvortex-0.3.0 → planvortex-0.5.0}/pyproject.toml +1 -1
  5. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_generated/models.py +15 -2
  6. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_shapes.py +4 -3
  7. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_version.py +1 -1
  8. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/accounts.py +1 -1
  9. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/ai_plans.py +84 -8
  10. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/accounts.py +1 -1
  11. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/ai_plans.py +77 -7
  12. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/types.py +1 -1
  13. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_ai_plans.py +45 -1
  14. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_route_coverage.py +6 -2
  15. {planvortex-0.3.0 → planvortex-0.5.0}/.gitignore +0 -0
  16. {planvortex-0.3.0 → planvortex-0.5.0}/LICENSE +0 -0
  17. {planvortex-0.3.0 → planvortex-0.5.0}/README.md +0 -0
  18. {planvortex-0.3.0 → planvortex-0.5.0}/scripts/build_docs.py +0 -0
  19. {planvortex-0.3.0 → planvortex-0.5.0}/scripts/check_packaging.py +0 -0
  20. {planvortex-0.3.0 → planvortex-0.5.0}/scripts/generate_models.py +0 -0
  21. {planvortex-0.3.0 → planvortex-0.5.0}/scripts/generate_sync.py +0 -0
  22. {planvortex-0.3.0 → planvortex-0.5.0}/scripts/route_coverage.py +0 -0
  23. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/__init__.py +0 -0
  24. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_client.py +0 -0
  25. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_client_sync.py +0 -0
  26. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/__init__.py +0 -0
  27. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/auth.py +0 -0
  28. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/auth_sync.py +0 -0
  29. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/errors.py +0 -0
  30. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/files.py +0 -0
  31. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/http.py +0 -0
  32. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/http_sync.py +0 -0
  33. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/pagination.py +0 -0
  34. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/query.py +0 -0
  35. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_core/transport.py +0 -0
  36. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/_generated/__init__.py +0 -0
  37. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/py.typed +0 -0
  38. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/__init__.py +0 -0
  39. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/apps.py +0 -0
  40. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/base.py +0 -0
  41. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/catalog.py +0 -0
  42. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/clients.py +0 -0
  43. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/comments.py +0 -0
  44. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/contacts.py +0 -0
  45. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/dashboard.py +0 -0
  46. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/integrations.py +0 -0
  47. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/messages.py +0 -0
  48. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/organizations.py +0 -0
  49. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/products.py +0 -0
  50. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/publications.py +0 -0
  51. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources/uploads.py +0 -0
  52. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/__init__.py +0 -0
  53. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/apps.py +0 -0
  54. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/base.py +0 -0
  55. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/catalog.py +0 -0
  56. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/clients.py +0 -0
  57. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/comments.py +0 -0
  58. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/contacts.py +0 -0
  59. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/dashboard.py +0 -0
  60. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/integrations.py +0 -0
  61. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/messages.py +0 -0
  62. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/organizations.py +0 -0
  63. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/products.py +0 -0
  64. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/publications.py +0 -0
  65. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/resources_sync/uploads.py +0 -0
  66. {planvortex-0.3.0 → planvortex-0.5.0}/src/planvortex/webhooks.py +0 -0
  67. {planvortex-0.3.0 → planvortex-0.5.0}/tests/__init__.py +0 -0
  68. {planvortex-0.3.0 → planvortex-0.5.0}/tests/conftest.py +0 -0
  69. {planvortex-0.3.0 → planvortex-0.5.0}/tests/contrato.py +0 -0
  70. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/__init__.py +0 -0
  71. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/conftest.py +0 -0
  72. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/test_auth.py +0 -0
  73. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/test_catalog.py +0 -0
  74. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/test_connect_flow.py +0 -0
  75. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/test_errors.py +0 -0
  76. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/test_publish.py +0 -0
  77. {planvortex-0.3.0 → planvortex-0.5.0}/tests/live/test_read.py +0 -0
  78. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_accounts.py +0 -0
  79. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_apps.py +0 -0
  80. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_catalog.py +0 -0
  81. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_client.py +0 -0
  82. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_clients.py +0 -0
  83. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_comments.py +0 -0
  84. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_contacts.py +0 -0
  85. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_core.py +0 -0
  86. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_dashboard.py +0 -0
  87. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_example_comments.py +0 -0
  88. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_example_connect.py +0 -0
  89. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_example_publish.py +0 -0
  90. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_example_schedule.py +0 -0
  91. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_example_webhooks.py +0 -0
  92. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_files.py +0 -0
  93. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_generate_models.py +0 -0
  94. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_generate_sync.py +0 -0
  95. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_integrations.py +0 -0
  96. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_messages.py +0 -0
  97. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_models.py +0 -0
  98. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_openapi_freshness.py +0 -0
  99. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_organizations.py +0 -0
  100. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_packaging.py +0 -0
  101. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_pagination.py +0 -0
  102. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_products.py +0 -0
  103. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_publications.py +0 -0
  104. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_query_parity.py +0 -0
  105. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_readme.py +0 -0
  106. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_shapes_parity.py +0 -0
  107. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_types.py +0 -0
  108. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_uploads.py +0 -0
  109. {planvortex-0.3.0 → planvortex-0.5.0}/tests/test_webhooks.py +0 -0
@@ -4,6 +4,62 @@ All notable changes to this package are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0] - 2026-09-02
8
+
9
+ **Threads is the twelfth network**, and the types now know it.
10
+
11
+ Nothing here removes anything: `SocialNetwork` gains a value, `PublishableNetwork` gains another,
12
+ and everything that type-checked before type-checks now. What was wrong until today was quieter than
13
+ an error — the committed copy of the spec predated Threads, so an account coming back from a real
14
+ API arrived with a `social_network` no `Literal` had ever heard of, and the prose still counted
15
+ eleven networks where the API had twelve.
16
+
17
+ ### Changed
18
+
19
+ - The OpenAPI copy and the generated models are rebuilt from the published document, so `threads`
20
+ is a `SocialNetwork`, a `CommentNetwork` and a publishable network.
21
+ - `PublishableNetwork` — hand-maintained, because generating it is not possible — includes
22
+ `threads`: **eleven of the twelve** networks have a feed, and `google_business` is still the one
23
+ that does not.
24
+ - The prose that counts networks: a connection link is a `redirect` on **ten of the twelve**, not
25
+ nine of eleven. WhatsApp and Telegram are still the two that are not.
26
+
27
+ ## [0.4.0] - 2026-08-31
28
+
29
+ An AI plan can now be **archived**, and deleting one finally means what people thought it meant.
30
+
31
+ The two look alike on a screen and have nothing in common underneath: archiving takes the plan out
32
+ of the listing and touches nothing else, while deleting takes the publications that had not gone out
33
+ with it. Almost every time someone reaches for "remove" they wanted the first one.
34
+
35
+ Upgrading from `0.3.0` needs no changes and `MIGRATION.md` gains no entry — everything added here is
36
+ additive. **Read the note on `remove()` anyway**: what it deletes changed in the server, so it has
37
+ been happening to your `0.3.0` calls for a while and the docs that shipped with them say otherwise.
38
+
39
+ ### Added
40
+
41
+ - **`ai_plans.archive()` and `ai_plans.unarchive()`**, in both clients. Archiving is **visibility
42
+ only**: the plan leaves the listing and moves to the archived one, no publication is touched —
43
+ anything scheduled keeps publishing — no credits come back, nothing is cancelled. It therefore
44
+ works in **any state**, `generating` included, and it is undone. On the object it is
45
+ `archived_date`: a field, **not** a value of `state`, so reading the state to find out whether a
46
+ plan is archived will never work. Absent means active, which is how every plan created before the
47
+ field existed comes back.
48
+ - **`archived` on `ai_plans.list()` and `ai_plans.aiterate()`/`iterate()`.** `True` returns the
49
+ archived plans instead of the active ones, never both at once. A `False` is not sent: the server
50
+ switches the filter on with the literal `"true"`, so it would be noise — and it would suggest a
51
+ third mode with both cupboards open that does not exist.
52
+
53
+ ### Changed
54
+
55
+ - **`ai_plans.remove()` deletes the plan's publications that have not gone out yet**, not only the
56
+ drafts: if the plan had already been validated, whatever was still scheduled goes too. This is a
57
+ change **in the server**, so it reached your existing calls the day it shipped — before it, a
58
+ validated plan you deleted kept publishing by itself the following week. Two states are
59
+ deliberately left alone: the already published one, because deleting it here would not take it off
60
+ the network and would only lose its history, and the one being published at that very moment.
61
+ If you wanted the plan out of the way without losing anything, that is `archive()`.
62
+
7
63
  ## [0.3.0] - 2026-08-31
8
64
 
9
65
  The AI planner stops being one thing. A plan can now be generated from **your own photos**, from an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: planvortex
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Official Python client for the PlanVortex API
5
5
  Project-URL: Homepage, https://planvortex.com/developers
6
6
  Project-URL: Documentation, https://taliasoftworks.github.io/PlanVortexPython/
@@ -107,7 +107,7 @@
107
107
  "accounts"
108
108
  ],
109
109
  "summary": "Get the connection links of every connectable network",
110
- "description": "The authorization URL of each network, so the user can connect an account to this organization.\n\n**A network that cannot produce a link simply does not appear.** That is a legitimate answer, not a failure: it is what happens with `discord` in an organization that has not saved its own bot credentials yet (see `PUT /organizations/{id_organization}/social_credentials/{social_network}`).\n\n**An app cannot call this.** Connecting a social account is an OAuth flow with a person in front of it, so this endpoint only accepts a user token or a temporal connect token; with app credentials it answers error 519. The way an integration does it is to issue a temporal connect token with `GET /organizations/{id_organization}/temporal_connect_token` and hand it to its end user.\n\n**Read `authorization`, not `link`.** Nine of the eleven networks are `redirect` and you send the person to `link`. Two are not, and neither of them fails visibly if you treat it as one:\n\n• **WhatsApp is not a URL at all.** Its sign-up is Meta's Embedded Signup, a popup you raise with the Facebook JavaScript SDK, so its `link` is an empty string and everything you need to open that popup travels in `authorization`. A client that loops over the list and redirects to `link` sends its user to its own page.\n• **Telegram has a link and still is not a redirect.** It opens a chat with the PlanVortex bot, and nobody comes back from it: the account is born minutes later, from the bot being added to a channel, and it is announced over the WebSocket. Open it in another tab and keep listening; redirect to it and there is nobody left to tell.",
110
+ "description": "The authorization URL of each network, so the user can connect an account to this organization.\n\n**A network that cannot produce a link simply does not appear.** That is a legitimate answer, not a failure: it is what happens with `discord` in an organization that has not saved its own bot credentials yet (see `PUT /organizations/{id_organization}/social_credentials/{social_network}`).\n\n**An app cannot call this.** Connecting a social account is an OAuth flow with a person in front of it, so this endpoint only accepts a user token or a temporal connect token; with app credentials it answers error 519. The way an integration does it is to issue a temporal connect token with `GET /organizations/{id_organization}/temporal_connect_token` and hand it to its end user.\n\n**Read `authorization`, not `link`.** Ten of the twelve networks are `redirect` and you send the person to `link`. Two are not, and neither of them fails visibly if you treat it as one:\n\n• **WhatsApp is not a URL at all.** Its sign-up is Meta's Embedded Signup, a popup you raise with the Facebook JavaScript SDK, so its `link` is an empty string and everything you need to open that popup travels in `authorization`. A client that loops over the list and redirects to `link` sends its user to its own page.\n• **Telegram has a link and still is not a redirect.** It opens a chat with the PlanVortex bot, and nobody comes back from it: the account is born minutes later, from the bot being added to a channel, and it is announced over the WebSocket. Open it in another tab and keep listening; redirect to it and there is nobody left to tell.",
111
111
  "operationId": "getConnectLinks",
112
112
  "x-planvortex-identity": [
113
113
  "current_user",
@@ -1000,7 +1000,7 @@
1000
1000
  "ai_plans"
1001
1001
  ],
1002
1002
  "summary": "List AI publication plans",
1003
- "description": "Return the organization's AI plans ordered by creation date (desc), paginated. Requires the ai_plans:read permission.\n\n**Cancelled plans are not listed.** Deleting a plan sets it to `cancelled` rather than removing it, and this listing filters those out — so a plan you deleted simply stops appearing, while `GET` by id still returns it.\n\nWithout `limit` the whole list comes back.",
1003
+ "description": "Return the organization's AI plans ordered by creation date (desc), paginated. Requires the ai_plans:read permission.\n\n**Active plans by default, archived ones with `archived=true`** — never both at once. Archiving is visibility only (see `POST .../archive`): the plan leaves this listing and keeps every publication it had.\n\n**Cancelled plans are not listed either.** Deleting a plan sets it to `cancelled` rather than removing it, and this listing filters those out — so a plan you deleted simply stops appearing, while `GET` by id still returns it.\n\nWithout `limit` the whole list comes back.",
1004
1004
  "operationId": "getAiPlans",
1005
1005
  "x-planvortex-identity": [
1006
1006
  "current_user",
@@ -1031,6 +1031,16 @@
1031
1031
  "schema": {
1032
1032
  "type": "integer"
1033
1033
  }
1034
+ },
1035
+ {
1036
+ "name": "archived",
1037
+ "in": "query",
1038
+ "description": "`true` returns the archived plans instead of the active ones. Anything other than the literal string `true` is read as false.",
1039
+ "required": false,
1040
+ "schema": {
1041
+ "type": "boolean",
1042
+ "default": false
1043
+ }
1034
1044
  }
1035
1045
  ],
1036
1046
  "responses": {
@@ -1113,8 +1123,8 @@
1113
1123
  "tags": [
1114
1124
  "ai_plans"
1115
1125
  ],
1116
- "summary": "Cancel / discard an AI publication plan",
1117
- "description": "Discard the plan and delete its generated draft publications. AI credits already spent on generation are NOT refunded (same criterion as X: if the provider charged, we charge). Requires the ai_plans:delete permission.",
1126
+ "summary": "Delete an AI publication plan and its pending publications",
1127
+ "description": "Delete the plan **and every publication of it that has not gone out yet**: the generated drafts and, if the plan was already validated, whatever was still scheduled. Two states are deliberately left alone: `sended`, because it is already live on the network and deleting it here would only lose its history while the post stays up, and `publishing`, which the publishing job holds at that very moment.\n\nAI credits already spent on generation are NOT refunded (same criterion as X: if the provider charged, we charge). A plan being generated cannot be deleted (state `generating`, error 2102): wait for the job to finish.\n\nTo keep the plan and its publications and only take it out of the listing, archive it instead (`POST .../archive`). Requires the ai_plans:delete permission.",
1118
1128
  "operationId": "deleteAiPlan",
1119
1129
  "x-planvortex-identity": [
1120
1130
  "current_user",
@@ -1258,6 +1268,106 @@
1258
1268
  }
1259
1269
  }
1260
1270
  },
1271
+ "/clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/archive": {
1272
+ "post": {
1273
+ "tags": [
1274
+ "ai_plans"
1275
+ ],
1276
+ "summary": "Archive an AI publication plan",
1277
+ "description": "Take the plan out of the default listing and move it to the archived one (`GET .../ai_plans?archived=true`). It is **visibility only**: no publication is touched — anything scheduled keeps publishing — no credits are refunded, and nothing is cancelled. It is reversible with `POST .../unarchive` and valid in **any state**, `generating` included, because it does not interrupt the generation job.\n\nArchiving an already archived plan just refreshes `archived_date`. Requires the ai_plans:update permission.",
1278
+ "operationId": "archiveAiPlan",
1279
+ "x-planvortex-identity": [
1280
+ "current_user",
1281
+ "current_app"
1282
+ ],
1283
+ "parameters": [
1284
+ {
1285
+ "$ref": "#/components/parameters/AiPlansidClient"
1286
+ },
1287
+ {
1288
+ "$ref": "#/components/parameters/AiPlansidOrganization"
1289
+ },
1290
+ {
1291
+ "$ref": "#/components/parameters/AiPlansidAiPlan"
1292
+ }
1293
+ ],
1294
+ "responses": {
1295
+ "200": {
1296
+ "description": "Successful operation. Returns the plan with `archived_date` set.",
1297
+ "content": {
1298
+ "application/json": {
1299
+ "schema": {
1300
+ "$ref": "#/components/schemas/AiPlansAiPlanOne"
1301
+ }
1302
+ }
1303
+ }
1304
+ },
1305
+ "400": {
1306
+ "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. |\n| `521` | Invalid client. The identifier is invalid or doesn't exist |",
1307
+ "content": {
1308
+ "application/json": {
1309
+ "schema": {
1310
+ "$ref": "#/components/schemas/Error"
1311
+ }
1312
+ }
1313
+ }
1314
+ },
1315
+ "500": {
1316
+ "description": "Unhandled error by the server"
1317
+ }
1318
+ }
1319
+ }
1320
+ },
1321
+ "/clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/unarchive": {
1322
+ "post": {
1323
+ "tags": [
1324
+ "ai_plans"
1325
+ ],
1326
+ "summary": "Unarchive an AI publication plan",
1327
+ "description": "Return the plan to the default listing: `archived_date` is cleared. Unarchiving a plan that was not archived does nothing and answers 200. Requires the ai_plans:update permission.",
1328
+ "operationId": "unarchiveAiPlan",
1329
+ "x-planvortex-identity": [
1330
+ "current_user",
1331
+ "current_app"
1332
+ ],
1333
+ "parameters": [
1334
+ {
1335
+ "$ref": "#/components/parameters/AiPlansidClient"
1336
+ },
1337
+ {
1338
+ "$ref": "#/components/parameters/AiPlansidOrganization"
1339
+ },
1340
+ {
1341
+ "$ref": "#/components/parameters/AiPlansidAiPlan"
1342
+ }
1343
+ ],
1344
+ "responses": {
1345
+ "200": {
1346
+ "description": "Successful operation. Returns the plan without `archived_date`.",
1347
+ "content": {
1348
+ "application/json": {
1349
+ "schema": {
1350
+ "$ref": "#/components/schemas/AiPlansAiPlanOne"
1351
+ }
1352
+ }
1353
+ }
1354
+ },
1355
+ "400": {
1356
+ "description": "The request failed. The body carries the PlanVortex error code in `code`:\n\n| Code | Meaning |\n| --- | --- |\n| `1101` | Invalid organization |\n| `2100` | AI plan doesn't exist. The identifier is invalid or doesn't exist. |\n| `521` | Invalid client. The identifier is invalid or doesn't exist |",
1357
+ "content": {
1358
+ "application/json": {
1359
+ "schema": {
1360
+ "$ref": "#/components/schemas/Error"
1361
+ }
1362
+ }
1363
+ }
1364
+ },
1365
+ "500": {
1366
+ "description": "Unhandled error by the server"
1367
+ }
1368
+ }
1369
+ }
1370
+ },
1261
1371
  "/clients/{id_client}/organizations/{id_organization}/ai_plans/{id_ai_plan}/publications/{id_publication}/regenerate": {
1262
1372
  "post": {
1263
1373
  "tags": [
@@ -1762,7 +1872,8 @@
1762
1872
  "google_business",
1763
1873
  "bluesky",
1764
1874
  "discord",
1765
- "telegram"
1875
+ "telegram",
1876
+ "threads"
1766
1877
  ]
1767
1878
  }
1768
1879
  }
@@ -3487,7 +3598,7 @@
3487
3598
  "comments"
3488
3599
  ],
3489
3600
  "summary": "The inbox: first-level comments across the whole organization",
3490
- "description": "Served from PlanVortex's database, so it costs nothing and calls no social network. It is a **snapshot**: `collected_date` says when each row was last read. Open a thread to see what the network says right now.\n\nOrdered by `creation_date` descending — the date on the network, not the date we collected it — so rows from nine networks interleave correctly.\n\n**On `telegram` the inbox starts the day the channel was connected.** The Bot API has no way to read the past: a bot only learns what happens while it is inside, so nothing written before the connection exists here and never will. Say so in your UI — an inbox that opens empty on a busy channel reads like a failure.\n\n**Your own replies are not in here.** Anything with `author.is_own: true` is filtered out: what you wrote is not incoming mail. They are still stored, and they do show up in the thread.\n\nRequires the `comments:read` permission (`client_organization_comments:read` for apps) and a paid plan.",
3601
+ "description": "Served from PlanVortex's database, so it costs nothing and calls no social network. It is a **snapshot**: `collected_date` says when each row was last read. Open a thread to see what the network says right now.\n\nOrdered by `creation_date` descending — the date on the network, not the date we collected it — so rows from ten networks interleave correctly.\n\n**On `telegram` the inbox starts the day the channel was connected.** The Bot API has no way to read the past: a bot only learns what happens while it is inside, so nothing written before the connection exists here and never will. Say so in your UI — an inbox that opens empty on a busy channel reads like a failure.\n\n**Your own replies are not in here.** Anything with `author.is_own: true` is filtered out: what you wrote is not incoming mail. They are still stored, and they do show up in the thread.\n\nRequires the `comments:read` permission (`client_organization_comments:read` for apps) and a paid plan.",
3491
3602
  "operationId": "getComments",
3492
3603
  "x-planvortex-identity": [
3493
3604
  "current_user",
@@ -3976,7 +4087,7 @@
3976
4087
  "catalog"
3977
4088
  ],
3978
4089
  "summary": "What every network supports",
3979
- "description": "The full matrix network → capabilities. It is the **single source of truth** about what a network can do: the catalogue publishes it, the backend checks it before acting, and your integration should filter its account pickers with it rather than keeping a list of its own.\n\n`comments` is the coarse gate — whether the network has comments at all. Which *actions* it allows on one is a finer question and lives in `GET /social_comment_actions`, because the shape here is `{[capability]: boolean}` and nesting an object inside would break it.\n\nToday nine networks answer `comments: true`: Facebook, Instagram, LinkedIn, X, YouTube, Google Business, Bluesky, Discord and Telegram. TikTok and WhatsApp answer `false`, for reasons of theirs and not ours.\n\nA `true` here is about the **network**, not about one account of it: a Telegram channel with no linked discussion group answers 965 on its comments even though the network has them.\n\nOnly needs authentication.",
4090
+ "description": "The full matrix network → capabilities. It is the **single source of truth** about what a network can do: the catalogue publishes it, the backend checks it before acting, and your integration should filter its account pickers with it rather than keeping a list of its own.\n\n`comments` is the coarse gate — whether the network has comments at all. Which *actions* it allows on one is a finer question and lives in `GET /social_comment_actions`, because the shape here is `{[capability]: boolean}` and nesting an object inside would break it.\n\nToday ten networks answer `comments: true`: Facebook, Instagram, Threads, LinkedIn, X, YouTube, Google Business, Bluesky, Discord and Telegram. TikTok and WhatsApp answer `false`, for reasons of theirs and not ours.\n\nA `true` here is about the **network**, not about one account of it: a Telegram channel with no linked discussion group answers 965 on its comments even though the network has them.\n\nOnly needs authentication.",
3980
4091
  "operationId": "getSocialCapabilities",
3981
4092
  "x-planvortex-identity": [
3982
4093
  "current_user",
@@ -4034,7 +4145,7 @@
4034
4145
  "catalog"
4035
4146
  ],
4036
4147
  "summary": "What each network lets you do to a comment",
4037
- "description": "The fine-grained matrix: reply, hide, delete your own, delete somebody else's — network by network. Read it before painting controls. Networks without comments answer `false` to all four.\n\nThe differences are real and each has a reason in the network's own API:\n\n| Network | reply | hide | delete_own | delete_others |\n| --- | --- | --- | --- | --- |\n| `facebook` | yes | yes | yes | yes |\n| `instagram` | yes | yes | yes | **no** — Instagram only lets you hide someone else's |\n| `youtube` | yes | yes | yes | yes — the channel owner really does moderate |\n| `linkedin` | yes | **no** — there is no hide endpoint | yes | yes |\n| `twitter` | yes | yes | yes | **no** — you cannot delete another account's post |\n| `google_business` | yes | **no** | yes — **your reply**, never the review | **no** |\n| `bluesky` | yes | yes — through the post's `threadgate` | yes | **no** — the reply lives in somebody else's repository |\n| `discord` | yes | **no** — Discord has no hide, only delete | yes | yes |\n| `telegram` | yes | **no** — Telegram has no hide either | yes | yes — with the bot as an admin of the discussion group, otherwise error 969 |\n| `tiktok`, `whatsapp` | no | no | no | no |\n\nOnly needs authentication.",
4148
+ "description": "The fine-grained matrix: reply, hide, delete your own, delete somebody else's — network by network. Read it before painting controls. Networks without comments answer `false` to all four.\n\nThe differences are real and each has a reason in the network's own API:\n\n| Network | reply | hide | delete_own | delete_others |\n| --- | --- | --- | --- | --- |\n| `facebook` | yes | yes | yes | yes |\n| `instagram` | yes | yes | yes | **no** — Instagram only lets you hide someone else's |\n| `youtube` | yes | yes | yes | yes — the channel owner really does moderate |\n| `linkedin` | yes | **no** — there is no hide endpoint | yes | yes |\n| `twitter` | yes | yes | yes | **no** — you cannot delete another account's post |\n| `google_business` | yes | **no** | yes — **your reply**, never the review | **no** |\n| `bluesky` | yes | yes — through the post's `threadgate` | yes | **no** — the reply lives in somebody else's repository |\n| `discord` | yes | **no** — Discord has no hide, only delete | yes | yes |\n| `telegram` | yes | **no** — Telegram has no hide either | yes | yes — with the bot as an admin of the discussion group, otherwise error 969 |\n| `threads` | yes | yes | yes | **no** — deleting somebody else's reply is not a permission you lack, it is a call that does not exist in the API |\n| `tiktok`, `whatsapp` | no | no | no | no |\n\nOnly needs authentication.",
4038
4149
  "operationId": "getSocialCommentActions",
4039
4150
  "x-planvortex-identity": [
4040
4151
  "current_user",
@@ -9400,7 +9511,7 @@
9400
9511
  "required": [
9401
9512
  "type"
9402
9513
  ],
9403
- "description": "**How** an account of this network is authorized, which is not always \"send the user to this URL\".\n\nNine of the eleven networks are `redirect`: open `link` and the network sends the person back to PlanVortex with a code. Two are not:\n\n• **WhatsApp.** Its sign-up is Meta's *Embedded Signup*: a popup raised by the Facebook JavaScript SDK from your own page, which returns — over `postMessage` — session data (`waba_id`, `phone_number_id`) that no query string carries. Its `link` is therefore an empty string.\n• **Telegram.** There is no OAuth here: no consent screen, no `code`, no account token. `link` opens a private chat with the PlanVortex bot, the person then adds that bot to their channel, and **the account is created from that event**, not from any request of yours. Which means the connection cannot be finished by calling `GET /organizations/{id_organization}/account-connect/telegram` — see that endpoint.\n\nBranch on `authorization.type`, never on whether `link` is empty.",
9514
+ "description": "**How** an account of this network is authorized, which is not always \"send the user to this URL\".\n\nTen of the twelve networks are `redirect`: open `link` and the network sends the person back to PlanVortex with a code. Two are not:\n\n• **WhatsApp.** Its sign-up is Meta's *Embedded Signup*: a popup raised by the Facebook JavaScript SDK from your own page, which returns — over `postMessage` — session data (`waba_id`, `phone_number_id`) that no query string carries. Its `link` is therefore an empty string.\n• **Telegram.** There is no OAuth here: no consent screen, no `code`, no account token. `link` opens a private chat with the PlanVortex bot, the person then adds that bot to their channel, and **the account is created from that event**, not from any request of yours. Which means the connection cannot be finished by calling `GET /organizations/{id_organization}/account-connect/telegram` — see that endpoint.\n\nBranch on `authorization.type`, never on whether `link` is empty.",
9404
9515
  "properties": {
9405
9516
  "type": {
9406
9517
  "type": "string",
@@ -9597,6 +9708,11 @@
9597
9708
  ],
9598
9709
  "description": "State machine: pending -> generating -> generated -> validated | failed | cancelled. Poll the plan while state is pending or generating."
9599
9710
  },
9711
+ "archived_date": {
9712
+ "type": "string",
9713
+ "format": "date-time",
9714
+ "description": "When the plan was archived. Absent means it is active, which is how every plan created before archiving existed comes back.\n\nIt is a field of its own and NOT a value of `state` on purpose: `state` is the generation lifecycle and archiving is orthogonal to it — a `validated` plan that already published gets archived just like a `failed` one. Inside the enum you would have to decide which state it returns to when unarchived, and that question has no answer.\n\nArchiving is visibility only: it takes the plan out of the default listing and touches no publication. Anything it had scheduled keeps publishing."
9715
+ },
9600
9716
  "orchestrator_result": {
9601
9717
  "type": "object",
9602
9718
  "description": "Raw plan returned by the orchestrator, kept for audit (before validation/trimming)."
@@ -10399,7 +10515,7 @@
10399
10515
  "$ref": "#/components/schemas/CatalogSocialLimitsMap"
10400
10516
  }
10401
10517
  ],
10402
- "description": "Maximum length of a publication's text. Bluesky counts graphemes, everyone else counts characters — Telegram included, where `String.length` is exactly the right unit.\n\nOn Telegram there are **two numbers for the same field**: `telegram` (4.096) while the publication is text only, and `telegram_media` (1.024) the moment it carries an image or a video, because then the text is a media caption and not a message. Switch the counter when the file is attached, not when publish is pressed."
10518
+ "description": "Maximum length of a publication's text. **Bluesky and Threads count GRAPHEMES**, everyone else counts characters — Telegram included, where `String.length` is exactly the right unit. The difference is not academic: an emoji is one grapheme and two `String.length` units, so counting a Threads post with `.length` rejects at 250 emojis what the network publishes happily at 500.\n\nOn Telegram there are **two numbers for the same field**: `telegram` (4.096) while the publication is text only, and `telegram_media` (1.024) the moment it carries an image or a video, because then the text is a media caption and not a message. Switch the counter when the file is attached, not when publish is pressed."
10403
10519
  },
10404
10520
  "max_post_bytes": {
10405
10521
  "allOf": [
@@ -11142,7 +11258,8 @@
11142
11258
  "google_business",
11143
11259
  "bluesky",
11144
11260
  "discord",
11145
- "telegram"
11261
+ "telegram",
11262
+ "threads"
11146
11263
  ]
11147
11264
  },
11148
11265
  "CommentsCommentThread": {
@@ -13610,7 +13727,7 @@
13610
13727
  },
13611
13728
  "PublicationStats": {
13612
13729
  "type": "object",
13613
- "description": "Raw, per-network metrics for a publication. Only the fields that belong to the publication's own social network are returned.\n\n**An absent field is not a zero.** A field is present only when the network actually reported it; `0` means the network measured zero. This matters most on X (Twitter), where metrics are split into groups with different access levels: `public_metrics` (likes, replys, retwets, quotes, bookmarks, impressions) is always available, while clicks, the pre-computed `engagement` and the video playback quartiles come from X's non-public metrics — only for your own posts, within 30 days of publishing, and only if the app is entitled to them. When they are unavailable they are omitted rather than returned as `0`. Do not default missing fields to zero when displaying them.\n\nOn `discord` there are only two: `likes` (the reactions on the message) and `comments` (the messages in its thread). There is no impressions figure anywhere in Discord's API, so engagement is computed over the server's member count.\n\nOn `bluesky` there are no impressions and no reach either — only the public counters — so engagement is computed over followers.\n\nOn `telegram` there are two as well, and **neither of them is asked for**: the Bot API has no method that returns a message's metrics, so `reactions` arrives on its own through the bot and `comments` is counted in PlanVortex's own inbox. There are no impressions, no reach, no views and no forwards to be had anywhere in it, so engagement is computed over followers.",
13730
+ "description": "Raw, per-network metrics for a publication. Only the fields that belong to the publication's own social network are returned.\n\n**An absent field is not a zero.** A field is present only when the network actually reported it; `0` means the network measured zero. This matters most on X (Twitter), where metrics are split into groups with different access levels: `public_metrics` (likes, replys, retwets, quotes, bookmarks, impressions) is always available, while clicks, the pre-computed `engagement` and the video playback quartiles come from X's non-public metrics — only for your own posts, within 30 days of publishing, and only if the app is entitled to them. When they are unavailable they are omitted rather than returned as `0`. Do not default missing fields to zero when displaying them.\n\nOn `discord` there are only two: `likes` (the reactions on the message) and `comments` (the messages in its thread). There is no impressions figure anywhere in Discord's API, so engagement is computed over the server's member count.\n\nOn `bluesky` there are no impressions and no reach either — only the public counters — so engagement is computed over followers.\n\nOn `threads` there are six, and the one that matters is `views`: it is the only one of the four newest networks with something like impressions, so its engagement rate is computed over a real base and not over followers. Sharing arrives split in three — `reposts`, `quotes` and `shares` (outside Threads) — and the three are added into one figure, the same criterion as on X; the breakdown stays in `raw`. A reply is what every other network calls a comment.\n\nOn `telegram` there are two as well, and **neither of them is asked for**: the Bot API has no method that returns a message's metrics, so `reactions` arrives on its own through the bot and `comments` is counted in PlanVortex's own inbox. There are no impressions, no reach, no views and no forwards to be had anywhere in it, so engagement is computed over followers.",
13614
13731
  "properties": {
13615
13732
  "likes": {
13616
13733
  "type": "integer"
@@ -13749,7 +13866,8 @@
13749
13866
  "youtube",
13750
13867
  "bluesky",
13751
13868
  "discord",
13752
- "telegram"
13869
+ "telegram",
13870
+ "threads"
13753
13871
  ],
13754
13872
  "description": "Network the publication targets. **Required when creating**: the request fails with error 702 if it is missing or not one of these values. It must match the network of the account in the path.\n\nNot every connectable network publishes — a local business listing receives reviews, not posts — so this list is shorter than the one in `GET /social_networks`. Ask `GET /allowed_social_publications` rather than hardcoding it, because it grows."
13755
13873
  },
@@ -14092,7 +14210,8 @@
14092
14210
  "google_business",
14093
14211
  "bluesky",
14094
14212
  "discord",
14095
- "telegram"
14213
+ "telegram",
14214
+ "threads"
14096
14215
  ],
14097
14216
  "type": "string",
14098
14217
  "description": "A social network supported by PlanVortex.\n\n**This list grows.** Treat it as an open enumeration: a client that rejects an unknown value breaks the day a network is added, which happens several times a year. Not every network does everything — ask `GET /social_capabilities`."
@@ -9,7 +9,7 @@ name = "planvortex"
9
9
  # entonces `project.version` desapareceria del pyproject y el paso del workflow de release que
10
10
  # compara el tag con la version —que lo lee con `tomllib`— dejaria de encontrarla. Se quedan las dos
11
11
  # y las ata `tests/test_packaging.py`, que falla si se separan.
12
- version = "0.3.0"
12
+ version = "0.5.0"
13
13
  description = "Official Python client for the PlanVortex API"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.10"
@@ -137,7 +137,7 @@ class AccountsSocialAuthorizationMethod(TypedDict):
137
137
  """
138
138
  **How** an account of this network is authorized, which is not always "send the user to this URL".
139
139
 
140
- Nine of the eleven networks are `redirect`: open `link` and the network sends the person back to PlanVortex with a code. Two are not:
140
+ Ten of the twelve networks are `redirect`: open `link` and the network sends the person back to PlanVortex with a code. Two are not:
141
141
 
142
142
  • **WhatsApp.** Its sign-up is Meta's *Embedded Signup*: a popup raised by the Facebook JavaScript SDK from your own page, which returns — over `postMessage` — session data (`waba_id`, `phone_number_id`) that no query string carries. Its `link` is therefore an empty string.
143
143
  • **Telegram.** There is no OAuth here: no consent screen, no `code`, no account token. `link` opens a private chat with the PlanVortex bot, the person then adds that bot to their channel, and **the account is created from that event**, not from any request of yours. Which means the connection cannot be finished by calling `GET /organizations/{id_organization}/account-connect/telegram` — see that endpoint.
@@ -855,6 +855,7 @@ CommentsCommentNetworkName: TypeAlias = Literal[
855
855
  "bluesky",
856
856
  "discord",
857
857
  "telegram",
858
+ "threads",
858
859
  ]
859
860
  """
860
861
  A network that has comments. Treat it as an open list: a new one is added before your integration hears about it.
@@ -1668,6 +1669,8 @@ class PublicationStats(TypedDict):
1668
1669
 
1669
1670
  On `bluesky` there are no impressions and no reach either — only the public counters — so engagement is computed over followers.
1670
1671
 
1672
+ On `threads` there are six, and the one that matters is `views`: it is the only one of the four newest networks with something like impressions, so its engagement rate is computed over a real base and not over followers. Sharing arrives split in three — `reposts`, `quotes` and `shares` (outside Threads) — and the three are added into one figure, the same criterion as on X; the breakdown stays in `raw`. A reply is what every other network calls a comment.
1673
+
1671
1674
  On `telegram` there are two as well, and **neither of them is asked for**: the Bot API has no method that returns a message's metrics, so `reactions` arrives on its own through the bot and `comments` is counted in PlanVortex's own inbox. There are no impressions, no reach, no views and no forwards to be had anywhere in it, so engagement is computed over followers.
1672
1675
  """
1673
1676
 
@@ -1758,6 +1761,7 @@ class PublicationsPublicationInput(TypedDict):
1758
1761
  "bluesky",
1759
1762
  "discord",
1760
1763
  "telegram",
1764
+ "threads",
1761
1765
  ]
1762
1766
  ]
1763
1767
  """
@@ -1907,6 +1911,7 @@ SocialNetwork: TypeAlias = Literal[
1907
1911
  "bluesky",
1908
1912
  "discord",
1909
1913
  "telegram",
1914
+ "threads",
1910
1915
  ]
1911
1916
  """
1912
1917
  A social network supported by PlanVortex.
@@ -2175,7 +2180,7 @@ class CatalogPlannerTemplate(TypedDict):
2175
2180
  class CatalogSocialLimits(TypedDict):
2176
2181
  characters: CatalogSocialLimitsMap
2177
2182
  """
2178
- Maximum length of a publication's text. Bluesky counts graphemes, everyone else counts characters — Telegram included, where `String.length` is exactly the right unit.
2183
+ Maximum length of a publication's text. **Bluesky and Threads count GRAPHEMES**, everyone else counts characters — Telegram included, where `String.length` is exactly the right unit. The difference is not academic: an emoji is one grapheme and two `String.length` units, so counting a Threads post with `.length` rejects at 250 emojis what the network publishes happily at 500.
2179
2184
 
2180
2185
  On Telegram there are **two numbers for the same field**: `telegram` (4.096) while the publication is text only, and `telegram_media` (1.024) the moment it carries an image or a video, because then the text is a media caption and not a message. Switch the counter when the file is attached, not when publish is pressed.
2181
2186
  """
@@ -2872,6 +2877,14 @@ class AiPlansAiPlan(TypedDict):
2872
2877
  """
2873
2878
  State machine: pending -> generating -> generated -> validated | failed | cancelled. Poll the plan while state is pending or generating.
2874
2879
  """
2880
+ archived_date: NotRequired[str]
2881
+ """
2882
+ When the plan was archived. Absent means it is active, which is how every plan created before archiving existed comes back.
2883
+
2884
+ It is a field of its own and NOT a value of `state` on purpose: `state` is the generation lifecycle and archiving is orthogonal to it — a `validated` plan that already published gets archived just like a `failed` one. Inside the enum you would have to decide which state it returns to when unarchived, and that question has no answer.
2885
+
2886
+ Archiving is visibility only: it takes the plan out of the default listing and touches no publication. Anything it had scheduled keeps publishing.
2887
+ """
2875
2888
  orchestrator_result: NotRequired[dict[str, Any]]
2876
2889
  """
2877
2890
  Raw plan returned by the orchestrator, kept for audit (before validation/trimming).
@@ -137,7 +137,7 @@ class AccountUpdate(TypedDict):
137
137
 
138
138
 
139
139
  class RedirectAuthorization(TypedDict):
140
- """Send the person to the entry's ``link``. Nine of the eleven networks.
140
+ """Send the person to the entry's ``link``. Ten of the twelve networks.
141
141
 
142
142
  One of the THREE halves of the ``authorization`` block of a connection link. The generated type
143
143
  is a single flat ``TypedDict`` with every field ``NotRequired``, because that is the only way
@@ -255,6 +255,7 @@ class EnableResult(TypedDict):
255
255
  PublishableNetwork: TypeAlias = Literal[
256
256
  "facebook",
257
257
  "instagram",
258
+ "threads",
258
259
  "twitter",
259
260
  "linkedin",
260
261
  "tiktok",
@@ -264,12 +265,12 @@ PublishableNetwork: TypeAlias = Literal[
264
265
  "discord",
265
266
  "telegram",
266
267
  ]
267
- """A network that accepts publications: the ten of the eleven that have a feed.
268
+ """A network that accepts publications: the eleven of the twelve that have a feed.
268
269
 
269
270
  ``google_business`` is the one missing, and it is not an oversight: a local listing receives
270
271
  reviews, not posts. Sending it raises error 702. It is a **narrower** type than
271
272
  ``types.SocialNetwork`` and that is the whole point — an account's ``social_network`` is one of
272
- eleven and this is one of ten, so handing one straight to the other is a type error even when a
273
+ twelve and this is one of eleven, so handing one straight to the other is a type error even when a
273
274
  ``capability="publications"`` filter has already made it impossible at runtime.
274
275
  ``types.is_publishable_network`` is what bridges it.
275
276
 
@@ -12,7 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import platform
14
14
 
15
- VERSION = "0.3.0"
15
+ VERSION = "0.5.0"
16
16
 
17
17
  # Sin barra final, y con el `/v1.0.0` dentro: la version de la API es parte de la ruta, no una
18
18
  # cabecera, asi que una `base_url` sin ella da 404 en todo.
@@ -56,7 +56,7 @@ class AsyncAccountsResource(AsyncResource):
56
56
  rather than a failure: it is what happens to Discord in an organization that has not saved
57
57
  its own bot credentials yet.
58
58
 
59
- **LOOK AT ``authorization``, NOT AT WHETHER ``link`` IS EMPTY.** Nine of the eleven networks
59
+ **LOOK AT ``authorization``, NOT AT WHETHER ``link`` IS EMPTY.** Ten of the twelve networks
60
60
  are ``redirect`` and the person is sent to ``link``. The other two are not, and neither of them
61
61
  fails visibly if you walk the list redirecting to ``link``:
62
62
 
@@ -22,7 +22,13 @@ AND WHAT SURPRISES PEOPLE:
22
22
  so Monday/Wednesday/Friday with 3 accounts is 9 publications, not 21.
23
23
  - **These routes hang off the CLIENT**, not only off the organization: they carry both identifiers.
24
24
  - **Deleting is cancelling.** The plan moves to ``cancelled`` and stops appearing in the listing, but
25
- :meth:`AsyncAiPlansResource.get` still returns it.
25
+ :meth:`AsyncAiPlansResource.get` still returns it. What does go for good are its publications that
26
+ had not gone out yet: the drafts and, if the plan was already validated, whatever was still
27
+ scheduled.
28
+ - **Archiving is NOT deleting, and it is the action people are usually after.**
29
+ :meth:`AsyncAiPlansResource.archive` takes the plan out of the listing and touches no publication
30
+ — anything scheduled keeps publishing — works in any state and can be undone. The archived ones
31
+ are read with ``list(..., archived=True)``, never next to the active ones.
26
32
 
27
33
  THE TEMPLATES: WHAT THE PLAN IS GENERATED FROM
28
34
 
@@ -67,7 +73,7 @@ from collections.abc import AsyncIterator
67
73
 
68
74
  from planvortex._core.pagination import Page, PageParams
69
75
  from planvortex._shapes import AiPlanRegenerateResult
70
- from planvortex.resources.base import AsyncResource, require_id
76
+ from planvortex.resources.base import AsyncResource, Query, require_id
71
77
  from planvortex.types import AiPlan, AiPlanCreateRequest, AiPlanCreateResult
72
78
 
73
79
 
@@ -166,17 +172,27 @@ class AsyncAiPlansResource(AsyncResource):
166
172
  *,
167
173
  limit: int | None = None,
168
174
  offset: int | None = None,
175
+ archived: bool | None = None,
169
176
  timeout: float | None = None,
170
177
  ) -> Page[AiPlan]:
171
- """The organization's plans, from the newest to the oldest.
178
+ """The organization's ACTIVE plans, from the newest to the oldest.
172
179
 
173
- **The cancelled ones do not show up**, and here ``publications`` are identifiers, not the
174
- whole publications: for that there is :meth:`get`. With no ``limit`` they all come back.
180
+ **The cancelled ones do not show up** — nor the archived ones, which are asked for with
181
+ ``archived=True`` — and here ``publications`` are identifiers, not the whole publications:
182
+ for that there is :meth:`get`. With no ``limit`` they all come back.
183
+
184
+ ``archived`` opens the other cupboard, never both at once: archiving moves a plan somewhere
185
+ else, it does not put a label on it and leave it where it was.
186
+
187
+ .. code-block:: python
188
+
189
+ activos = await pv.ai_plans.list(client_id, org_id)
190
+ guardados = await pv.ai_plans.list(client_id, org_id, archived=True)
175
191
  """
176
192
  pagina: Page[AiPlan] = await self._list(
177
193
  self._path(id_client, id_organization),
178
194
  "ai_plans",
179
- {"limit": limit, "offset": offset},
195
+ self._list_query(limit, offset, archived),
180
196
  timeout=timeout,
181
197
  )
182
198
  return pagina
@@ -188,6 +204,7 @@ class AsyncAiPlansResource(AsyncResource):
188
204
  *,
189
205
  limit: int | None = None,
190
206
  offset: int | None = None,
207
+ archived: bool | None = None,
191
208
  timeout: float | None = None,
192
209
  ) -> AsyncIterator[AiPlan]:
193
210
  """The organization's plans, chaining pages."""
@@ -198,6 +215,7 @@ class AsyncAiPlansResource(AsyncResource):
198
215
  id_organization,
199
216
  limit=params.limit,
200
217
  offset=params.offset,
218
+ archived=archived,
201
219
  timeout=timeout,
202
220
  )
203
221
 
@@ -274,6 +292,47 @@ class AsyncAiPlansResource(AsyncResource):
274
292
  resultado: AiPlanRegenerateResult = await self._post(ruta, {"target": target}, timeout=timeout)
275
293
  return resultado
276
294
 
295
+ async def archive(
296
+ self,
297
+ id_client: str,
298
+ id_organization: str,
299
+ id_ai_plan: str,
300
+ *,
301
+ timeout: float | None = None,
302
+ ) -> AiPlan:
303
+ """Archive the plan: it leaves the listing and moves to the archived one
304
+ (``list(..., archived=True)``).
305
+
306
+ It is **visibility only**. No publication is touched — anything scheduled keeps publishing —
307
+ no credits are refunded and nothing is cancelled, so it works in ANY state, ``generating``
308
+ included: it does not interrupt the job. Undone with :meth:`unarchive`.
309
+
310
+ It is what people are after almost every time they think of "removing" a plan: :meth:`remove`
311
+ takes the publications that have not gone out with it, and this does not.
312
+ """
313
+ plan: AiPlan = await self._post_one(
314
+ f"{self._one_path(id_client, id_organization, id_ai_plan)}/archive",
315
+ "ai_plan",
316
+ timeout=timeout,
317
+ )
318
+ return plan
319
+
320
+ async def unarchive(
321
+ self,
322
+ id_client: str,
323
+ id_organization: str,
324
+ id_ai_plan: str,
325
+ *,
326
+ timeout: float | None = None,
327
+ ) -> AiPlan:
328
+ """Put the plan back in the active listing. On a plan that was not archived it does nothing."""
329
+ plan: AiPlan = await self._post_one(
330
+ f"{self._one_path(id_client, id_organization, id_ai_plan)}/unarchive",
331
+ "ai_plan",
332
+ timeout=timeout,
333
+ )
334
+ return plan
335
+
277
336
  async def remove(
278
337
  self,
279
338
  id_client: str,
@@ -282,11 +341,28 @@ class AsyncAiPlansResource(AsyncResource):
282
341
  *,
283
342
  timeout: float | None = None,
284
343
  ) -> None:
285
- """Cancel the plan. **It does not delete it**: it moves to ``cancelled``, disappears from
286
- :meth:`list` and :meth:`get` still returns it.
344
+ """Delete the plan **and its publications that have not gone out yet**: the generated drafts
345
+ and, if it had already been validated, whatever was still scheduled. The already published
346
+ ones stay — deleting them here would not take them off the network, it would only lose their
347
+ history — and neither is the one being published at that very moment touched.
348
+
349
+ The plan itself is not wiped: it moves to ``cancelled``, disappears from :meth:`list` and
350
+ :meth:`get` still returns it. Spent credits are not refunded and a ``generating`` plan cannot
351
+ be deleted (2102): wait for the job to finish.
352
+
353
+ To stop seeing it without losing anything, :meth:`archive`.
287
354
  """
288
355
  await self._delete(self._one_path(id_client, id_organization, id_ai_plan), timeout=timeout)
289
356
 
357
+ def _list_query(self, limit: int | None, offset: int | None, archived: bool | None) -> Query:
358
+ return {
359
+ "limit": limit,
360
+ "offset": offset,
361
+ # El servidor enciende el filtro con el literal "true": mandar `archived=false` pediria
362
+ # lo mismo que no mandar nada, asi que un `False` se omite en vez de viajar como ruido.
363
+ "archived": True if archived else None,
364
+ }
365
+
290
366
  def _path(self, id_client: str, id_organization: str) -> str:
291
367
  cliente = require_id(id_client, "id_client")
292
368
  organizacion = require_id(id_organization, "id_organization")