From 98927a0ae049e10d8fe4e23c456534b9f330a664 Mon Sep 17 00:00:00 2001 From: Calin Marian Date: Mon, 16 Mar 2026 14:33:45 +0200 Subject: [PATCH 1/4] Improve text formatting with ADF rendering and HTML link preservation Rewrite HTML tag stripper to preserve links (as "text (url)") and images (as "[image: url]") instead of stripping all tags. Add Atlassian Document Format (ADF) renderer for Jira Cloud descriptions, handling text with link marks, inlineCard/embedCard smart links, mentions, hard breaks, and media nodes. Fix formatJiraIssue to handle ADF object descriptions in addition to plain string descriptions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/formatter.zig | 293 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 256 insertions(+), 37 deletions(-) diff --git a/src/formatter.zig b/src/formatter.zig index f91c664..d52c2c4 100644 --- a/src/formatter.zig +++ b/src/formatter.zig @@ -5,57 +5,128 @@ pub const OutputFormat = enum { json, }; -/// Simple HTML tag stripper - removes HTML tags and decodes basic entities +/// Extract the value of an HTML attribute from the inner content of a tag. +/// E.g., given `a href="https://example.com" class="link"` and "href", +/// returns "https://example.com" (a slice into tag_inner). +fn extractHtmlAttr(tag_inner: []const u8, attr_name: []const u8) ?[]const u8 { + var pos: usize = 0; + while (pos + attr_name.len < tag_inner.len) : (pos += 1) { + if (!std.mem.startsWith(u8, tag_inner[pos..], attr_name)) continue; + // Must be preceded by whitespace to avoid partial matches (e.g. "data-href") + if (pos > 0 and tag_inner[pos - 1] != ' ' and tag_inner[pos - 1] != '\t' and tag_inner[pos - 1] != '\n') continue; + var p = pos + attr_name.len; + // Skip optional whitespace around = + while (p < tag_inner.len and (tag_inner[p] == ' ' or tag_inner[p] == '\t')) : (p += 1) {} + if (p >= tag_inner.len or tag_inner[p] != '=') continue; + p += 1; + while (p < tag_inner.len and (tag_inner[p] == ' ' or tag_inner[p] == '\t')) : (p += 1) {} + if (p >= tag_inner.len) continue; + const quote = tag_inner[p]; + if (quote != '"' and quote != '\'') continue; + const val_start = p + 1; + const val_end = std.mem.indexOfScalarPos(u8, tag_inner, val_start, quote) orelse continue; + return tag_inner[val_start..val_end]; + } + return null; +} + +/// HTML tag stripper that preserves links and images. +/// text → text (url) [or just text if text == url] +/// → [image: url] fn stripHtmlTags(allocator: std.mem.Allocator, html: []const u8) ![]u8 { var result = std.ArrayList(u8).initCapacity(allocator, html.len) catch return try allocator.dupe(u8, html); errdefer result.deinit(allocator); - var in_tag = false; + var i: usize = 0; var in_entity = false; var entity_buffer: [10]u8 = undefined; var entity_len: usize = 0; - var i: usize = 0; - while (i < html.len) : (i += 1) { + // Track current href to append URL after link text if it differs + var link_href: ?[]const u8 = null; + var link_text_start: usize = 0; + + while (i < html.len) { const c = html[i]; if (c == '<') { - in_tag = true; - } else if (c == '>') { - in_tag = false; - // Add space after closing tag for readability - if (i + 1 < html.len and html[i + 1] != ' ' and html[i + 1] != '\n') { - result.append(allocator, ' ') catch {}; - } - } else if (!in_tag) { - if (c == '&') { - in_entity = true; - entity_len = 0; - } else if (in_entity) { - if (c == ';') { - in_entity = false; - const entity = entity_buffer[0..entity_len]; - if (std.mem.eql(u8, entity, "nbsp")) { - result.append(allocator, ' ') catch {}; - } else if (std.mem.eql(u8, entity, "lt")) { - result.append(allocator, '<') catch {}; - } else if (std.mem.eql(u8, entity, "gt")) { - result.append(allocator, '>') catch {}; - } else if (std.mem.eql(u8, entity, "amp")) { - result.append(allocator, '&') catch {}; - } else if (std.mem.eql(u8, entity, "quot")) { - result.append(allocator, '"') catch {}; - } else { - // Unknown entity, just skip + const close = std.mem.indexOfScalarPos(u8, html, i + 1, '>') orelse { + i += 1; + continue; + }; + const tag_inner = html[i + 1 .. close]; + const trimmed = std.mem.trimLeft(u8, tag_inner, " \t\n\r"); + + if (trimmed.len > 0 and trimmed[0] == 'a' and + (trimmed.len == 1 or trimmed[1] == ' ' or trimmed[1] == '\t' or trimmed[1] == '\n')) + { + // Opening tag - extract href + link_href = extractHtmlAttr(tag_inner, "href"); + link_text_start = result.items.len; + } else if (trimmed.len >= 2 and trimmed[0] == '/' and trimmed[1] == 'a' and + (trimmed.len == 2 or trimmed[2] == ' ' or trimmed[2] == '\t')) + { + // Closing tag - append URL if link text differs from href + if (link_href) |href| { + const link_text = result.items[link_text_start..result.items.len]; + const trimmed_text = std.mem.trim(u8, link_text, " \t\n\r"); + if (!std.mem.eql(u8, trimmed_text, href)) { + try result.appendSlice(allocator, " ("); + try result.appendSlice(allocator, href); + try result.appendSlice(allocator, ")"); } - } else if (entity_len < entity_buffer.len) { - entity_buffer[entity_len] = c; - entity_len += 1; + link_href = null; + } + } else if (trimmed.len >= 3 and std.mem.startsWith(u8, trimmed, "img") and + (trimmed.len == 3 or trimmed[3] == ' ' or trimmed[3] == '/' or trimmed[3] == '>')) + { + // tag - show src URL + if (extractHtmlAttr(tag_inner, "src")) |src| { + try result.appendSlice(allocator, "[image: "); + try result.appendSlice(allocator, src); + try result.appendSlice(allocator, "]"); } } else { - result.append(allocator, c) catch {}; + // Other tag - add space after for readability + if (close + 1 < html.len and html[close + 1] != ' ' and html[close + 1] != '\n') { + result.append(allocator, ' ') catch {}; + } + } + + i = close + 1; + continue; + } + + // Entity handling + if (c == '&') { + in_entity = true; + entity_len = 0; + } else if (in_entity) { + if (c == ';') { + in_entity = false; + const entity = entity_buffer[0..entity_len]; + if (std.mem.eql(u8, entity, "nbsp")) { + result.append(allocator, ' ') catch {}; + } else if (std.mem.eql(u8, entity, "lt")) { + result.append(allocator, '<') catch {}; + } else if (std.mem.eql(u8, entity, "gt")) { + result.append(allocator, '>') catch {}; + } else if (std.mem.eql(u8, entity, "amp")) { + result.append(allocator, '&') catch {}; + } else if (std.mem.eql(u8, entity, "quot")) { + result.append(allocator, '"') catch {}; + } else { + // Unknown entity, skip + } + } else if (entity_len < entity_buffer.len) { + entity_buffer[entity_len] = c; + entity_len += 1; } + } else { + result.append(allocator, c) catch {}; } + + i += 1; } return result.toOwnedSlice(allocator); @@ -82,6 +153,149 @@ fn cleanWhitespace(allocator: std.mem.Allocator, text: []const u8) ![]u8 { return result.toOwnedSlice(allocator); } +/// Write plain text extracted from a Jira ADF (Atlassian Document Format) node. +/// Iterative DFS to avoid per-recursion allocations. +fn writeAdfText(writer: anytype, node: std.json.Value) !void { + const max_stack = 64; + var stack: [max_stack]struct { items: []const std.json.Value, idx: usize } = undefined; + var depth: usize = 0; + + // Seed with the root node's content array + if (node != .object) return; + const root_content = node.object.get("content") orelse return; + if (root_content != .array) return; + stack[0] = .{ .items = root_content.array.items, .idx = 0 }; + depth = 1; + + while (depth > 0) { + const frame = &stack[depth - 1]; + if (frame.idx >= frame.items.len) { + depth -= 1; + continue; + } + const child = frame.items[frame.idx]; + frame.idx += 1; + + if (child != .object) continue; + const obj = child.object; + + // Emit text content + if (obj.get("text")) |text_val| { + if (text_val == .string) { + try writer.writeAll(text_val.string); + // Check for link marks and append URL if it differs from text + if (obj.get("marks")) |marks_val| { + if (marks_val == .array) { + for (marks_val.array.items) |mark| { + if (mark != .object) continue; + const mt = if (mark.object.get("type")) |t| if (t == .string) t.string else null else null; + if (mt) |mark_type| { + if (std.mem.eql(u8, mark_type, "link")) { + if (mark.object.get("attrs")) |attrs| { + if (attrs == .object) { + if (attrs.object.get("href")) |href| { + if (href == .string and !std.mem.eql(u8, text_val.string, href.string)) { + try writer.writeAll(" ("); + try writer.writeAll(href.string); + try writer.writeAll(")"); + } + } + } + } + } + } + } + } + } + } + } + + // Handle inlineCard (smart links / embedded URLs) + { + const nt = if (obj.get("type")) |t| if (t == .string) t.string else null else null; + if (nt) |node_type| { + if (std.mem.eql(u8, node_type, "inlineCard") or std.mem.eql(u8, node_type, "embedCard")) { + if (obj.get("attrs")) |attrs| { + if (attrs == .object) { + if (attrs.object.get("url")) |url| { + if (url == .string) { + try writer.writeAll(url.string); + try writer.writeAll("\n"); + } + } + } + } + } else if (std.mem.eql(u8, node_type, "mention")) { + if (obj.get("attrs")) |attrs| { + if (attrs == .object) { + if (attrs.object.get("text")) |text| { + if (text == .string) { + try writer.writeAll(text.string); + } + } + } + } + } else if (std.mem.eql(u8, node_type, "hardBreak")) { + try writer.writeAll("\n"); + } else if (std.mem.eql(u8, node_type, "media")) { + if (obj.get("attrs")) |attrs| { + if (attrs == .object) { + const mt = if (attrs.object.get("type")) |t| if (t == .string) t.string else null else null; + if (mt) |media_type| { + if (std.mem.eql(u8, media_type, "external")) { + if (attrs.object.get("url")) |url| { + if (url == .string) { + try writer.writeAll("[image: "); + try writer.writeAll(url.string); + try writer.writeAll("]"); + } + } + } else { + const alt = if (attrs.object.get("alt")) |a| if (a == .string) a.string else null else null; + if (alt) |alt_text| { + try writer.writeAll("[image: "); + try writer.writeAll(alt_text); + try writer.writeAll("]"); + } else { + try writer.writeAll("[image]"); + } + } + } + } + } + } + } + } + + // Push child's content array onto stack + if (obj.get("content")) |content_val| { + if (content_val == .array and depth < max_stack) { + stack[depth] = .{ .items = content_val.array.items, .idx = 0 }; + depth += 1; + continue; // process children before emitting newline + } + } + + // Newline after block-level nodes (only when no children were pushed) + if (obj.get("type")) |type_val| { + if (type_val == .string) { + const t = type_val.string; + if (std.mem.eql(u8, t, "paragraph") or + std.mem.eql(u8, t, "heading") or + std.mem.eql(u8, t, "bulletList") or + std.mem.eql(u8, t, "orderedList") or + std.mem.eql(u8, t, "listItem") or + std.mem.eql(u8, t, "codeBlock") or + std.mem.eql(u8, t, "blockquote") or + std.mem.eql(u8, t, "rule")) + { + try writer.writeAll("\n"); + } + } + } + } +} + /// Format Confluence search results as readable text pub fn formatConfluenceSearchResults(allocator: std.mem.Allocator, json_str: []const u8, show_full_content: bool) ![]u8 { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, json_str, .{}); @@ -336,12 +550,17 @@ pub fn formatJiraIssue(allocator: std.mem.Allocator, json_str: []const u8) ![]u8 try writer.print("Updated: {s}\n", .{updated.string}); } - // Description + // Description (can be a plain string or an ADF object in Jira Cloud) if (fields_obj.get("description")) |description| { if (description != .null) { try writer.writeAll("\nDescription:\n"); try writer.writeAll("─────────────────────────────────────────\n"); - try writer.print("{s}\n", .{description.string}); + if (description == .string) { + try writer.print("{s}\n", .{description.string}); + } else if (description == .object) { + try writeAdfText(writer, description); + try writer.writeAll("\n"); + } } } From 1e12dd1ba849b4128e35d0f2e0ef39075071ba32 Mon Sep 17 00:00:00 2001 From: Calin Marian Date: Mon, 16 Mar 2026 14:49:08 +0200 Subject: [PATCH 2/4] Address review feedback on formatter improvements Fix off-by-one in extractHtmlAttr loop bound and use std.ascii.isWhitespace for consistent whitespace checks. Propagate allocation errors in stripHtmlTags instead of silently swallowing them. Fix writeAdfText to emit newlines after block-level nodes by tracking is_block in the DFS stack, so paragraphs and headings are properly separated. Add [image] fallback for external media nodes missing a URL. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/formatter.zig | 61 ++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/src/formatter.zig b/src/formatter.zig index d52c2c4..96cbd40 100644 --- a/src/formatter.zig +++ b/src/formatter.zig @@ -10,16 +10,16 @@ pub const OutputFormat = enum { /// returns "https://example.com" (a slice into tag_inner). fn extractHtmlAttr(tag_inner: []const u8, attr_name: []const u8) ?[]const u8 { var pos: usize = 0; - while (pos + attr_name.len < tag_inner.len) : (pos += 1) { + while (pos + attr_name.len <= tag_inner.len) : (pos += 1) { if (!std.mem.startsWith(u8, tag_inner[pos..], attr_name)) continue; // Must be preceded by whitespace to avoid partial matches (e.g. "data-href") - if (pos > 0 and tag_inner[pos - 1] != ' ' and tag_inner[pos - 1] != '\t' and tag_inner[pos - 1] != '\n') continue; + if (pos > 0 and !std.ascii.isWhitespace(tag_inner[pos - 1])) continue; var p = pos + attr_name.len; // Skip optional whitespace around = - while (p < tag_inner.len and (tag_inner[p] == ' ' or tag_inner[p] == '\t')) : (p += 1) {} + while (p < tag_inner.len and std.ascii.isWhitespace(tag_inner[p])) : (p += 1) {} if (p >= tag_inner.len or tag_inner[p] != '=') continue; p += 1; - while (p < tag_inner.len and (tag_inner[p] == ' ' or tag_inner[p] == '\t')) : (p += 1) {} + while (p < tag_inner.len and std.ascii.isWhitespace(tag_inner[p])) : (p += 1) {} if (p >= tag_inner.len) continue; const quote = tag_inner[p]; if (quote != '"' and quote != '\'') continue; @@ -89,7 +89,7 @@ fn stripHtmlTags(allocator: std.mem.Allocator, html: []const u8) ![]u8 { } else { // Other tag - add space after for readability if (close + 1 < html.len and html[close + 1] != ' ' and html[close + 1] != '\n') { - result.append(allocator, ' ') catch {}; + try result.append(allocator, ' '); } } @@ -106,15 +106,15 @@ fn stripHtmlTags(allocator: std.mem.Allocator, html: []const u8) ![]u8 { in_entity = false; const entity = entity_buffer[0..entity_len]; if (std.mem.eql(u8, entity, "nbsp")) { - result.append(allocator, ' ') catch {}; + try result.append(allocator, ' '); } else if (std.mem.eql(u8, entity, "lt")) { - result.append(allocator, '<') catch {}; + try result.append(allocator, '<'); } else if (std.mem.eql(u8, entity, "gt")) { - result.append(allocator, '>') catch {}; + try result.append(allocator, '>'); } else if (std.mem.eql(u8, entity, "amp")) { - result.append(allocator, '&') catch {}; + try result.append(allocator, '&'); } else if (std.mem.eql(u8, entity, "quot")) { - result.append(allocator, '"') catch {}; + try result.append(allocator, '"'); } else { // Unknown entity, skip } @@ -123,7 +123,7 @@ fn stripHtmlTags(allocator: std.mem.Allocator, html: []const u8) ![]u8 { entity_len += 1; } } else { - result.append(allocator, c) catch {}; + try result.append(allocator, c); } i += 1; @@ -157,19 +157,22 @@ fn cleanWhitespace(allocator: std.mem.Allocator, text: []const u8) ![]u8 { /// Iterative DFS to avoid per-recursion allocations. fn writeAdfText(writer: anytype, node: std.json.Value) !void { const max_stack = 64; - var stack: [max_stack]struct { items: []const std.json.Value, idx: usize } = undefined; + var stack: [max_stack]struct { items: []const std.json.Value, idx: usize, is_block: bool } = undefined; var depth: usize = 0; // Seed with the root node's content array if (node != .object) return; const root_content = node.object.get("content") orelse return; if (root_content != .array) return; - stack[0] = .{ .items = root_content.array.items, .idx = 0 }; + stack[0] = .{ .items = root_content.array.items, .idx = 0, .is_block = false }; depth = 1; while (depth > 0) { const frame = &stack[depth - 1]; if (frame.idx >= frame.items.len) { + if (frame.is_block) { + try writer.writeAll("\n"); + } depth -= 1; continue; } @@ -248,7 +251,11 @@ fn writeAdfText(writer: anytype, node: std.json.Value) !void { try writer.writeAll("[image: "); try writer.writeAll(url.string); try writer.writeAll("]"); + } else { + try writer.writeAll("[image]"); } + } else { + try writer.writeAll("[image]"); } } else { const alt = if (attrs.object.get("alt")) |a| if (a == .string) a.string else null else null; @@ -270,25 +277,29 @@ fn writeAdfText(writer: anytype, node: std.json.Value) !void { // Push child's content array onto stack if (obj.get("content")) |content_val| { if (content_val == .array and depth < max_stack) { - stack[depth] = .{ .items = content_val.array.items, .idx = 0 }; + // Track whether this is a block-level node so we emit a newline after its children + const is_block = if (obj.get("type")) |tv| if (tv == .string) blk: { + const t = tv.string; + break :blk (std.mem.eql(u8, t, "paragraph") or + std.mem.eql(u8, t, "heading") or + std.mem.eql(u8, t, "bulletList") or + std.mem.eql(u8, t, "orderedList") or + std.mem.eql(u8, t, "listItem") or + std.mem.eql(u8, t, "codeBlock") or + std.mem.eql(u8, t, "blockquote") or + std.mem.eql(u8, t, "rule")); + } else false else false; + stack[depth] = .{ .items = content_val.array.items, .idx = 0, .is_block = is_block }; depth += 1; - continue; // process children before emitting newline + continue; } } - // Newline after block-level nodes (only when no children were pushed) + // Newline after leaf block-level nodes (no content array) if (obj.get("type")) |type_val| { if (type_val == .string) { const t = type_val.string; - if (std.mem.eql(u8, t, "paragraph") or - std.mem.eql(u8, t, "heading") or - std.mem.eql(u8, t, "bulletList") or - std.mem.eql(u8, t, "orderedList") or - std.mem.eql(u8, t, "listItem") or - std.mem.eql(u8, t, "codeBlock") or - std.mem.eql(u8, t, "blockquote") or - std.mem.eql(u8, t, "rule")) - { + if (std.mem.eql(u8, t, "rule")) { try writer.writeAll("\n"); } } From 7ccf2f9991dbd8792095a03d56b5c223f7a63579 Mon Sep 17 00:00:00 2001 From: Calin Marian Date: Mon, 16 Mar 2026 14:36:23 +0200 Subject: [PATCH 3/4] Add --expand-links flag to recursively fetch linked Confluence pages When viewing a Jira issue with --expand-links[=N], extract URLs from the ADF description and fetch the linked Confluence pages inline. Supports recursive depth and deduplication of visited pages. Add URL extraction utilities for both ADF and HTML content. --- src/formatter.zig | 127 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 109 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 233 insertions(+), 3 deletions(-) diff --git a/src/formatter.zig b/src/formatter.zig index 96cbd40..17460de 100644 --- a/src/formatter.zig +++ b/src/formatter.zig @@ -714,3 +714,130 @@ pub fn formatGenericList(allocator: std.mem.Allocator, json_str: []const u8, ite return try allocator.dupe(u8, "No items found.\n"); } + +/// Extract all URLs from an ADF (Atlassian Document Format) node. +/// Returns owned copies of URL strings; caller must free each element and the slice. +pub fn extractAdfUrls(allocator: std.mem.Allocator, node: std.json.Value) ![][]u8 { + var urls = try std.ArrayList([]u8).initCapacity(allocator, 4); + errdefer { + for (urls.items) |url| allocator.free(url); + urls.deinit(allocator); + } + + const max_stack = 64; + var stack: [max_stack]struct { items: []const std.json.Value, idx: usize } = undefined; + var depth: usize = 0; + + if (node != .object) return try urls.toOwnedSlice(allocator); + const root_content = node.object.get("content") orelse return try urls.toOwnedSlice(allocator); + if (root_content != .array) return try urls.toOwnedSlice(allocator); + stack[0] = .{ .items = root_content.array.items, .idx = 0 }; + depth = 1; + + while (depth > 0) { + const frame = &stack[depth - 1]; + if (frame.idx >= frame.items.len) { + depth -= 1; + continue; + } + const child = frame.items[frame.idx]; + frame.idx += 1; + + if (child != .object) continue; + const obj = child.object; + + // embedCard / inlineCard → attrs.url + const nt = if (obj.get("type")) |t| if (t == .string) t.string else null else null; + if (nt) |node_type| { + if (std.mem.eql(u8, node_type, "embedCard") or std.mem.eql(u8, node_type, "inlineCard")) { + if (obj.get("attrs")) |attrs| { + if (attrs == .object) { + if (attrs.object.get("url")) |url| { + if (url == .string) { + try urls.append(allocator, try allocator.dupe(u8, url.string)); + } + } + } + } + } + } + + // Text nodes with link marks → attrs.href + if (obj.get("marks")) |marks_val| { + if (marks_val == .array) { + for (marks_val.array.items) |mark| { + if (mark != .object) continue; + const mt = if (mark.object.get("type")) |t| if (t == .string) t.string else null else null; + if (mt) |mark_type| { + if (std.mem.eql(u8, mark_type, "link")) { + if (mark.object.get("attrs")) |attrs| { + if (attrs == .object) { + if (attrs.object.get("href")) |href| { + if (href == .string) { + try urls.append(allocator, try allocator.dupe(u8, href.string)); + } + } + } + } + } + } + } + } + } + + // Push children + if (obj.get("content")) |content_val| { + if (content_val == .array and depth < max_stack) { + stack[depth] = .{ .items = content_val.array.items, .idx = 0 }; + depth += 1; + } + } + } + + return try urls.toOwnedSlice(allocator); +} + +/// Extract all href URLs from HTML content (e.g., Confluence storage format). +/// Returns owned copies of URL strings; caller must free each element and the slice. +pub fn extractHtmlUrls(allocator: std.mem.Allocator, html: []const u8) ![][]u8 { + var urls = try std.ArrayList([]u8).initCapacity(allocator, 8); + errdefer { + for (urls.items) |url| allocator.free(url); + urls.deinit(allocator); + } + + var i: usize = 0; + while (i < html.len) { + if (html[i] == '<') { + const close = std.mem.indexOfScalarPos(u8, html, i + 1, '>') orelse { + i += 1; + continue; + }; + const tag_inner = html[i + 1 .. close]; + const trimmed = std.mem.trimLeft(u8, tag_inner, " \t\n\r"); + if (trimmed.len > 0 and trimmed[0] == 'a' and + (trimmed.len == 1 or trimmed[1] == ' ' or trimmed[1] == '\t' or trimmed[1] == '\n')) + { + if (extractHtmlAttr(tag_inner, "href")) |href| { + try urls.append(allocator, try allocator.dupe(u8, href)); + } + } + i = close + 1; + } else { + i += 1; + } + } + + return try urls.toOwnedSlice(allocator); +} + +/// Extract Confluence page ID from a URL like .../wiki/spaces/X/pages/12345/Title +pub fn extractConfluencePageId(url: []const u8) ?[]const u8 { + const marker = "/pages/"; + const pos = std.mem.indexOf(u8, url, marker) orelse return null; + const start = pos + marker.len; + var end = start; + while (end < url.len and url[end] >= '0' and url[end] <= '9') : (end += 1) {} + if (end == start) return null; + return url[start..end]; +} diff --git a/src/main.zig b/src/main.zig index bf84628..1d2baf8 100644 --- a/src/main.zig +++ b/src/main.zig @@ -60,6 +60,7 @@ fn printHelp() !void { \\ --format=text Output format: text (default) or json \\ --format=json Raw JSON output \\ --full-content Show full content (default shows preview only) + \\ --expand-links[=N] Fetch linked Confluence pages inline (N=depth, default 1) \\ \\Jira Commands: \\ issue Get issue details (e.g., PROJECT-123) @@ -142,6 +143,77 @@ fn hasFullContentFlag(args: []const [:0]const u8) bool { return false; } +/// Parse --expand-links or --expand-links=N flag. Returns null if absent, depth otherwise. +fn parseExpandLinksDepth(args: []const [:0]const u8) ?usize { + for (args) |arg| { + if (std.mem.eql(u8, arg, "--expand-links")) { + return 1; + } + if (std.mem.startsWith(u8, arg, "--expand-links=")) { + return std.fmt.parseInt(usize, arg["--expand-links=".len..], 10) catch 1; + } + } + return null; +} + +/// Recursively fetch and display a linked Confluence page and its children. +fn expandConfluenceLink( + allocator: std.mem.Allocator, + confluence: *ConfluenceClient, + base_url: []const u8, + url: []const u8, + page_id: []const u8, + remaining_depth: usize, + visited: *std.ArrayList([]u8), +) void { + if (remaining_depth == 0) return; + + // Check if already visited + for (visited.items) |id| { + if (std.mem.eql(u8, id, page_id)) return; + } + + // Mark as visited + const owned_id = allocator.dupe(u8, page_id) catch return; + visited.append(allocator, owned_id) catch { + allocator.free(owned_id); + return; + }; + + // Fetch and display + const page_response = confluence.getPage(page_id) catch return; + defer allocator.free(page_response); + + const page_formatted = formatter.formatConfluencePage(allocator, page_response, base_url) catch return; + defer allocator.free(page_formatted); + + std.debug.print("\n── Linked: {s} ──\n\n{s}", .{ url, page_formatted }); + + if (remaining_depth <= 1) return; + + // Extract child URLs from page HTML content and recurse + var parsed = std.json.parseFromSlice(std.json.Value, allocator, page_response, .{}) catch return; + defer parsed.deinit(); + + const body = if (parsed.value.object.get("body")) |b| b else return; + if (body != .object) return; + const storage = if (body.object.get("storage")) |s| s else return; + if (storage != .object) return; + const value = if (storage.object.get("value")) |v| v else return; + if (value != .string) return; + + const child_urls = formatter.extractHtmlUrls(allocator, value.string) catch return; + defer { + for (child_urls) |u| allocator.free(u); + allocator.free(child_urls); + } + + for (child_urls) |child_url| { + const child_page_id = formatter.extractConfluencePageId(child_url) orelse continue; + expandConfluenceLink(allocator, confluence, base_url, child_url, child_page_id, remaining_depth - 1, visited); + } +} + fn printConfluenceHelp() !void { const help = \\Confluence Commands: @@ -167,7 +239,7 @@ fn printConfluenceHelp() !void { std.debug.print("{s}\n", .{help}); } -fn handleJiraCommand(allocator: std.mem.Allocator, client: *AtlassianClient, args: []const [:0]const u8) !void { +fn handleJiraCommand(allocator: std.mem.Allocator, client: *AtlassianClient, args: []const [:0]const u8, base_url: []const u8) !void { if (args.len < 1) { try printJiraHelp(); return; @@ -186,7 +258,7 @@ fn handleJiraCommand(allocator: std.mem.Allocator, client: *AtlassianClient, arg .help => try printJiraHelp(), .issue => { if (args.len < 2) { - std.debug.print("Usage: jira issue [--format=text|json]\n", .{}); + std.debug.print("Usage: jira issue [--format=text|json] [--expand-links[=N]]\n", .{}); return; } const response = try jira.getIssue(args[1], null); @@ -196,6 +268,37 @@ fn handleJiraCommand(allocator: std.mem.Allocator, client: *AtlassianClient, arg const formatted = try formatter.formatJiraIssue(allocator, response); defer allocator.free(formatted); std.debug.print("{s}", .{formatted}); + + // Recursively expand linked Confluence pages + if (parseExpandLinksDepth(args)) |depth| { + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, response, .{}); + defer parsed.deinit(); + + const fields = parsed.value.object.get("fields") orelse return; + const description = fields.object.get("description") orelse return; + if (description != .object) return; + + const adf_urls = try formatter.extractAdfUrls(allocator, description); + defer { + for (adf_urls) |url| allocator.free(url); + allocator.free(adf_urls); + } + + const confluence_base_path = std.process.getEnvVarOwned(allocator, "CONFLUENCE_BASE_PATH") catch "/wiki"; + defer if (!std.mem.eql(u8, confluence_base_path, "/wiki")) allocator.free(confluence_base_path); + var confluence = ConfluenceClient.init(client, allocator, confluence_base_path); + + var visited = try std.ArrayList([]u8).initCapacity(allocator, 16); + defer { + for (visited.items) |id| allocator.free(id); + visited.deinit(allocator); + } + + for (adf_urls) |url| { + const page_id = formatter.extractConfluencePageId(url) orelse continue; + expandConfluenceLink(allocator, &confluence, base_url, url, page_id, depth, &visited); + } + } } else { std.debug.print("{s}\n", .{response}); } @@ -582,7 +685,7 @@ pub fn main() !void { // Dispatch to service handler // Pass base URL to Confluence command to dynamically generate URL switch (service) { - .jira => try handleJiraCommand(allocator, &client, args[2..]), + .jira => try handleJiraCommand(allocator, &client, args[2..], base_url), .confluence => try handleConfluenceCommand(allocator, &client, args[2..], base_url), .config => {}, // Handled above } From 9d86c3f46115ca0e841c1a9163eed25fc2e5f47a Mon Sep 17 00:00:00 2001 From: Calin Marian Date: Mon, 16 Mar 2026 14:50:11 +0200 Subject: [PATCH 4/4] Address review feedback for expand-links Use std.ascii.isDigit for readability in extractConfluencePageId. Log warnings when Confluence page fetch or format fails instead of silently returning. --- src/formatter.zig | 2 +- src/main.zig | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/formatter.zig b/src/formatter.zig index 17460de..75b96c8 100644 --- a/src/formatter.zig +++ b/src/formatter.zig @@ -837,7 +837,7 @@ pub fn extractConfluencePageId(url: []const u8) ?[]const u8 { const pos = std.mem.indexOf(u8, url, marker) orelse return null; const start = pos + marker.len; var end = start; - while (end < url.len and url[end] >= '0' and url[end] <= '9') : (end += 1) {} + while (end < url.len and std.ascii.isDigit(url[end])) : (end += 1) {} if (end == start) return null; return url[start..end]; } diff --git a/src/main.zig b/src/main.zig index 1d2baf8..e77771d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -181,10 +181,16 @@ fn expandConfluenceLink( }; // Fetch and display - const page_response = confluence.getPage(page_id) catch return; + const page_response = confluence.getPage(page_id) catch |err| { + std.debug.print("Warning: failed to fetch Confluence page {s}: {}\n", .{ page_id, err }); + return; + }; defer allocator.free(page_response); - const page_formatted = formatter.formatConfluencePage(allocator, page_response, base_url) catch return; + const page_formatted = formatter.formatConfluencePage(allocator, page_response, base_url) catch |err| { + std.debug.print("Warning: failed to format Confluence page {s}: {}\n", .{ page_id, err }); + return; + }; defer allocator.free(page_formatted); std.debug.print("\n── Linked: {s} ──\n\n{s}", .{ url, page_formatted });