diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 424e81e..37638af 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -6,6 +6,29 @@ All notable changes to the Confluence Page Exporter tool are documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [Unreleased] + +### Added + +- Write support for Confluence Cloud: `upload update` / `upload create` / + `upload merge` and all attachment operations now work on Cloud — Cloud + support is complete. Pages are created and updated via REST API v2 (the + numeric space id is resolved from the key automatically; a version conflict + arrives as 409 and is handled as before), attachments are uploaded through + the v1 endpoints Atlassian kept on Cloud. Verified end-to-end against a + live Cloud site, including both-sides conflict detection. + +### Fixed + +- Removed the phantom "changed on server" in `compare` for pages whose macros + were authored without service attributes: Confluence adds + `ac:schema-version` to macros on save, and the Cloud editor stamps + `local-id`/`ac:local-id` onto elements — the normalizer now ignores these + attributes during comparison (uploaded content is not affected). The + canonicalization epoch is bumped to 3: hashes stored in markers are + recomputed on each page's next sync; until then local-edit detection falls + back to file modification times, once. + ## [2.17.0] — 2026-07-02 ### Added diff --git a/CHANGELOG.md b/CHANGELOG.md index 399e1b8..be2589c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.0.0/). +## [Unreleased] + +### Добавлено + +- Поддержка записи на Confluence Cloud: `upload update` / `upload create` / + `upload merge` и все операции с вложениями теперь работают на Cloud — + поддержка Cloud стала полной. Страницы создаются и обновляются через REST + API v2 (числовой идентификатор пространства резолвится из ключа + автоматически, конфликт версий приходит как 409 и обрабатывается как + раньше), вложения загружаются через сохранившиеся на Cloud v1-эндпоинты. + Проверено сквозным тестом на живом Cloud-сайте, включая детект конфликтов + «изменено с обеих сторон». + +### Исправлено + +- Убран фантомный «изменено на сервере» в `compare` для страниц, чьи макросы + были созданы без служебных атрибутов: Confluence при сохранении добавляет + макросам `ac:schema-version`, а редактор Cloud штампует на элементы + `local-id`/`ac:local-id` — нормализатор теперь игнорирует эти атрибуты при + сравнении (на отправляемый контент это не влияет). Эпоха канонизации + поднята до 3: сохранённые в маркерах хэши пересчитаются при следующей + синхронизации каждой страницы, до этого детект локальных правок + одноразово опирается на время изменения файлов. + ## [2.17.0] — 2026-07-02 ### Добавлено diff --git a/README.en.md b/README.en.md index 32d43d6..7c61404 100644 --- a/README.en.md +++ b/README.en.md @@ -25,7 +25,7 @@ The tool supports: - attachments as separate files - a marker file `.id_` (its body is JSON with the original title and space key) for stable page identification and version tracking - Authentication modes: `--auth-type onprem` and `--auth-type cloud`; by default the type is auto-detected from `--base-url` (`*.atlassian.net` hosts → `cloud`) -- Confluence Cloud support (REST API v2) — read-only for now: `download update`/`download merge`, `compare`, and the `confluence_ping` / `confluence_get_page_content` MCP tools. Write operations (upload, attachments) on Cloud fail with a clear error and arrive in upcoming releases +- Full Confluence Cloud support (REST API v2 + the v1 endpoints Atlassian kept): every command — download, upload, compare, attachments — works the same against Server/DC and Cloud; Cloud's numeric space ids are transparently resolved from keys - Multi-layered configuration with priority: CLI > environment variables > file > default value - Global `--verbose` flag for detailed (debug-level) output - Dry-run support where applicable diff --git a/README.md b/README.md index fd1d253..295bc94 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ - вложения как отдельные файлы - файл-маркер `.id_` (в теле — JSON с оригинальным заголовком и ключом пространства) для стабильной идентификации страницы и отслеживания версии - Режимы аутентификации: `--auth-type onprem` и `--auth-type cloud`; по умолчанию тип определяется автоматически по `--base-url` (хосты `*.atlassian.net` → `cloud`) -- Поддержка Confluence Cloud (REST API v2) — пока только чтение: `download update`/`download merge`, `compare`, MCP-инструменты `confluence_ping` и `confluence_get_page_content`. Операции записи (upload, вложения) на Cloud вернут понятную ошибку и появятся в следующих релизах +- Полная поддержка Confluence Cloud (REST API v2 + сохранившиеся v1-эндпоинты): все команды — download, upload, compare, вложения — работают одинаково на Server/DC и Cloud; числовые идентификаторы пространств Cloud прозрачно резолвятся из ключей - Многоуровневая конфигурация с приоритетом: CLI > переменные окружения > файл > значение по умолчанию - Глобальный параметр `--verbose` для подробного (debug-level) вывода - Поддержка dry-run там, где применимо diff --git a/docs/mcp/agent-instructions.md b/docs/mcp/agent-instructions.md index fbca8a8..ad1a658 100644 --- a/docs/mcp/agent-instructions.md +++ b/docs/mcp/agent-instructions.md @@ -10,7 +10,7 @@ These same instructions are also published as `docs/mcp/agent-instructions.md` i - **Sandbox root.** The server was started with `--root-dir `. Every path you pass to a tool is resolved relative to that root; absolute paths are accepted only when they lie inside it. Outside paths fail with `OUT_OF_SANDBOX`. **You cannot widen this sandbox** — it is set at server startup and ignores env vars and config files. - **Read-only mode.** If the server was started with `--read-only`, all `confluence_upload_*` tools refuse with `READ_ONLY_VIOLATION`. `confluence_download_*`, `confluence_compare`, `confluence_ping`, and `confluence_get_page_content` remain available. -- **Confluence Cloud (read-only phase).** Against a Cloud site (auto-detected from a `*.atlassian.net` base URL, or `AuthType=cloud`) the download/compare/ping/get-page-content tools work via the Cloud v2 API; all `confluence_upload_*` tools currently fail with `NOT_SUPPORTED` — Cloud write support has not shipped yet. Do not retry; report it to the user. +- **Confluence Cloud.** Cloud sites (auto-detected from a `*.atlassian.net` base URL, or `AuthType=cloud`) are fully supported — every tool works the same as against Server/Data Center. No Cloud-specific behaviour to account for. - **Space keys (multi-space).** A default `spaceKey` may be configured server-side, and most tools accept an optional `spaceKey` that overrides it. For an **already-synced tree you normally don't need to pass `spaceKey` at all** — each page records its space locally (in the `.id` marker) and the server is the authority. Passing a `spaceKey` that contradicts a page's real space makes the upload tools **error** rather than silently mis-place pages. New child pages are always created in their parent's space (a Confluence tree lives in exactly one space). See "Working with multiple spaces" below. ## Local layout @@ -64,7 +64,7 @@ Every tool returns one of two shapes: | `INVALID_ARGS` | You passed contradictory or missing arguments. Read `error` and try again. | | `OUT_OF_SANDBOX` | A path argument resolved outside `--root-dir`. Use a path inside the sandbox. | | `READ_ONLY_VIOLATION` | You called an upload tool on a read-only server. Stop trying. | -| `NOT_SUPPORTED` | The operation is not available for this deployment — currently any write against Confluence Cloud (read-only Cloud phase). Stop trying and tell the user. | +| `NOT_SUPPORTED` | The operation is not available for this deployment. Stop trying and tell the user. | | `AUTH_FAILED` | 401/403 from Confluence or a local FS auth error. Report to the user. | | `PAGE_NOT_FOUND` | Page ID/title doesn't exist on the server. Re-check inputs. | | `NETWORK_ERROR` | `HttpRequestException` with no status — DNS/TCP/SSL. The server already retried up to 3× with backoff, so this means all retries failed. Call `confluence_ping` to confirm the server can reach Confluence. | diff --git a/src/ConfluencePageExporter/Services/ConfluenceApiHelpers.cs b/src/ConfluencePageExporter/Services/ConfluenceApiHelpers.cs index 4811459..6ec1239 100644 --- a/src/ConfluencePageExporter/Services/ConfluenceApiHelpers.cs +++ b/src/ConfluencePageExporter/Services/ConfluenceApiHelpers.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; namespace ConfluencePageExporter.Services; @@ -42,4 +43,68 @@ internal static async Task EnsureSuccessAsync(HttpResponseMessage response, stri /// internal static string EscapeCql(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private const string DefaultAttachmentMediaType = "application/octet-stream"; + + /// + /// Minimal extension → media-type map for the attachment upload path. + /// Confluence infers an attachment's media type from the multipart part's + /// Content-Type; when we send none it falls back to the filename + /// extension, which has no answer for extensionless files. This covers + /// the common attachment kinds so a new attachment keeps its proper type, + /// with as the catch-all. + /// + private static readonly IReadOnlyDictionary ExtensionMediaTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [".png"] = "image/png", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".gif"] = "image/gif", + [".bmp"] = "image/bmp", + [".webp"] = "image/webp", + [".svg"] = "image/svg+xml", + [".pdf"] = "application/pdf", + [".txt"] = "text/plain", + [".csv"] = "text/csv", + [".xml"] = "application/xml", + [".json"] = "application/json", + [".html"] = "text/html", + [".htm"] = "text/html", + [".zip"] = "application/zip", + [".drawio"] = "application/vnd.jgraph.mxfile", + }; + + /// + /// Resolves the media type to stamp on an uploaded attachment part. + /// Prefers the caller-supplied type (on update: the existing attachment's + /// stored server type, so the update never changes it), then the extension + /// map, and finally for + /// extensionless/unknown files. + /// + private static string ResolveAttachmentContentType(string fileName, string? preferredMediaType) + { + if (!string.IsNullOrWhiteSpace(preferredMediaType)) + return preferredMediaType; + + var ext = Path.GetExtension(fileName); + if (!string.IsNullOrEmpty(ext) && ExtensionMediaTypes.TryGetValue(ext, out var mapped)) + return mapped; + + return DefaultAttachmentMediaType; + } + + /// + /// Sets the multipart file part's Content-Type from + /// , falling back to + /// if the resolved value is not a + /// valid media-type header (e.g. an odd server-reported value). + /// + internal static void ApplyAttachmentContentType(ByteArrayContent part, string fileName, string? preferredMediaType) + { + var resolved = ResolveAttachmentContentType(fileName, preferredMediaType); + if (!MediaTypeHeaderValue.TryParse(resolved, out var parsed)) + MediaTypeHeaderValue.TryParse(DefaultAttachmentMediaType, out parsed); + part.Headers.ContentType = parsed; + } } diff --git a/src/ConfluencePageExporter/Services/ConfluenceCloudApiClient.cs b/src/ConfluencePageExporter/Services/ConfluenceCloudApiClient.cs index 752018a..d90fd6e 100644 --- a/src/ConfluencePageExporter/Services/ConfluenceCloudApiClient.cs +++ b/src/ConfluencePageExporter/Services/ConfluenceCloudApiClient.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Net; +using System.Text; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using ConfluencePageExporter.Models; @@ -22,9 +23,10 @@ namespace ConfluencePageExporter.Services; /// may exist". /// /// -/// Read-only phase: write operations (page create/update, attachment -/// upload/update/delete) throw until the -/// Cloud write phase ships. +/// Writes: page create/update go through v2 (POST/PUT /pages, numeric +/// spaceId resolved from the key, optimistic concurrency — a stale +/// version.number yields 409 CONFLICT); attachment upload/update use +/// the v1 multipart endpoints kept on Cloud, attachment delete uses v2. /// /// public class ConfluenceCloudApiClient : IConfluenceApiClient @@ -35,10 +37,6 @@ public class ConfluenceCloudApiClient : IConfluenceApiClient /// private const int BulkPageChunkSize = 100; - private const string WriteNotSupportedMessage = - "This operation is not supported on Confluence Cloud yet: current Cloud support is read-only " + - "(download, compare, page content). Write support (upload/create/merge, attachments) arrives in an upcoming release."; - private readonly HttpClient _httpClient; private readonly ILogger _logger; @@ -50,6 +48,9 @@ public class ConfluenceCloudApiClient : IConfluenceApiClient /// Numeric space id → space key, resolved once per space per client lifetime. private readonly ConcurrentDictionary _spaceKeyById = new(); + /// Space key → numeric space id (v2 create requires the id). Inverse of . + private readonly ConcurrentDictionary _spaceIdByKey = new(StringComparer.OrdinalIgnoreCase); + /// /// Page id → title cache. v2 responses carry only parentId (no /// ancestor titles), so parent titles are resolved through this cache; @@ -297,22 +298,191 @@ public async Task DownloadAttachmentAsync(string downloadUrl, Cancellati return await response.Content.ReadAsByteArrayAsync(ct); } - // ── Write operations: not supported in the read-only Cloud phase ───── + // ── Page writes (v2) ───────────────────────────────────────────────── + + public async Task CreatePageAsync(string spaceKey, string? parentId, string title, string content, CancellationToken ct = default) + { + var spaceId = await GetSpaceIdByKeyAsync(spaceKey, ct); + + var payload = new Dictionary + { + ["spaceId"] = spaceId, + ["status"] = "current", + ["title"] = title, + ["body"] = new { representation = "storage", value = content }, + }; + // Absent parentId → Cloud files the page under the space homepage, + // the v2 counterpart of v1's "space root". + if (!string.IsNullOrEmpty(parentId)) + payload["parentId"] = parentId; + + using var body = JsonContent(payload); + using var response = await _httpClient.PostAsync($"{_v2}/pages", body, ct); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Failed to create page '{Title}'. Status code: {StatusCode}, Error: {Error}", + title, response.StatusCode, errorContent); + throw ConfluenceApiHelpers.CreateException(response.StatusCode, errorContent, $"Failed to create page '{title}'"); + } + + var created = await ReadPageAsync(response, $"Could not deserialize creation response for page '{title}'", ct); + _pageTitleById[created.Id] = created.Title; + return new PageUpdateResult(created.Id, created.Version?.Number ?? 1); + } + + public async Task UpdatePageAsync(string pageId, string title, string content, string? parentId, int? knownVersion = null, CancellationToken ct = default) + { + // Same optimistic-concurrency contract as the v1 client: the PUT is + // based on the caller-observed version when given (a concurrent edit + // then surfaces as 409), else on the current version fetched here. + // v2 accepts exactly current+1 and answers a stale number with + // 409 CONFLICT. + int version; + if (knownVersion is int known) + { + version = known + 1; + } + else + { + var current = await FetchPageAsync(pageId, "", $"Failed to fetch current version of page {pageId}", ct); + version = (current.Version?.Number ?? 1) + 1; + } + + var payload = new Dictionary + { + ["id"] = pageId, + ["status"] = "current", + ["title"] = title, + ["body"] = new { representation = "storage", value = content }, + ["version"] = new { number = version }, + }; + // v2 PUT honours parentId — this is how a page moves to a new parent. + if (!string.IsNullOrEmpty(parentId)) + payload["parentId"] = parentId; + + using var body = JsonContent(payload); + using var response = await _httpClient.PutAsync($"{_v2}/pages/{pageId}", body, ct); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(ct); + _logger.LogError("Failed to update page '{Title}' (ID: {PageId}). Status code: {StatusCode}, Error: {Error}", + title, pageId, response.StatusCode, errorContent); + throw ConfluenceApiHelpers.CreateException(response.StatusCode, errorContent, $"Failed to update page '{title}' (ID: {pageId})"); + } + + var updated = await ReadPageAsync(response, $"Could not deserialize update response for page {pageId}", ct); + _pageTitleById[updated.Id] = updated.Title; + // The response version is authoritative: Cloud accepts a + // content-identical PUT as a no-op and leaves the version unchanged. + return new PageUpdateResult(updated.Id, updated.Version?.Number ?? version); + } + + // ── Attachment writes (v1 multipart kept on Cloud; delete via v2) ──── - public Task CreatePageAsync(string spaceKey, string? parentId, string title, string content, CancellationToken ct = default) => - throw new NotSupportedException(WriteNotSupportedMessage); + public async Task UploadAttachmentAsync(string pageId, string filePath, string fileName, CancellationToken ct = default) + { + try + { + var fileContent = await File.ReadAllBytesAsync(filePath, ct); + using var content = new MultipartFormDataContent(); + var filePart = new ByteArrayContent(fileContent); + ConfluenceApiHelpers.ApplyAttachmentContentType(filePart, fileName, preferredMediaType: null); + content.Add(filePart, "file", fileName); - public Task UpdatePageAsync(string pageId, string title, string content, string? parentId, int? knownVersion = null, CancellationToken ct = default) => - throw new NotSupportedException(WriteNotSupportedMessage); + var url = $"{_v1}/content/{pageId}/child/attachment"; + using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; + request.Headers.Add("X-Atlassian-Token", "nocheck"); - public Task UploadAttachmentAsync(string pageId, string filePath, string fileName, CancellationToken ct = default) => - throw new NotSupportedException(WriteNotSupportedMessage); + using var response = await _httpClient.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Failed to upload attachment '{FileName}' to page {PageId}. Status code: {StatusCode}, Error: {Error}", + fileName, pageId, response.StatusCode, errorContent); + return false; + } + return true; + } + catch (OperationCanceledException) + { + // Cancellation is not a per-attachment failure — propagate it + // instead of reporting a tolerated "false" below. + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error uploading attachment '{FileName}' to page {PageId}", fileName, pageId); + return false; + } + } - public Task UpdateAttachmentDataAsync(string pageId, string attachmentId, string filePath, string fileName, string? contentType = null, CancellationToken ct = default) => - throw new NotSupportedException(WriteNotSupportedMessage); + public async Task UpdateAttachmentDataAsync(string pageId, string attachmentId, string filePath, string fileName, string? contentType = null, CancellationToken ct = default) + { + try + { + var fileContent = await File.ReadAllBytesAsync(filePath, ct); + using var content = new MultipartFormDataContent(); + var filePart = new ByteArrayContent(fileContent); + // Stamp the stored server media type so Confluence keeps it + // instead of re-inferring from a (possibly missing) extension — + // see the interface doc for the extensionless-attachment story. + ConfluenceApiHelpers.ApplyAttachmentContentType(filePart, fileName, preferredMediaType: contentType); + content.Add(filePart, "file", fileName); + content.Add(new StringContent("true"), "minorEdit"); + + var url = $"{_v1}/content/{pageId}/child/attachment/{attachmentId}/data"; + using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; + request.Headers.Add("X-Atlassian-Token", "nocheck"); + + using var response = await _httpClient.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(ct); + _logger.LogWarning( + "Failed to update attachment '{FileName}' (ID: {AttachmentId}) on page {PageId}. Status code: {StatusCode}, Error: {Error}", + fileName, attachmentId, pageId, response.StatusCode, errorContent); + return false; + } + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating attachment '{FileName}' (ID: {AttachmentId}) on page {PageId}", fileName, attachmentId, pageId); + return false; + } + } - public Task DeleteAttachmentAsync(string pageId, string attachmentId, CancellationToken ct = default) => - throw new NotSupportedException(WriteNotSupportedMessage); + public async Task DeleteAttachmentAsync(string pageId, string attachmentId, CancellationToken ct = default) + { + try + { + var url = $"{_v2}/attachments/{attachmentId}"; + using var response = await _httpClient.DeleteAsync(url, ct); + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Failed to delete attachment {AttachmentId} from page {PageId}. Status code: {StatusCode}, Error: {Error}", + attachmentId, pageId, response.StatusCode, errorContent); + return false; + } + _logger.LogInformation("Deleted attachment {AttachmentId} from page {PageId}", attachmentId, pageId); + return true; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting attachment {AttachmentId} from page {PageId}", attachmentId, pageId); + return false; + } + } // ── v2 plumbing ────────────────────────────────────────────────────── @@ -441,10 +611,50 @@ version is null var space = JsonConvert.DeserializeObject(content) ?? throw new InvalidOperationException($"Could not deserialize space {spaceId}"); - _spaceKeyById[space.Id] = space.Key; + CacheSpace(space); return space.Key; } + /// + /// Resolves a space key to its numeric id (v2 page create requires the + /// id), cached per client lifetime. An unknown key throws — creating a + /// page in a mistyped space must not fail silently. + /// + private async Task GetSpaceIdByKeyAsync(string spaceKey, CancellationToken ct) + { + if (_spaceIdByKey.TryGetValue(spaceKey, out var cached)) + return cached; + + var url = $"{_v2}/spaces?keys={Uri.EscapeDataString(spaceKey)}"; + using var response = await _httpClient.GetAsync(url, ct); + await ConfluenceApiHelpers.EnsureSuccessAsync(response, $"Failed to resolve space key '{spaceKey}'", ct); + var content = await response.Content.ReadAsStringAsync(ct); + var spaces = JsonConvert.DeserializeObject>(content) + ?? throw new InvalidOperationException($"Could not deserialize the spaces response for key '{spaceKey}'"); + var space = spaces.Results.FirstOrDefault(s => string.Equals(s.Key, spaceKey, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidOperationException($"Space '{spaceKey}' was not found on the Cloud site."); + + CacheSpace(space); + return space.Id; + } + + /// Populates both directions of the space cache from one lookup. + private void CacheSpace(CloudSpace space) + { + _spaceKeyById[space.Id] = space.Key; + _spaceIdByKey[space.Key] = space.Id; + } + + private static StringContent JsonContent(object payload) => + new(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json"); + + private static async Task ReadPageAsync(HttpResponseMessage response, string errorContext, CancellationToken ct) + { + var content = await response.Content.ReadAsStringAsync(ct); + return JsonConvert.DeserializeObject(content) + ?? throw new InvalidOperationException(errorContext); + } + /// /// Resolves a page title by id via the cache, fetching the bare page on a /// miss. Best-effort: an inaccessible parent (deleted, no permission) diff --git a/src/ConfluencePageExporter/Services/HttpClientConfluenceApiClient.cs b/src/ConfluencePageExporter/Services/HttpClientConfluenceApiClient.cs index 4752087..2a77dbd 100644 --- a/src/ConfluencePageExporter/Services/HttpClientConfluenceApiClient.cs +++ b/src/ConfluencePageExporter/Services/HttpClientConfluenceApiClient.cs @@ -305,69 +305,10 @@ private static Task EnsureSuccessAsync(HttpResponseMessage response, string cont private static string EscapeCql(string value) => ConfluenceApiHelpers.EscapeCql(value); - private const string DefaultAttachmentMediaType = "application/octet-stream"; - - /// - /// Minimal extension → media-type map for the upload path. Confluence infers - /// an attachment's media type from the multipart part's Content-Type; when we - /// send none it falls back to the filename extension, which has no answer for - /// extensionless files. This covers the common attachment kinds so a new - /// attachment keeps its proper type, with - /// as the catch-all. - /// - private static readonly IReadOnlyDictionary ExtensionMediaTypes = - new Dictionary(StringComparer.OrdinalIgnoreCase) - { - [".png"] = "image/png", - [".jpg"] = "image/jpeg", - [".jpeg"] = "image/jpeg", - [".gif"] = "image/gif", - [".bmp"] = "image/bmp", - [".webp"] = "image/webp", - [".svg"] = "image/svg+xml", - [".pdf"] = "application/pdf", - [".txt"] = "text/plain", - [".csv"] = "text/csv", - [".xml"] = "application/xml", - [".json"] = "application/json", - [".html"] = "text/html", - [".htm"] = "text/html", - [".zip"] = "application/zip", - [".drawio"] = "application/vnd.jgraph.mxfile", - }; - - /// - /// Resolves the media type to stamp on an uploaded attachment part. Prefers - /// the caller-supplied type (on update: the existing attachment's stored - /// server type, so the update never changes it), then the extension map, and - /// finally for extensionless/unknown - /// files. - /// - private static string ResolveAttachmentContentType(string fileName, string? preferredMediaType) - { - if (!string.IsNullOrWhiteSpace(preferredMediaType)) - return preferredMediaType; - - var ext = Path.GetExtension(fileName); - if (!string.IsNullOrEmpty(ext) && ExtensionMediaTypes.TryGetValue(ext, out var mapped)) - return mapped; - - return DefaultAttachmentMediaType; - } - - /// - /// Sets the multipart file part's Content-Type from - /// , falling back to - /// if the resolved value is not a - /// valid media-type header (e.g. an odd server-reported value). - /// - private static void ApplyAttachmentContentType(ByteArrayContent part, string fileName, string? preferredMediaType) - { - var resolved = ResolveAttachmentContentType(fileName, preferredMediaType); - if (!MediaTypeHeaderValue.TryParse(resolved, out var parsed)) - MediaTypeHeaderValue.TryParse(DefaultAttachmentMediaType, out parsed); - part.Headers.ContentType = parsed; - } + // Attachment media-type plumbing lives in ConfluenceApiHelpers, shared + // with the Cloud client (both upload through the same v1 multipart shape). + private static void ApplyAttachmentContentType(ByteArrayContent part, string fileName, string? preferredMediaType) => + ConfluenceApiHelpers.ApplyAttachmentContentType(part, fileName, preferredMediaType); public async Task UploadAttachmentAsync(string pageId, string filePath, string fileName, CancellationToken ct = default) { diff --git a/src/ConfluencePageExporter/Services/NormalizationContract.cs b/src/ConfluencePageExporter/Services/NormalizationContract.cs index 0062bd6..8e2fc4e 100644 --- a/src/ConfluencePageExporter/Services/NormalizationContract.cs +++ b/src/ConfluencePageExporter/Services/NormalizationContract.cs @@ -23,9 +23,11 @@ public static class NormalizationContract { /// /// Epoch stamped into markers alongside a freshly computed hash. - /// History: 1 = initial; 2 = strip ac:macro-id and empty ac:parameter. + /// History: 1 = initial; 2 = strip ac:macro-id and empty ac:parameter; + /// 3 = also strip ac:schema-version and local-id / ac:local-id + /// (server- and Cloud-editor-assigned noise, verified live on Cloud). /// - public const int CurrentEpoch = 2; + public const int CurrentEpoch = 3; /// Algorithm tag prefixed to every stored hash, making it self-describing. public const string HashPrefix = "sha256:"; @@ -33,12 +35,13 @@ public static class NormalizationContract /// /// Golden canary input. Deliberately exercises the dimensions normalization /// touches: out-of-order attributes, a named HTML entity, inter-tag - /// whitespace, element nesting, a stripped ac:macro-id, a dropped - /// empty ac:parameter, and a retained named parameter. + /// whitespace, element nesting, stripped ac:macro-id / + /// ac:schema-version / ac:local-id / local-id, a + /// dropped empty ac:parameter, and a retained named parameter. /// internal const string GoldenInput = - "

y

" + - "" + + "

y

" + + "" + "T" + ""; diff --git a/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs b/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs index 431dea5..59b1e29 100644 --- a/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs +++ b/src/ConfluencePageExporter/Services/XmlContentNormalizer.cs @@ -126,15 +126,24 @@ private static void NormalizeNode(XElement element) /// /// ac:macro-id — a server-assigned GUID added to macros that lack /// one (and otherwise stable across saves); not content. + /// ac:schema-version — assigned by the server to macros that lack + /// it (observed live on Cloud for both POST and PUT; Server does the same). + /// local-id / ac:local-id — Cloud-editor bookkeeping ids + /// stamped on elements and macros; preserved by REST round-trips but injected + /// by editor saves. /// empty-named <ac:parameter ac:name=""/> — silently discarded /// by the server on save. /// /// Comparing with these stripped makes ContentEquals agree with Confluence's own - /// canonicalisation instead of reporting a permanent phantom diff. + /// canonicalisation instead of reporting a permanent phantom diff. Stripping + /// affects comparison/hashing only — uploaded content is never normalized. ///
private static void StripVolatileConfluenceArtifacts(XElement element) { element.Attribute(Ac + "macro-id")?.Remove(); + element.Attribute(Ac + "schema-version")?.Remove(); + element.Attribute(Ac + "local-id")?.Remove(); + element.Attribute("local-id")?.Remove(); var emptyParameters = element.Elements(Ac + "parameter") .Where(p => string.IsNullOrWhiteSpace((string?)p.Attribute(Ac + "name")) && string.IsNullOrEmpty(p.Value)) diff --git a/tests/ConfluencePageExporter.Tests/Services/ConfluenceCloudApiClientTests.cs b/tests/ConfluencePageExporter.Tests/Services/ConfluenceCloudApiClientTests.cs index 3b828c3..2f31fe5 100644 --- a/tests/ConfluencePageExporter.Tests/Services/ConfluenceCloudApiClientTests.cs +++ b/tests/ConfluencePageExporter.Tests/Services/ConfluenceCloudApiClientTests.cs @@ -404,22 +404,298 @@ public async Task FindPageByTitleAsync_ShouldThrowApiException_OnHttpError() await Should.ThrowAsync(act); } - // ── Read-only contract ─────────────────────────────────────────────── + // ── Page writes ────────────────────────────────────────────────────── [Fact] - public async Task WriteOperations_ShouldThrowNotSupported_InReadOnlyCloudPhase() + public async Task CreatePageAsync_ShouldResolveSpaceKeyToId_AndPostV2Payload() { var handler = new StubHttpMessageHandler(); + handler.EnqueueResponse(HttpStatusCode.OK, ListJson(new Dictionary + { + ["id"] = "777", ["key"] = "DOCS", ["name"] = "Docs", + })); + handler.EnqueueResponder(request => + { + request.Method.ShouldBe(HttpMethod.Post); + request.RequestUri!.ToString().ShouldBe($"{V2}/pages"); + var body = request.Content!.ReadAsStringAsync().Result; + body.ShouldContain("\"spaceId\":\"777\""); + body.ShouldContain("\"status\":\"current\""); + body.ShouldContain("\"title\":\"NewPage\""); + body.ShouldContain("\"parentId\":\"10\""); + body.ShouldContain("\"representation\":\"storage\""); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(PageJson("700", "NewPage", spaceId: "777", parentId: "10")), + }; + }); + var client = CreateClient(handler); + + var result = await client.CreatePageAsync("DOCS", "10", "NewPage", "

x

", TestContext.Current.CancellationToken); + + result.Id.ShouldBe("700"); + result.VersionNumber.ShouldBe(1); + handler.Requests[0].RequestUri!.ToString().ShouldBe($"{V2}/spaces?keys=DOCS"); + } + + [Fact] + public async Task CreatePageAsync_WithoutParent_ShouldOmitParentId() + { + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponse(HttpStatusCode.OK, ListJson(new Dictionary + { + ["id"] = "777", ["key"] = "DOCS", + })); + handler.EnqueueResponder(request => + { + request.Content!.ReadAsStringAsync().Result.ShouldNotContain("parentId"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(PageJson("700", "NewPage", spaceId: "777")), + }; + }); + var client = CreateClient(handler); + + var result = await client.CreatePageAsync("DOCS", null, "NewPage", "

x

", TestContext.Current.CancellationToken); + + result.Id.ShouldBe("700"); + } + + [Fact] + public async Task CreatePageAsync_ShouldReuseSpaceCache_SeededByReads() + { + // A read resolves space id→key via GET /spaces/{id}; the same lookup + // must seed key→id so a following create needs no /spaces?keys= call. + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponse(HttpStatusCode.OK, PageJson("100", "Existing", spaceId: "777")); + handler.EnqueueResponse(HttpStatusCode.OK, SpaceJson("777", "DOCS")); + handler.EnqueueResponder(request => + { + request.RequestUri!.ToString().ShouldBe($"{V2}/pages"); // straight to POST + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(PageJson("700", "NewPage", spaceId: "777")), + }; + }); + var client = CreateClient(handler); + + await client.GetPageByIdAsync("100", TestContext.Current.CancellationToken); + var result = await client.CreatePageAsync("DOCS", null, "NewPage", "

x

", TestContext.Current.CancellationToken); + + result.Id.ShouldBe("700"); + handler.Requests.Count.ShouldBe(3); + } + + [Fact] + public async Task UpdatePageAsync_WithKnownVersion_ShouldPutOnce_WithIncrementAndStatus() + { + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponder(request => + { + request.Method.ShouldBe(HttpMethod.Put); + request.RequestUri!.ToString().ShouldBe($"{V2}/pages/900"); + var body = request.Content!.ReadAsStringAsync().Result; + body.ShouldContain("\"number\":6"); + body.ShouldContain("\"status\":\"current\""); + body.ShouldNotContain("parentId"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(PageJson("900", "New", version: 6)), + }; + }); var client = CreateClient(handler); - var ct = TestContext.Current.CancellationToken; - await Should.ThrowAsync(() => client.CreatePageAsync("DOCS", null, "T", "

", ct)); - await Should.ThrowAsync(() => client.UpdatePageAsync("1", "T", "

", null, null, ct)); - await Should.ThrowAsync(() => client.UploadAttachmentAsync("1", "f", "f", ct)); - await Should.ThrowAsync(() => client.UpdateAttachmentDataAsync("1", "a", "f", "f", null, ct)); - await Should.ThrowAsync(() => client.DeleteAttachmentAsync("1", "a", ct)); + var result = await client.UpdatePageAsync("900", "New", "

new

", null, knownVersion: 5, TestContext.Current.CancellationToken); - handler.Requests.ShouldBeEmpty(); // rejected before any HTTP traffic + result.VersionNumber.ShouldBe(6); + handler.Requests.ShouldHaveSingleItem(); + } + + [Fact] + public async Task UpdatePageAsync_WithoutKnownVersion_ShouldFetchCurrentVersion_ThenPut() + { + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponse(HttpStatusCode.OK, PageJson("900", "Old", version: 3)); + handler.EnqueueResponder(request => + { + request.Method.ShouldBe(HttpMethod.Put); + request.Content!.ReadAsStringAsync().Result.ShouldContain("\"number\":4"); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(PageJson("900", "New", version: 4)), + }; + }); + var client = CreateClient(handler); + + var result = await client.UpdatePageAsync("900", "New", "

new

", null, null, TestContext.Current.CancellationToken); + + result.VersionNumber.ShouldBe(4); + handler.Requests.Count.ShouldBe(2); + handler.Requests[0].Method.ShouldBe(HttpMethod.Get); + handler.Requests[0].RequestUri!.ToString().ShouldBe($"{V2}/pages/900"); + } + + [Fact] + public async Task UpdatePageAsync_ShouldThrowConflict_On409() + { + // Live-verified Cloud shape: 409 with errors[].code = CONFLICT. + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponse(HttpStatusCode.Conflict, + """{"errors":[{"status":409,"code":"CONFLICT","title":"Version must be incremented when updating a page. Current Version: [5]. Provided version: [5]"}]}"""); + var client = CreateClient(handler); + + var act = async () => await client.UpdatePageAsync("900", "New", "

new

", null, knownVersion: 4, TestContext.Current.CancellationToken); + + var ex = await Should.ThrowAsync(act); + ex.StatusCode.ShouldBe(HttpStatusCode.Conflict); + } + + [Fact] + public async Task UpdatePageAsync_ShouldReturnServerVersion_WhenCloudTreatsPutAsNoOp() + { + // Live-verified: a content-identical PUT is accepted (200) but the + // version stays unchanged — the response version is authoritative + // for the marker, not knownVersion+1. + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponder(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(PageJson("900", "Same", version: 1)), + }); + var client = CreateClient(handler); + + var result = await client.UpdatePageAsync("900", "Same", "

x

", null, knownVersion: 1, TestContext.Current.CancellationToken); + + result.VersionNumber.ShouldBe(1); + } + + [Fact] + public async Task UpdatePageAsync_ShouldSendParentId_ForMove() + { + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponder(request => + { + request.Content!.ReadAsStringAsync().Result.ShouldContain("\"parentId\":\"42\""); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(PageJson("900", "T", parentId: "42", version: 2)), + }; + }); + var client = CreateClient(handler); + + var result = await client.UpdatePageAsync("900", "T", "

x

", "42", knownVersion: 1, TestContext.Current.CancellationToken); + + result.VersionNumber.ShouldBe(2); + } + + // ── Attachment writes ──────────────────────────────────────────────── + + [Fact] + public async Task UploadAttachmentAsync_ShouldPostV1Multipart_UnderWikiContext() + { + var handler = new StubHttpMessageHandler(); + string? nocheckHeader = null; + string? filePartType = null; + handler.EnqueueResponder(request => + { + request.RequestUri!.ToString().ShouldBe($"{V1}/content/100/child/attachment"); + nocheckHeader = request.Headers.TryGetValues("X-Atlassian-Token", out var values) ? string.Join(",", values) : null; + filePartType = GetFilePartContentType(request); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; + }); + var client = CreateClient(handler); + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllBytesAsync(tempFile, [1, 2, 3], TestContext.Current.CancellationToken); + + var ok = await client.UploadAttachmentAsync("100", tempFile, "preview.png", TestContext.Current.CancellationToken); + + ok.ShouldBeTrue(); + nocheckHeader.ShouldBe("nocheck"); + filePartType.ShouldBe("image/png"); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task UpdateAttachmentDataAsync_ShouldPreserveProvidedMediaType() + { + var handler = new StubHttpMessageHandler(); + string? filePartType = null; + handler.EnqueueResponder(request => + { + request.RequestUri!.ToString().ShouldBe($"{V1}/content/100/child/attachment/ATT-1/data"); + filePartType = GetFilePartContentType(request); + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }; + }); + var client = CreateClient(handler); + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllBytesAsync(tempFile, [1, 2, 3], TestContext.Current.CancellationToken); + + var ok = await client.UpdateAttachmentDataAsync("100", "ATT-1", tempFile, "diagram", "application/vnd.jgraph.mxfile", TestContext.Current.CancellationToken); + + ok.ShouldBeTrue(); + filePartType.ShouldBe("application/vnd.jgraph.mxfile"); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task UploadAttachmentAsync_ShouldReturnFalse_OnServerError() + { + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponse(HttpStatusCode.InternalServerError, """{"message":"boom"}"""); + var client = CreateClient(handler); + var tempFile = Path.GetTempFileName(); + try + { + await File.WriteAllBytesAsync(tempFile, [1], TestContext.Current.CancellationToken); + + var ok = await client.UploadAttachmentAsync("100", tempFile, "f.bin", TestContext.Current.CancellationToken); + + ok.ShouldBeFalse(); + } + finally + { + File.Delete(tempFile); + } + } + + [Fact] + public async Task DeleteAttachmentAsync_ShouldDeleteViaV2() + { + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponder(request => + { + request.Method.ShouldBe(HttpMethod.Delete); + request.RequestUri!.ToString().ShouldBe($"{V2}/attachments/att123"); + return new HttpResponseMessage(HttpStatusCode.NoContent); + }); + var client = CreateClient(handler); + + var ok = await client.DeleteAttachmentAsync("100", "att123", TestContext.Current.CancellationToken); + + ok.ShouldBeTrue(); + } + + [Fact] + public async Task DeleteAttachmentAsync_ShouldReturnFalse_OnError() + { + var handler = new StubHttpMessageHandler(); + handler.EnqueueResponse(HttpStatusCode.NotFound, """{"message":"gone"}"""); + var client = CreateClient(handler); + + var ok = await client.DeleteAttachmentAsync("100", "att123", TestContext.Current.CancellationToken); + + ok.ShouldBeFalse(); } // ── Cancellation ───────────────────────────────────────────────────── @@ -449,6 +725,25 @@ private static ConfluenceCloudApiClient CreateClient(StubHttpMessageHandler hand LoggerTestHelper.CreateLogger()); } + /// + /// Returns the media type of the multipart "file" part on an attachment + /// upload/update request, or null if absent. Read inside the stub + /// responder while the in-memory request content is still alive. + /// + private static string? GetFilePartContentType(HttpRequestMessage request) + { + if (request.Content is not MultipartFormDataContent multipart) + return null; + + foreach (var part in multipart) + { + if (part.Headers.ContentDisposition?.Name?.Trim('"') == "file") + return part.Headers.ContentType?.MediaType; + } + + return null; + } + private static Dictionary PageObj( string id, string title, string? spaceId = null, string? parentId = null, int version = 1, string body = "

x

") => new() { diff --git a/tests/ConfluencePageExporter.Tests/Services/XmlContentNormalizerTests.cs b/tests/ConfluencePageExporter.Tests/Services/XmlContentNormalizerTests.cs index 782809c..e98d085 100644 --- a/tests/ConfluencePageExporter.Tests/Services/XmlContentNormalizerTests.cs +++ b/tests/ConfluencePageExporter.Tests/Services/XmlContentNormalizerTests.cs @@ -35,6 +35,26 @@ public void ContentEquals_IgnoresEmptyParameter_DroppedByServer() N.ContentEquals(local, server).ShouldBeTrue(); } + [Fact] + public void ContentEquals_IgnoresSchemaVersion_AssignedByServer() + { + // Verified live on Cloud: POST/PUT of a macro without ac:schema-version + // stores it with ac:schema-version="1" (Server behaves the same). + var local = "

x

"; + var server = "

x

"; + N.ContentEquals(local, server).ShouldBeTrue(); + } + + [Fact] + public void ContentEquals_IgnoresLocalId_StampedByCloudEditor() + { + // The Cloud editor stamps local-id on elements and ac:local-id on + // macros; REST round-trips preserve them, editor saves inject them. + var local = "

Text

x"; + var server = "

Text

x"; + N.ContentEquals(local, server).ShouldBeTrue(); + } + [Fact] public void ContentEquals_StillDetectsRealParameterChange() { @@ -262,11 +282,12 @@ public void NormalizeForComparison_ShouldStripIndentation() [Fact] public void NormalizeForComparison_ShouldSortAttributes() { - // ac:macro-id is stripped as a volatile artifact, so sorting is - // verified with two retained attributes (name < schema-version, Ordinal). - var input = ""; + // Volatile artifacts (ac:macro-id, ac:schema-version, …) are stripped, + // so sorting is verified with two retained ac:parameter-style + // attributes on a plain element (class < id, Ordinal). + var input = "

x

"; var result = N.NormalizeForComparison(input); - result.ShouldContain("ac:name=\"toc\" ac:schema-version=\"1\""); + result.ShouldContain("

x

"); } [Fact]