Skip to main content

omni_dev/atlassian/
confluence_types.rs

1//! Confluence Cloud REST API wire types (DTOs).
2//!
3//! Response and request data-transfer objects for the Confluence endpoints
4//! served by [`crate::atlassian::client::AtlassianClient`] and
5//! [`crate::atlassian::confluence_api::ConfluenceApi`]. Split out of `client.rs`
6//! and `confluence_api.rs` (see issue #1156) so those modules hold only
7//! transport logic.
8
9use anyhow::{Context, Result};
10use serde::{Deserialize, Serialize};
11
12/// A single hit from a Confluence CQL search (`GET /wiki/rest/api/content/search`).
13///
14/// See [`ConfluenceSearchResults`] for the paginated wrapper.
15#[derive(Debug, Clone, Serialize)]
16pub struct ConfluenceSearchResult {
17    /// Page ID.
18    pub id: String,
19    /// Page title.
20    pub title: String,
21    /// Space key (e.g., "ENG").
22    pub space_key: String,
23}
24
25/// Paginated wrapper around [`ConfluenceSearchResult`] hits from
26/// `GET /wiki/rest/api/content/search`.
27#[derive(Debug, Clone, Serialize)]
28pub struct ConfluenceSearchResults {
29    /// Matching pages.
30    pub results: Vec<ConfluenceSearchResult>,
31    /// Total number of matching results.
32    pub total: u32,
33}
34
35/// A single user hit from `GET /wiki/rest/api/search/user`.
36///
37/// See [`ConfluenceUserSearchResults`] for the paginated wrapper.
38#[derive(Debug, Clone, Serialize)]
39pub struct ConfluenceUserSearchResult {
40    /// Account ID (unique identifier). Absent for some user types such as
41    /// app users or deactivated users.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub account_id: Option<String>,
44    /// Display name.
45    pub display_name: String,
46    /// Email address.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub email: Option<String>,
49}
50
51/// Paginated wrapper around [`ConfluenceUserSearchResult`] hits from
52/// `GET /wiki/rest/api/search/user`.
53#[derive(Debug, Clone, Serialize)]
54pub struct ConfluenceUserSearchResults {
55    /// Matching users.
56    pub users: Vec<ConfluenceUserSearchResult>,
57    /// Total number of matching results.
58    pub total: u32,
59}
60
61/// A single user resolved by account ID via `GET /wiki/rest/api/user?accountId=`.
62///
63/// The Confluence v1 user object does not report an `active` flag, so `active`
64/// is always `None` here. Failure-tolerant in the same way as
65/// [`JiraUserRecord`](crate::atlassian::jira_types::JiraUserRecord). See [`ConfluenceUserGetResults`] for the batch wrapper.
66#[derive(Debug, Clone, Serialize)]
67pub struct ConfluenceUserRecord {
68    /// Account ID (always present — echoed back even when the lookup failed).
69    pub account_id: String,
70    /// Display name (falls back to `publicName` when `displayName` is absent).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub display_name: Option<String>,
73    /// Email address. Absent unless the caller has permission to see it.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub email: Option<String>,
76    /// Account type, e.g. `"atlassian"`, `"app"`.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub account_type: Option<String>,
79    /// Always `None` — the Confluence v1 user endpoint does not return an
80    /// active flag. Present for parity with [`JiraUserRecord`](crate::atlassian::jira_types::JiraUserRecord).
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub active: Option<bool>,
83    /// Reason this ID could not be resolved (e.g. `"HTTP 404"`). Absent on
84    /// success.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub error: Option<String>,
87}
88
89/// Batch wrapper around [`ConfluenceUserRecord`] from resolving one or more
90/// account IDs via `GET /wiki/rest/api/user?accountId=`.
91#[derive(Debug, Clone, Serialize)]
92pub struct ConfluenceUserGetResults {
93    /// Resolved users, one per requested account ID (in request order).
94    pub users: Vec<ConfluenceUserRecord>,
95}
96
97#[derive(Deserialize)]
98#[allow(dead_code)]
99pub(crate) struct ConfluenceContentSearchResponse {
100    pub(crate) results: Vec<ConfluenceContentSearchEntry>,
101    #[serde(default)]
102    pub(crate) size: u32,
103    #[serde(rename = "_links", default)]
104    pub(crate) links: Option<ConfluenceSearchLinks>,
105}
106
107#[derive(Deserialize, Default)]
108pub(crate) struct ConfluenceSearchLinks {
109    pub(crate) next: Option<String>,
110}
111
112#[derive(Deserialize)]
113pub(crate) struct ConfluenceContentSearchEntry {
114    pub(crate) id: String,
115    pub(crate) title: String,
116    #[serde(rename = "_expandable")]
117    pub(crate) expandable: Option<ConfluenceExpandable>,
118}
119
120#[derive(Deserialize)]
121pub(crate) struct ConfluenceExpandable {
122    pub(crate) space: Option<String>,
123}
124
125// ── Confluence user search API response structs ───────────────────
126
127#[derive(Deserialize)]
128pub(crate) struct ConfluenceUserSearchResponse {
129    pub(crate) results: Vec<ConfluenceUserSearchEntry>,
130    #[serde(rename = "_links", default)]
131    pub(crate) links: Option<ConfluenceSearchLinks>,
132}
133
134#[derive(Deserialize)]
135pub(crate) struct ConfluenceUserSearchEntry {
136    #[serde(default)]
137    pub(crate) user: Option<ConfluenceSearchUser>,
138}
139
140#[derive(Deserialize)]
141pub(crate) struct ConfluenceSearchUser {
142    #[serde(rename = "accountId", default)]
143    pub(crate) account_id: Option<String>,
144    #[serde(rename = "displayName", default)]
145    pub(crate) display_name: Option<String>,
146    #[serde(default)]
147    pub(crate) email: Option<String>,
148    #[serde(rename = "publicName", default)]
149    pub(crate) public_name: Option<String>,
150}
151
152// ── Confluence user-get API response struct ───────────────────────
153
154/// Deserialization helper for `GET /wiki/rest/api/user?accountId=` — a bare
155/// user object (not wrapped in `results`). `publicName` is the fallback when
156/// `displayName` is unavailable; the v1 endpoint has no `active` flag.
157#[derive(Deserialize)]
158pub(crate) struct ConfluenceUserGetEntry {
159    #[serde(rename = "accountId", default)]
160    pub(crate) account_id: Option<String>,
161    #[serde(rename = "accountType", default)]
162    pub(crate) account_type: Option<String>,
163    #[serde(rename = "displayName", default)]
164    pub(crate) display_name: Option<String>,
165    #[serde(rename = "publicName", default)]
166    pub(crate) public_name: Option<String>,
167    #[serde(default)]
168    pub(crate) email: Option<String>,
169}
170
171// ── Internal API response structs ───────────────────────────────────
172
173#[derive(Deserialize)]
174pub(crate) struct ConfluencePageResponse {
175    pub(crate) id: String,
176    pub(crate) title: String,
177    pub(crate) status: String,
178    #[serde(rename = "spaceId")]
179    pub(crate) space_id: String,
180    pub(crate) version: Option<ConfluenceVersion>,
181    pub(crate) body: Option<ConfluenceBody>,
182    #[serde(rename = "parentId")]
183    pub(crate) parent_id: Option<String>,
184    #[serde(default)]
185    pub(crate) ancestors: Vec<ConfluenceAncestorEntry>,
186}
187
188#[derive(Deserialize)]
189pub(crate) struct ConfluenceAncestorEntry {
190    pub(crate) id: String,
191}
192
193#[derive(Deserialize)]
194pub(crate) struct ConfluenceVersion {
195    pub(crate) number: u32,
196}
197
198#[derive(Deserialize)]
199pub(crate) struct ConfluenceBody {
200    pub(crate) atlas_doc_format: Option<ConfluenceAtlasDoc>,
201}
202
203#[derive(Deserialize)]
204pub(crate) struct ConfluenceAtlasDoc {
205    pub(crate) value: String,
206}
207
208// ── Space lookup ────────────────────────────────────────────────────
209
210#[derive(Deserialize)]
211pub(crate) struct ConfluenceSpaceResponse {
212    pub(crate) key: String,
213}
214
215#[derive(Deserialize)]
216pub(crate) struct ConfluenceSpacesResponse {
217    pub(crate) results: Vec<ConfluenceSpaceEntry>,
218    #[serde(rename = "_links", default)]
219    pub(crate) links: Option<ConfluenceSpaceLinks>,
220}
221
222#[derive(Deserialize)]
223pub(crate) struct ConfluenceSpaceLinks {
224    pub(crate) next: Option<String>,
225}
226
227#[derive(Deserialize)]
228pub(crate) struct ConfluenceSpaceEntry {
229    pub(crate) id: String,
230    #[serde(default)]
231    pub(crate) key: Option<String>,
232    #[serde(default)]
233    pub(crate) name: Option<String>,
234    #[serde(rename = "type", default)]
235    pub(crate) type_: Option<String>,
236    #[serde(default)]
237    pub(crate) status: Option<String>,
238    #[serde(rename = "homepageId", default)]
239    pub(crate) homepage_id: Option<String>,
240}
241
242/// A Confluence space.
243#[derive(Debug, Clone, Serialize)]
244pub struct ConfluenceSpace {
245    /// Space ID.
246    pub id: String,
247    /// Space key (e.g. "ENG").
248    pub key: String,
249    /// Display name.
250    pub name: String,
251    /// Space type ("global", "personal", "collaboration", "knowledge_base").
252    #[serde(rename = "type")]
253    pub type_: String,
254    /// Status ("current" or "archived").
255    pub status: String,
256    /// Homepage page ID, when reported by the API.
257    #[serde(rename = "homepageId", skip_serializing_if = "Option::is_none")]
258    pub homepage_id: Option<String>,
259}
260
261impl From<ConfluenceSpaceEntry> for ConfluenceSpace {
262    fn from(e: ConfluenceSpaceEntry) -> Self {
263        Self {
264            id: e.id,
265            key: e.key.unwrap_or_default(),
266            name: e.name.unwrap_or_default(),
267            type_: e.type_.unwrap_or_default(),
268            status: e.status.unwrap_or_default(),
269            homepage_id: e.homepage_id,
270        }
271    }
272}
273
274/// A page of spaces returned by [`ConfluenceApi::list_spaces`](crate::atlassian::confluence_api::ConfluenceApi::list_spaces).
275///
276/// Pagination is *not* auto-drained: callers receive one page at a time and
277/// pass `next_cursor` back to fetch the next page. Mirrors the
278/// [`ConfluenceAttachmentPage`] shape so MCP/CLI callers can stream large
279/// space inventories without buffering everything in memory.
280#[derive(Debug, Clone, Serialize)]
281pub struct ConfluenceSpacePage {
282    /// Spaces on this page.
283    pub results: Vec<ConfluenceSpace>,
284    /// Opaque cursor for the next page, when present.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub next_cursor: Option<String>,
287}
288
289/// A summary record for a Confluence page in a space.
290#[derive(Debug, Clone, Serialize)]
291pub struct PageSummary {
292    /// Page ID.
293    pub id: String,
294    /// Page title.
295    pub title: String,
296    /// Page status (e.g. `current`, `archived`, `draft`, `trashed`).
297    #[serde(skip_serializing_if = "String::is_empty")]
298    pub status: String,
299    /// Parent page ID, when reported by the API.
300    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
301    pub parent_id: Option<String>,
302    /// Author account ID, when reported by the API.
303    #[serde(rename = "authorId", skip_serializing_if = "Option::is_none")]
304    pub author_id: Option<String>,
305    /// ISO 8601 creation timestamp, when reported by the API.
306    #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")]
307    pub created_at: Option<String>,
308}
309
310/// A page of [`PageSummary`] records returned by
311/// [`ConfluenceApi::list_space_pages`](crate::atlassian::confluence_api::ConfluenceApi::list_space_pages).
312///
313/// Pagination is *not* auto-drained: callers receive one page at a time and
314/// pass `next_cursor` back to fetch the next page. Spaces can contain
315/// thousands of pages, so we avoid buffering the whole inventory in memory.
316#[derive(Debug, Clone, Serialize)]
317pub struct PageSummaryPage {
318    /// Pages on this response.
319    pub results: Vec<PageSummary>,
320    /// Opaque cursor for the next page, when present.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub next_cursor: Option<String>,
323}
324
325// ── Children response ──────────────────────────────────────────────
326
327#[derive(Deserialize)]
328pub(crate) struct ConfluenceChildrenResponse {
329    pub(crate) results: Vec<ConfluenceChildEntry>,
330    #[serde(rename = "_links", default)]
331    pub(crate) links: Option<ConfluenceChildrenLinks>,
332}
333
334#[derive(Deserialize)]
335pub(crate) struct ConfluenceChildEntry {
336    pub(crate) id: String,
337    pub(crate) title: String,
338    #[serde(default)]
339    pub(crate) status: Option<String>,
340}
341
342#[derive(Deserialize)]
343pub(crate) struct ConfluenceChildrenLinks {
344    pub(crate) next: Option<String>,
345}
346
347// V2 space-pages response (for `depth=root`).
348#[derive(Deserialize)]
349pub(crate) struct ConfluenceSpacePagesResponse {
350    pub(crate) results: Vec<ConfluenceSpacePageEntry>,
351    #[serde(rename = "_links", default)]
352    pub(crate) links: Option<ConfluenceChildrenLinks>,
353}
354
355#[derive(Deserialize)]
356pub(crate) struct ConfluenceSpacePageEntry {
357    pub(crate) id: String,
358    pub(crate) title: String,
359    #[serde(default)]
360    pub(crate) status: Option<String>,
361    #[serde(rename = "parentId", default)]
362    pub(crate) parent_id: Option<String>,
363}
364
365// V2 space-pages response carrying author/createdAt for `list_space_pages`.
366// Kept separate from `ConfluenceSpacePagesResponse` to avoid widening that
367// type's contract (used by `get_space_root_pages`).
368#[derive(Deserialize)]
369pub(crate) struct ConfluenceSpacePagesSummaryResponse {
370    pub(crate) results: Vec<ConfluenceSpacePageSummaryEntry>,
371    #[serde(rename = "_links", default)]
372    pub(crate) links: Option<ConfluenceChildrenLinks>,
373}
374
375#[derive(Deserialize)]
376pub(crate) struct ConfluenceSpacePageSummaryEntry {
377    pub(crate) id: String,
378    pub(crate) title: String,
379    #[serde(default)]
380    pub(crate) status: Option<String>,
381    #[serde(rename = "parentId", default)]
382    pub(crate) parent_id: Option<String>,
383    #[serde(rename = "authorId", default)]
384    pub(crate) author_id: Option<String>,
385    #[serde(rename = "createdAt", default)]
386    pub(crate) created_at: Option<String>,
387}
388
389/// A child page returned from the children API.
390#[derive(Debug, Clone, serde::Serialize)]
391pub struct ChildPage {
392    /// Page ID.
393    pub id: String,
394    /// Page title.
395    pub title: String,
396    /// Page status (e.g. "current", "draft"). Empty if not provided by the API.
397    #[serde(default, skip_serializing_if = "String::is_empty")]
398    pub status: String,
399    /// Parent page ID, if known.
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub parent_id: Option<String>,
402    /// Space key, if known.
403    #[serde(default, skip_serializing_if = "Option::is_none")]
404    pub space_key: Option<String>,
405}
406
407// ── Comment types ─────────────────────────────────────────────────
408
409/// Distinguishes the two kinds of Confluence page comments.
410///
411/// Confluence v2 exposes footer comments (page-level discussion) and inline
412/// comments (anchored to a text selection) on separate endpoints. Tracking the
413/// kind on each [`ConfluenceComment`] lets a merged listing identify which
414/// endpoint each entry came from.
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
416#[serde(rename_all = "lowercase")]
417pub enum CommentKind {
418    /// A page-level footer comment.
419    Footer,
420    /// A comment anchored to a text selection in the page body.
421    Inline,
422}
423
424impl CommentKind {
425    /// Returns the URL segment Confluence v2 uses for this kind
426    /// (`"footer-comments"` or `"inline-comments"`).
427    #[must_use]
428    pub fn endpoint_segment(self) -> &'static str {
429        match self {
430            Self::Footer => "footer-comments",
431            Self::Inline => "inline-comments",
432        }
433    }
434}
435
436impl std::fmt::Display for CommentKind {
437    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
438        match self {
439            Self::Footer => f.write_str("footer"),
440            Self::Inline => f.write_str("inline"),
441        }
442    }
443}
444
445/// Anchor metadata required when creating an inline comment.
446///
447/// Confluence's `inline-comment-properties` payload identifies which text
448/// selection on the page the comment attaches to. `match_index` is 0-based;
449/// `match_count` is the total number of occurrences of `text` on the page.
450#[derive(Debug, Clone)]
451pub struct InlineAnchor {
452    /// The selected text the comment anchors to.
453    pub text: String,
454    /// 0-based index of which occurrence on the page this comment anchors to.
455    pub match_index: usize,
456    /// Total number of occurrences of `text` on the page.
457    pub match_count: usize,
458}
459
460/// A comment on a Confluence page.
461#[derive(Debug, Clone, Serialize)]
462pub struct ConfluenceComment {
463    /// Comment ID.
464    pub id: String,
465    /// Author display name.
466    pub author: String,
467    /// Whether this is a footer or inline comment.
468    pub kind: CommentKind,
469    /// Comment body as raw ADF JSON.
470    pub body_adf: Option<serde_json::Value>,
471    /// ISO 8601 creation timestamp.
472    pub created: String,
473    /// For inline comments: the `id` of the `annotation` mark this comment is
474    /// anchored to inside the page ADF (Confluence's `inlineMarkerRef`). `None`
475    /// for footer comments. Used to locate — and re-anchor — the highlighted run.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub inline_marker_ref: Option<String>,
478    /// For inline comments: the plaintext the reviewer originally highlighted
479    /// (Confluence's `inlineOriginalSelection`). This is durable — it does not
480    /// drift when the surrounding page text is edited — so it is the ground
481    /// truth for inline-comment drift auditing. `None` for footer comments.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub inline_original_selection: Option<String>,
484}
485
486#[derive(Deserialize)]
487pub(crate) struct ConfluenceCommentsResponse {
488    pub(crate) results: Vec<ConfluenceCommentEntry>,
489    #[serde(rename = "_links", default)]
490    pub(crate) links: Option<ConfluenceCommentsLinks>,
491}
492
493#[derive(Deserialize)]
494pub(crate) struct ConfluenceCommentsLinks {
495    pub(crate) next: Option<String>,
496}
497
498#[derive(Deserialize)]
499pub(crate) struct ConfluenceCommentEntry {
500    pub(crate) id: String,
501    #[serde(default)]
502    pub(crate) version: Option<ConfluenceCommentVersion>,
503    #[serde(default)]
504    pub(crate) body: Option<ConfluenceCommentBody>,
505    /// Inline-comment anchor metadata. Present on the v2 `inline-comments`
506    /// endpoint regardless of `body-format`; absent for footer comments.
507    #[serde(default)]
508    pub(crate) properties: Option<ConfluenceInlineCommentProperties>,
509}
510
511/// The `properties` object on an inline-comment entry. Both fields are optional
512/// so we deserialize tolerantly across footer comments (no `properties`) and any
513/// future shape changes.
514#[derive(Deserialize)]
515pub(crate) struct ConfluenceInlineCommentProperties {
516    /// The `annotation`-mark `id` this comment is anchored to in the page ADF.
517    #[serde(rename = "inlineMarkerRef", default)]
518    pub(crate) inline_marker_ref: Option<String>,
519    /// The plaintext the reviewer highlighted when the comment was posted.
520    #[serde(rename = "inlineOriginalSelection", default)]
521    pub(crate) inline_original_selection: Option<String>,
522}
523
524#[derive(Deserialize)]
525pub(crate) struct ConfluenceCommentVersion {
526    #[serde(rename = "authorId", default)]
527    pub(crate) author_id: Option<String>,
528    #[serde(rename = "createdAt", default)]
529    pub(crate) created_at: Option<String>,
530    /// Monotonic version number. Absent from list responses we ignore it on;
531    /// present when a single comment is fetched to bump for an update.
532    #[serde(default)]
533    pub(crate) number: Option<u32>,
534}
535
536#[derive(Deserialize)]
537pub(crate) struct ConfluenceCommentBody {
538    pub(crate) atlas_doc_format: Option<ConfluenceAtlasDoc>,
539}
540
541#[derive(Serialize)]
542pub(crate) struct ConfluenceAddCommentRequest {
543    #[serde(rename = "pageId")]
544    pub(crate) page_id: String,
545    pub(crate) body: ConfluenceUpdateBody,
546}
547
548#[derive(Serialize)]
549pub(crate) struct ConfluenceAddInlineCommentRequest {
550    #[serde(rename = "pageId")]
551    pub(crate) page_id: String,
552    pub(crate) body: ConfluenceUpdateBody,
553    #[serde(rename = "inlineCommentProperties")]
554    pub(crate) inline_comment_properties: InlineCommentProperties,
555}
556
557#[derive(Serialize)]
558pub(crate) struct InlineCommentProperties {
559    #[serde(rename = "textSelection")]
560    pub(crate) text_selection: String,
561    #[serde(rename = "textSelectionMatchCount")]
562    pub(crate) text_selection_match_count: usize,
563    #[serde(rename = "textSelectionMatchIndex")]
564    pub(crate) text_selection_match_index: usize,
565}
566
567/// Request body for `PUT /wiki/api/v2/{footer|inline}-comments/{id}` — used by
568/// both comment edit and inline-comment resolve/reopen. `resolved` is only sent
569/// for inline comments (footer comments cannot be resolved), so it is omitted
570/// when `None`.
571#[derive(Serialize)]
572pub(crate) struct ConfluenceUpdateCommentRequest {
573    pub(crate) version: ConfluenceUpdateVersion,
574    pub(crate) body: ConfluenceUpdateBody,
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub(crate) resolved: Option<bool>,
577}
578
579/// Minimal read of a single comment (`GET /wiki/api/v2/{segment}/{id}`) — just
580/// enough to learn its current `version.number` (required to build the next
581/// version on update) and its current ADF body (needed to re-send an unchanged
582/// body when only toggling the `resolved` flag). Reuses the shared
583/// [`ConfluenceCommentVersion`] / [`ConfluenceCommentBody`] shapes.
584#[derive(Deserialize)]
585pub(crate) struct ConfluenceCommentDetail {
586    #[serde(default)]
587    pub(crate) version: Option<ConfluenceCommentVersion>,
588    #[serde(default)]
589    pub(crate) body: Option<ConfluenceCommentBody>,
590}
591
592/// Response of `GET /wiki/rest/api/user/watch/content/{id}` — whether the
593/// (current or specified) user is watching the content.
594#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct ConfluenceWatchStatus {
596    /// Whether the user is watching the content.
597    #[serde(default)]
598    pub watching: bool,
599}
600
601// ── Labels ─────────────────────────────────────────────────────────
602
603#[derive(Deserialize)]
604pub(crate) struct ConfluenceLabelsResponse {
605    pub(crate) results: Vec<ConfluenceLabelEntry>,
606    #[serde(rename = "_links", default)]
607    pub(crate) links: Option<ConfluenceLabelsLinks>,
608}
609
610#[derive(Deserialize)]
611pub(crate) struct ConfluenceLabelEntry {
612    pub(crate) id: String,
613    pub(crate) name: String,
614    pub(crate) prefix: String,
615}
616
617#[derive(Deserialize)]
618pub(crate) struct ConfluenceLabelsLinks {
619    pub(crate) next: Option<String>,
620}
621
622/// A label on a Confluence page.
623#[derive(Debug, Clone, Serialize)]
624pub struct ConfluenceLabel {
625    /// Label ID.
626    pub id: String,
627    /// Label name.
628    pub name: String,
629    /// Label prefix (e.g. "global").
630    pub prefix: String,
631}
632
633#[derive(Serialize)]
634pub(crate) struct ConfluenceAddLabelEntry {
635    pub(crate) prefix: String,
636    pub(crate) name: String,
637}
638
639// ── Versions ───────────────────────────────────────────────────────
640
641#[derive(Deserialize)]
642pub(crate) struct ConfluenceVersionsResponse {
643    pub(crate) results: Vec<ConfluenceVersionEntry>,
644    #[serde(rename = "_links", default)]
645    pub(crate) links: Option<ConfluenceVersionsLinks>,
646}
647
648#[derive(Deserialize)]
649pub(crate) struct ConfluenceVersionEntry {
650    pub(crate) number: u32,
651    #[serde(rename = "createdAt", default)]
652    pub(crate) created_at: Option<String>,
653    #[serde(default)]
654    pub(crate) message: Option<String>,
655    #[serde(rename = "minorEdit", default)]
656    pub(crate) minor_edit: Option<bool>,
657    #[serde(rename = "authorId", default)]
658    pub(crate) author_id: Option<String>,
659}
660
661#[derive(Deserialize)]
662pub(crate) struct ConfluenceVersionsLinks {
663    pub(crate) next: Option<String>,
664}
665
666/// A single version entry from a Confluence page's history.
667///
668/// Optional fields (`created_at`, `author_id`, `message`) are returned as
669/// empty strings when the API omits them — older pages can have null author
670/// or timestamp data, see issue #708.
671#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct PageVersion {
673    /// Version number (1-based; current version at the head of the list).
674    pub number: u32,
675    /// ISO 8601 creation timestamp; empty if the API returned null.
676    #[serde(default)]
677    pub created_at: String,
678    /// Account ID of the author; empty if the API returned null.
679    #[serde(default)]
680    pub author_id: String,
681    /// Version comment / edit message; empty if the API returned null.
682    #[serde(default)]
683    pub message: String,
684    /// Whether the edit was marked as minor.
685    #[serde(default)]
686    pub minor_edit: bool,
687}
688
689/// Filter applied to a version listing.
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub enum SinceFilter {
692    /// Keep versions whose `number >= n`.
693    Version(u32),
694    /// Keep versions whose `created_at >= iso` (lexicographic compare on
695    /// ISO 8601 strings — ordering is correct as long as the timestamps
696    /// are fully qualified with offsets, which Confluence's API guarantees).
697    CreatedAt(String),
698}
699
700impl SinceFilter {
701    /// Parses a `since` parameter. A purely numeric input is interpreted as
702    /// a version number; anything containing `-` or `T` (the typical ISO 8601
703    /// markers) is treated as a date.
704    pub fn parse(raw: &str) -> Result<Self> {
705        let trimmed = raw.trim();
706        if trimmed.is_empty() {
707            anyhow::bail!("`since` must be a version number or ISO 8601 date");
708        }
709        if trimmed.chars().all(|c| c.is_ascii_digit()) {
710            let n: u32 = trimmed
711                .parse()
712                .with_context(|| format!("Invalid version number \"{trimmed}\""))?;
713            return Ok(Self::Version(n));
714        }
715        if trimmed.contains('-') || trimmed.contains('T') {
716            return Ok(Self::CreatedAt(trimmed.to_string()));
717        }
718        anyhow::bail!(
719            "`since` must be a numeric version (e.g. \"5\") or ISO 8601 date \
720             (e.g. \"2026-01-01T00:00:00Z\"); got \"{trimmed}\""
721        );
722    }
723
724    /// Whether `version` satisfies this filter (i.e. should be kept).
725    pub(crate) fn matches(&self, version: &PageVersion) -> bool {
726        match self {
727            Self::Version(min) => version.number >= *min,
728            Self::CreatedAt(min) => {
729                if version.created_at.is_empty() {
730                    // Tolerate missing timestamps: treat as too-old.
731                    false
732                } else {
733                    version.created_at.as_str() >= min.as_str()
734                }
735            }
736        }
737    }
738}
739
740// ── Page metadata ──────────────────────────────────────────────────
741
742/// Lightweight metadata about a Confluence page, returned by
743/// [`ConfluenceApi::get_page_metadata`](crate::atlassian::confluence_api::ConfluenceApi::get_page_metadata).
744#[derive(Debug, Clone, Serialize)]
745pub struct PageMetadata {
746    /// Page ID.
747    pub id: String,
748    /// Page title.
749    pub title: String,
750    /// Current version number, if known.
751    pub current_version: Option<u32>,
752}
753
754// ── Attachments ────────────────────────────────────────────────────
755
756#[derive(Deserialize)]
757pub(crate) struct ConfluenceAttachmentsResponse {
758    pub(crate) results: Vec<ConfluenceAttachmentEntry>,
759    #[serde(rename = "_links", default)]
760    pub(crate) links: Option<ConfluenceAttachmentLinks>,
761}
762
763#[derive(Deserialize)]
764pub(crate) struct ConfluenceAttachmentLinks {
765    pub(crate) next: Option<String>,
766}
767
768#[derive(Deserialize)]
769pub(crate) struct ConfluenceAttachmentEntry {
770    pub(crate) id: String,
771    pub(crate) title: String,
772    #[serde(rename = "mediaType", default)]
773    pub(crate) media_type: Option<String>,
774    #[serde(rename = "fileSize", default)]
775    pub(crate) file_size: Option<u64>,
776    #[serde(rename = "downloadLink", default)]
777    pub(crate) download_link: Option<String>,
778    #[serde(default)]
779    pub(crate) version: Option<ConfluenceAttachmentVersion>,
780    #[serde(rename = "pageId", default)]
781    pub(crate) page_id: Option<String>,
782    #[serde(rename = "fileId", default)]
783    pub(crate) file_id: Option<String>,
784}
785
786#[derive(Deserialize)]
787pub(crate) struct ConfluenceAttachmentVersion {
788    pub(crate) number: u32,
789}
790
791// ── v1 attachment-upload response ───────────────────────────────────
792//
793// Attachment *creation* is only available on the Confluence Cloud v1 REST
794// API (`POST /wiki/rest/api/content/{id}/child/attachment`); the v2 API
795// exposes no attachment-creation endpoint. The v1 response nests its
796// metadata differently from the v2 list shape above, so it needs its own
797// deserialization structs.
798#[derive(Deserialize)]
799pub(crate) struct ConfluenceV1AttachmentResponse {
800    pub(crate) results: Vec<ConfluenceV1AttachmentEntry>,
801}
802
803#[derive(Deserialize)]
804pub(crate) struct ConfluenceV1AttachmentEntry {
805    pub(crate) id: String,
806    pub(crate) title: String,
807    #[serde(default)]
808    pub(crate) extensions: Option<ConfluenceV1AttachmentExtensions>,
809    #[serde(default)]
810    pub(crate) version: Option<ConfluenceAttachmentVersion>,
811    #[serde(default)]
812    pub(crate) container: Option<ConfluenceV1AttachmentContainer>,
813    #[serde(rename = "_links", default)]
814    pub(crate) links: Option<ConfluenceV1AttachmentEntryLinks>,
815}
816
817#[derive(Deserialize)]
818pub(crate) struct ConfluenceV1AttachmentExtensions {
819    #[serde(rename = "mediaType", default)]
820    pub(crate) media_type: Option<String>,
821    #[serde(rename = "fileSize", default)]
822    pub(crate) file_size: Option<u64>,
823    #[serde(rename = "fileId", default)]
824    pub(crate) file_id: Option<String>,
825}
826
827#[derive(Deserialize)]
828pub(crate) struct ConfluenceV1AttachmentContainer {
829    #[serde(default)]
830    pub(crate) id: Option<String>,
831}
832
833#[derive(Deserialize)]
834pub(crate) struct ConfluenceV1AttachmentEntryLinks {
835    #[serde(default)]
836    pub(crate) download: Option<String>,
837}
838
839impl From<ConfluenceV1AttachmentEntry> for ConfluenceAttachment {
840    fn from(e: ConfluenceV1AttachmentEntry) -> Self {
841        let (media_type, file_size, file_id) = match e.extensions {
842            Some(x) => (x.media_type, x.file_size, x.file_id),
843            None => (None, None, None),
844        };
845        Self {
846            id: e.id,
847            title: e.title,
848            media_type,
849            file_size,
850            download_url: e.links.and_then(|l| l.download),
851            version: e.version.map(|v| v.number),
852            page_id: e.container.and_then(|c| c.id),
853            file_id,
854        }
855    }
856}
857
858/// An attachment on a Confluence page.
859#[derive(Debug, Clone, Serialize)]
860pub struct ConfluenceAttachment {
861    /// Attachment ID (used for delete and get).
862    pub id: String,
863    /// Display title (filename).
864    pub title: String,
865    /// MIME type, when reported by the API.
866    #[serde(skip_serializing_if = "Option::is_none")]
867    pub media_type: Option<String>,
868    /// File size in bytes, when reported by the API.
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub file_size: Option<u64>,
871    /// Download URL path or absolute URL, when reported by the API.
872    #[serde(skip_serializing_if = "Option::is_none")]
873    pub download_url: Option<String>,
874    /// Version number, when reported by the API.
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub version: Option<u32>,
877    /// Owning page ID, when reported by the API.
878    #[serde(skip_serializing_if = "Option::is_none")]
879    pub page_id: Option<String>,
880    /// Underlying file ID, when reported by the API.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub file_id: Option<String>,
883}
884
885impl From<ConfluenceAttachmentEntry> for ConfluenceAttachment {
886    fn from(e: ConfluenceAttachmentEntry) -> Self {
887        Self {
888            id: e.id,
889            title: e.title,
890            media_type: e.media_type,
891            file_size: e.file_size,
892            download_url: e.download_link,
893            version: e.version.map(|v| v.number),
894            page_id: e.page_id,
895            file_id: e.file_id,
896        }
897    }
898}
899
900/// A page of attachments returned by [`ConfluenceApi::list_attachments`](crate::atlassian::confluence_api::ConfluenceApi::list_attachments).
901///
902/// Pagination is *not* auto-drained: callers receive one page at a time and
903/// pass `next_cursor` back to fetch the next page. Other v2 list helpers in
904/// this module (e.g. [`ConfluenceApi::get_labels`](crate::atlassian::confluence_api::ConfluenceApi::get_labels)) auto-drain — attachments
905/// expose the cursor explicitly so MCP/CLI callers can stream very large
906/// attachment lists without buffering everything in memory.
907#[derive(Debug, Clone, Serialize)]
908pub struct ConfluenceAttachmentPage {
909    /// Attachments on this page.
910    pub results: Vec<ConfluenceAttachment>,
911    /// Opaque cursor for the next page, when present.
912    #[serde(skip_serializing_if = "Option::is_none")]
913    pub next_cursor: Option<String>,
914}
915
916// ── Create request ─────────────────────────────────────────────────
917
918#[derive(Serialize)]
919pub(crate) struct ConfluenceCreateRequest {
920    #[serde(rename = "spaceId")]
921    pub(crate) space_id: String,
922    pub(crate) title: String,
923    pub(crate) body: ConfluenceUpdateBody,
924    #[serde(rename = "parentId", skip_serializing_if = "Option::is_none")]
925    pub(crate) parent_id: Option<String>,
926    pub(crate) status: String,
927}
928
929#[derive(Deserialize)]
930pub(crate) struct ConfluenceCreateResponse {
931    pub(crate) id: String,
932}
933
934// ── Update request ──────────────────────────────────────────────────
935
936#[derive(Serialize)]
937pub(crate) struct ConfluenceUpdateRequest {
938    pub(crate) id: String,
939    pub(crate) status: String,
940    pub(crate) title: String,
941    pub(crate) body: ConfluenceUpdateBody,
942    pub(crate) version: ConfluenceUpdateVersion,
943}
944
945#[derive(Serialize)]
946pub(crate) struct ConfluenceUpdateBody {
947    pub(crate) representation: String,
948    pub(crate) value: String,
949}
950
951#[derive(Serialize)]
952pub(crate) struct ConfluenceUpdateVersion {
953    pub(crate) number: u32,
954    pub(crate) message: Option<String>,
955}
956
957// ── Move types ─────────────────────────────────────────────────────
958
959/// Position for [`ConfluenceApi::move_page`](crate::atlassian::confluence_api::ConfluenceApi::move_page). Same-space only —
960/// cross-space moves are not supported by the v2 API.
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962pub enum MovePosition {
963    /// Place the page as the last child of the target (target becomes the new parent).
964    Append,
965    /// Place the page as a sibling immediately before the target.
966    Before,
967    /// Place the page as a sibling immediately after the target.
968    After,
969}
970
971impl MovePosition {
972    /// Returns the URL-path segment used by the Confluence move endpoint.
973    pub fn as_str(self) -> &'static str {
974        match self {
975            Self::Append => "append",
976            Self::Before => "before",
977            Self::After => "after",
978        }
979    }
980}
981
982/// Updated page metadata returned by [`ConfluenceApi::move_page`](crate::atlassian::confluence_api::ConfluenceApi::move_page).
983#[derive(Debug, Clone, Serialize)]
984pub struct MovedPage {
985    /// Page ID.
986    pub id: String,
987    /// Page title.
988    pub title: String,
989    /// New parent page ID, if the page now has a parent.
990    #[serde(skip_serializing_if = "Option::is_none")]
991    pub parent_id: Option<String>,
992    /// Ancestor page IDs from root toward the immediate parent.
993    pub ancestors: Vec<String>,
994}