Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/plan.md

Large diffs are not rendered by default.

84 changes: 84 additions & 0 deletions internal/adf/adf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Package adf renders the Atlassian Document Format, the JSON in which Jira's REST API gives a
// ticket's description and comments, as Markdown to read (decision 57).
package adf

import (
"cmp"
"slices"
"strings"
)

// Node is a node of a document: a block, such as a paragraph or a table, or an inline node, such
// as a run of text or a mention.
type Node struct {
Type string `json:"type"`
Text string `json:"text"`
Attrs map[string]any `json:"attrs"`
Marks []Mark `json:"marks"`
Content []Node `json:"content"`
}

// Mark styles a run of text, such as strong or a link.
type Mark struct {
Type string `json:"type"`
Attrs map[string]any `json:"attrs"`
}

// Markdown renders a node, most often a whole document, as Markdown. The Markdown is for reading,
// so the text stays as Jira has it, and a node that text can't show, such as an image, leaves a
// word in its place. A node of an unknown type shows its content, or the text that stands in for
// it.
func Markdown(node Node) string {
return blocks([]Node{node})
}

// blocks renders nodes one after the other, with a blank line between them. A run of inline
// nodes makes one block, and an empty block makes none.
func blocks(nodes []Node) string {
parts := make([]string, 0, len(nodes))
for len(nodes) > 0 {
var part string
if run := inlineRun(nodes); run > 0 {
part, nodes = inline(nodes[:run]), nodes[run:]
} else {
part, nodes = block(nodes[0]), nodes[1:]
}

if part != "" {
parts = append(parts, part)
}
}

return strings.Join(parts, "\n\n")
}

// inlineRun returns how many of the nodes, from the first on, are inline nodes.
func inlineRun(nodes []Node) int {
end := slices.IndexFunc(nodes, func(node Node) bool { return !isInline(node.Type) })
if end < 0 {
return len(nodes)
}

return end
}

// isInline tells the nodes that flow in a line of text from the blocks.
func isInline(kind string) bool {
return slices.Contains([]string{
"text", "hardBreak", "mention", "emoji", "date", "status", "inlineCard",
"inlineExtension", "placeholder", "mediaInline",
}, kind)
}

// standIn returns the text that stands in for a node without content: a mention's name, an
// emoji, a status, a card's address or an extension's text.
func standIn(node Node) string {
return cmp.Or(attr(node.Attrs, "text"), attr(node.Attrs, "shortName"), attr(node.Attrs, "url"))
}

// attr returns the text of the attribute, or "" if there is none.
func attr(attrs map[string]any, key string) string {
text, _ := attrs[key].(string)

return text
}
80 changes: 80 additions & 0 deletions internal/adf/adf_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package adf_test

import (
"encoding/json/v2"
"io/fs"
"os"
"strings"
"testing"
"time"

"github.com/codedogapp/baton/internal/adf"
)

// TestMain puts the tests in a zone east of UTC, so that a date shown in the machine's zone, not
// in UTC, fails them, wherever they run.
func TestMain(m *testing.M) {
time.Local = time.FixedZone("UTC+2", int((2 * time.Hour).Seconds()))

m.Run()
}

// TestMarkdown renders each document in testdata and compares it with the Markdown beside it.
func TestMarkdown(t *testing.T) {
testdata := os.DirFS("testdata")

docs, err := fs.Glob(testdata, "*.json")
if err != nil || len(docs) == 0 {
t.Fatalf("no documents in testdata: %v", err)
}

for _, doc := range docs {
name := strings.TrimSuffix(doc, ".json")
t.Run(name, func(t *testing.T) {
var node adf.Node
if err := json.Unmarshal(read(t, testdata, doc), &node); err != nil {
t.Fatal(err)
}

want := strings.TrimSuffix(string(read(t, testdata, name+".md")), "\n")
if got := adf.Markdown(node); got != want {
t.Errorf("Markdown() =\n%s\nwant:\n%s", got, want)
}
})
}
}

func TestMarkdownOfOneNode(t *testing.T) {
tests := []struct {
name string
node adf.Node
want string
}{
{"nothing", adf.Node{}, ""},
{"an inline node", adf.Node{Type: "text", Text: "plain"}, "plain"},
{
"an unknown node",
adf.Node{Type: "future", Content: []adf.Node{{Type: "media"}}},
"[attachment]",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := adf.Markdown(tt.node); got != tt.want {
t.Errorf("Markdown() = %q, want %q", got, tt.want)
}
})
}
}

func read(t *testing.T, files fs.FS, path string) []byte {
t.Helper()

data, err := fs.ReadFile(files, path)
if err != nil {
t.Fatal(err)
}

return data
}
15 changes: 15 additions & 0 deletions internal/adf/block.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package adf

import "cmp"

// block renders a block node.
func block(node Node) string {
switch node.Type {
case "paragraph":
return inline(node.Content)
case "media":
return attachment(node)
default:
return cmp.Or(blocks(node.Content), standIn(node))
}
}
112 changes: 112 additions & 0 deletions internal/adf/inline.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package adf

import (
"cmp"
"slices"
"strconv"
"strings"
"time"
)

// inline renders inline nodes as one run of text.
func inline(nodes []Node) string {
var text strings.Builder
for _, node := range nodes {
text.WriteString(span(node))
}

return text.String()
}

// span renders an inline node.
func span(node Node) string {
switch node.Type {
case "text":
return styled(node)
case "hardBreak":
return "\n"
case "date":
return date(attr(node.Attrs, "timestamp"))
case "mediaInline":
return attachment(node)
default:
return cmp.Or(inline(node.Content), standIn(node))
}
}

// styled renders a run of text with its marks: code innermost, since code shows its text as it
// is, and a link outermost. Other marks, such as a colour, leave the text plain.
func styled(node Node) string {
text := node.Text
if _, ok := find(node.Marks, "code"); ok {
text = code(text)
}

for _, style := range []struct{ mark, delim string }{{"em", "*"}, {"strong", "**"}, {"strike", "~~"}} {
if _, ok := find(node.Marks, style.mark); ok {
text = style.delim + text + style.delim
}
}

if link, ok := find(node.Marks, "link"); ok {
text = "[" + text + "](" + attr(link.Attrs, "href") + ")"
}

return text
}

// find returns the mark of the type, if the marks hold one.
func find(marks []Mark, kind string) (Mark, bool) {
i := slices.IndexFunc(marks, func(mark Mark) bool { return mark.Type == kind })
if i < 0 {
return Mark{}, false
}

return marks[i], true
}

// code renders text as a code span. A backtick at either end of the text would join the fence, so
// a space stands between them, which Markdown drops.
func code(text string) string {
pad := ""
if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") {
pad = " "
}

ticks := fence(text)

return ticks + pad + text + pad + ticks
}

// fence returns the shortest run of backticks that the code doesn't hold, so that the code can't
// end its own fence.
func fence(code string) string {
ticks := "`"
for strings.Contains(code, ticks) {
ticks += "`"
}

return ticks
}

// date renders a date's timestamp, milliseconds since the epoch, as its day in UTC, as Jira does.
// A timestamp that isn't a number shows as it is.
func date(timestamp string) string {
millis, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return timestamp
}

return time.UnixMilli(millis).UTC().Format(time.DateOnly)
}

// attachment stands in for a file or an image, which text can't show, with its name, or its
// address if it has no name.
func attachment(node Node) string {
name := cmp.Or(attr(node.Attrs, "alt"), attr(node.Attrs, "url"))
if name == "" {
return "[attachment]"
}

return "[attachment: " + name + "]"
}
71 changes: 71 additions & 0 deletions internal/adf/testdata/blocks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"version": 1,
"type": "doc",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "A paragraph."}]},
{
"type": "mediaSingle",
"attrs": {"layout": "center"},
"content": [
{
"type": "media",
"attrs": {"type": "file", "id": "1", "collection": "c", "alt": "screen.png", "width": 640, "height": 480}
},
{
"type": "caption",
"content": [
{"type": "text", "text": "The "},
{"type": "text", "text": "login", "marks": [{"type": "em"}]},
{"type": "text", "text": " screen"}
]
}
]
},
{
"type": "mediaGroup",
"content": [
{"type": "media", "attrs": {"type": "external", "url": "https://example.com/a.png"}},
{"type": "media", "attrs": {"type": "external", "url": "https://example.com/b.png", "alt": "A diagram"}},
{"type": "media", "attrs": {"type": "file", "id": "2", "collection": "c"}}
]
},
{"type": "blockCard", "attrs": {"url": "https://example.com/DEMO-3"}},
{"type": "extension", "attrs": {"extensionType": "demo", "extensionKey": "k", "text": "A macro's text"}},
{
"type": "bodiedExtension",
"attrs": {"extensionType": "demo", "extensionKey": "k", "text": "Text for a missing macro"},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "The macro's body"}]}]
},
{
"type": "layoutSection",
"content": [
{
"type": "layoutColumn",
"attrs": {"width": 50},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
},
{
"type": "layoutColumn",
"attrs": {"width": 50},
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
}
]
},
{
"type": "futureBlock",
"content": [
{
"type": "paragraph",
"content": [
{"type": "text", "text": "Unknown, "},
{
"type": "futureInline",
"attrs": {"text": "stand-in"},
"content": [{"type": "text", "text": "with known text"}]
}
]
}
]
}
]
}
23 changes: 23 additions & 0 deletions internal/adf/testdata/blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
A paragraph.

[attachment: screen.png]

The *login* screen

[attachment: https://example.com/a.png]

[attachment: A diagram]

[attachment]

https://example.com/DEMO-3

A macro's text

The macro's body

Left

Right

Unknown, with known text
Loading
Loading