Intro
Welcome back! We are continuing on our journey to make a prototype program that converts an exported Ghost database to Markdown. With the end goal being that we can get shindakun.net up and running with Hugo. Last time, we took it pretty easy and focused mostly on reading the file into memory and converting the JSON to a Go struct. From there we printed out the first post.
Post Data
As a recap here is what one of the post fields contain.
{
"id": "60710b90705967038fe662d6",
"uuid": "71ba3d71-ac18-4f33-82f7-1962baa83a07",
"title": "db test",
"slug": "db-test",
"mobiledoc": "{\"version\":\"0.3.1\",\"markups\":[],\"atoms\":[],\"cards\":[[\"markdown\",{\"cardName\":\"card-markdown\",\"markdown\":\"\\n<strike>This is a db test</strike>.\\n\"}]],\"sections\":[[10,0]],\"ghostVersion\":\"3.0\"}",
"html": "<!--kg-card-begin: markdown--><p><strike>This is a db test</strike>.</p>\n<!--kg-card-end: markdown-->",
"comment_id": "2",
"plaintext": "This is a db test.",
"feature_image": null,
"featured": 0,
"type": "post",
"status": "published",
"locale": null,
"visibility": "public",
"email_recipient_filter": "none",
"author_id": "60710b8d705967038fe66214",
"created_at": "2004-08-09T19:11:20.000Z",
"updated_at": "2004-08-09T19:11:20.000Z",
"published_at": "2004-08-09T19:11:20.000Z",
"custom_excerpt": null,
"codeinjection_head": null,
"codeinjection_foot": null,
"custom_template": null,
"canonical_url": null
},
The majority of the fields we need to craft a post with appropriate frontmatter exist within this the object we’re getting back. We can see the title, slug, published date, etc. The section that contains the Markdown is in a format known as Mobiledoc.
Mobiledoc
According to the Ghost documentation Mobiledoc is
…a standardised JSON-based document storage format, which forms the heart of publishing with Ghost.
When extracted from the JSON object and cleaned up we’ll have another bit of JSON we can work with.
{
"version": "0.3.1",
"markups": [],
"atoms": [],
"cards": [
[
"markdown",
{
"cardName": "card-markdown",
"markdown": "\n<strike>This is a db test</strike>.\n"
}
]
],
"sections": [
[
10,
0
]
],
"ghostVersion": "3.0"
}
Sounds Easy
First we’ll use our favorite site, the JSON to Go converter to convert the JSON object to a struct we can work with.
type Mobiledoc struct {
Version string `json:"version"`
Markups []interface{} `json:"markups"`
Atoms []interface{} `json:"atoms"`
Cards [][]interface{} `json:"cards"`
Sections [][]int `json:"sections"`
GhostVersion string `json:"ghostVersion"`
}
Our code is pretty long now so I’m going to leave out the other struct GhostDatabase
struct, it’ll be in the complete code listing below though. We’re still going to be dumping code to the screen since we’re still working on our decoding logic.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"time"
)
type GhostDatabase struct {...}
type Mobiledoc struct {
Version string `json:"version"`
Markups []interface{} `json:"markups"`
Atoms []interface{} `json:"atoms"`
Cards [][]interface{} `json:"cards"`
Sections [][]int `json:"sections"`
GhostVersion string `json:"ghostVersion"`
}
func main() {
fmt.Println("ghost2hugo")
file, err := os.Open("shindakun-dot-net.ghost.2022-03-18-22-02-58.json")
if err != nil {
fmt.Println(err)
}
defer file.Close()
b, err := io.ReadAll(file)
if err != nil {
fmt.Println(err)
}
var db GhostDatabase
err = json.Unmarshal(b, &db)
if err != nil {
fmt.Println(err)
}
Let’s continue to focus on the first post for now since once we have that working it should just be a matter of looping through the “database”. This is where it gets a little tricky. We’re working with a couple of nested arrays so to work our way down to the appropriate section we use db.Db[0].Data.Posts[0].Mobiledoc
. This will give use the escaped version of our JSON object.
"{\"version\":\"0.3.1\",\"markups\":[],\"atoms\":[],\"cards\":[[\"markdown\",{\"cardName\":\"card-markdown\",\"markdown\":\"\\n<strike>This is a db test</strike>.\\n\"}]],\"sections\":[[10,0]],\"ghostVersion\":\"3.0\"}"
Let’s Go On A Trip
I knew that there would be a way to unescape the string that we get, checking the Go documentation led me to strconv.Unquote
.
Unquote interprets s as a single-quoted, double-quoted, or backquoted Go string literal, returning the string value that s quotes. (If s is single-quoted, it would be a Go character literal; Unquote returns the corresponding one-character string.)
Exactly what we need! Except that when I tried to unquote the string I kept receiving an invalid syntax
error. This had me confused for a little bit. After puzzling over it I realized if I prepended and appended a back tick to the string it seems to be treated as a raw string literal. This leads to code that looks like the following.
c := "`" + db.Db[0].Data.Posts[0].Mobiledoc + "`"
un, err := strconv.Unquote(c)
if err != nil {
fmt.Println(err)
}
fmt.Printf("%v\n", un)
Finally! We have the JSON!
{"version":"0.3.1","markups":[],"atoms":[],"cards":[["markdown",{"cardName":"card-markdown","markdown":"\n<strike>This is a db test</strike>.\n"}]],"sections":[[10,0]],"ghostVersion":"3.0"}
Now, we can unmarshal that into the Mobiledoc struct we set up earlier!
var md Mobiledoc
err = json.Unmarshal([]byte(un), &md)
if err != nil {
fmt.Println(err)
}
fmt.Printf("%#v", md)
Checking our dumped result we can see that we’ve got the expected data.
main.Mobiledoc{Version:"0.3.1", Markups:[]interface {}{}, Atoms:[]interface {}{}, Cards:[][]interface {}{[]interface {}{"markdown", map[string]interface {}{"cardName":"card-markdown", "markdown":"\n<strike>This is a db test</strike>.\n"}}}, Sections:[][]int{[]int{10, 0}}, GhostVersion:"3.0"}
Right now we’re concerned with the section called Cards
which if we naively want to access we can use the following.
card := md.Cards[0][1]
fmt.Printf("\n\ncard: %#v\n", card)
card: map[string]interface {}{"cardName":"card-markdown", "markdown":"\n<strike>This is a db test</strike>.\n"}
Let’s convert that to something a bit easier to access.
bbb := card.(map[string]interface{})
fmt.Println(bbb["markdown"])
<strike>This is a db test</strike>.
Next Time
So far so good, we’ve extracted the Markdown from the first post as expected. Next time around we’ll be writing a small loop to start trying to extract all the posts. I have a feeling that’s where the fun will begin.
Enjoy this post? |
---|
How about buying me a coffee? |
Code Listing
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"time"
)
type GhostDatabase struct {
Db []struct {
Meta struct {
ExportedOn int64 `json:"exported_on"`
Version string `json:"version"`
} `json:"meta"`
Data struct {
Posts []struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Title string `json:"title"`
Slug string `json:"slug"`
Mobiledoc string `json:"mobiledoc"`
HTML string `json:"html"`
CommentID string `json:"comment_id"`
Plaintext string `json:"plaintext"`
FeatureImage interface{} `json:"feature_image"`
Featured int `json:"featured"`
Type string `json:"type"`
Status string `json:"status"`
Locale interface{} `json:"locale"`
Visibility string `json:"visibility"`
EmailRecipientFilter string `json:"email_recipient_filter"`
AuthorID string `json:"author_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PublishedAt time.Time `json:"published_at"`
CustomExcerpt interface{} `json:"custom_excerpt"`
CodeinjectionHead interface{} `json:"codeinjection_head"`
CodeinjectionFoot interface{} `json:"codeinjection_foot"`
CustomTemplate interface{} `json:"custom_template"`
CanonicalURL interface{} `json:"canonical_url"`
} `json:"posts"`
PostsAuthors []struct {
ID string `json:"id"`
PostID string `json:"post_id"`
AuthorID string `json:"author_id"`
SortOrder int `json:"sort_order"`
} `json:"posts_authors"`
PostsMeta []interface{} `json:"posts_meta"`
PostsTags []struct {
ID string `json:"id"`
PostID string `json:"post_id"`
TagID string `json:"tag_id"`
SortOrder int `json:"sort_order"`
} `json:"posts_tags"`
Roles []struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} `json:"roles"`
RolesUsers []struct {
ID string `json:"id"`
RoleID string `json:"role_id"`
UserID string `json:"user_id"`
} `json:"roles_users"`
Settings []struct {
ID string `json:"id"`
Group string `json:"group"`
Key string `json:"key"`
Value string `json:"value"`
Type string `json:"type"`
Flags interface{} `json:"flags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} `json:"settings"`
Tags []struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description interface{} `json:"description"`
FeatureImage interface{} `json:"feature_image"`
ParentID interface{} `json:"parent_id"`
Visibility string `json:"visibility"`
OgImage interface{} `json:"og_image"`
OgTitle interface{} `json:"og_title"`
OgDescription interface{} `json:"og_description"`
TwitterImage interface{} `json:"twitter_image"`
TwitterTitle interface{} `json:"twitter_title"`
TwitterDescription interface{} `json:"twitter_description"`
MetaTitle interface{} `json:"meta_title"`
MetaDescription interface{} `json:"meta_description"`
CodeinjectionHead interface{} `json:"codeinjection_head"`
CodeinjectionFoot interface{} `json:"codeinjection_foot"`
CanonicalURL interface{} `json:"canonical_url"`
AccentColor interface{} `json:"accent_color"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} `json:"tags"`
Users []struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Password string `json:"password"`
Email string `json:"email"`
ProfileImage string `json:"profile_image"`
CoverImage interface{} `json:"cover_image"`
Bio interface{} `json:"bio"`
Website interface{} `json:"website"`
Location interface{} `json:"location"`
Facebook interface{} `json:"facebook"`
Twitter interface{} `json:"twitter"`
Accessibility string `json:"accessibility"`
Status string `json:"status"`
Locale interface{} `json:"locale"`
Visibility string `json:"visibility"`
MetaTitle interface{} `json:"meta_title"`
MetaDescription interface{} `json:"meta_description"`
Tour interface{} `json:"tour"`
LastSeen time.Time `json:"last_seen"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} `json:"users"`
} `json:"data"`
} `json:"db"`
}
type Mobiledoc struct {
Version string `json:"version"`
Markups []interface{} `json:"markups"`
Atoms []interface{} `json:"atoms"`
Cards [][]interface{} `json:"cards"`
Sections [][]int `json:"sections"`
GhostVersion string `json:"ghostVersion"`
}
func main() {
fmt.Println("ghost2hugo")
file, err := os.Open("shindakun-dot-net.ghost.2022-03-18-22-02-58.json")
if err != nil {
fmt.Println(err)
}
defer file.Close()
b, err := io.ReadAll(file)
if err != nil {
fmt.Println(err)
}
var db GhostDatabase
err = json.Unmarshal(b, &db)
if err != nil {
fmt.Println(err)
}
c := "`" + db.Db[0].Data.Posts[0].Mobiledoc + "`"
un, err := strconv.Unquote(c)
if err != nil {
fmt.Println(err)
}
fmt.Printf("%v", un)
var md Mobiledoc
err = json.Unmarshal([]byte(un), &md)
if err != nil {
fmt.Println(err)
}
fmt.Printf("%#v", md)
card := md.Cards[0][1]
fmt.Printf("\n\ncard: %#v\n", card)
bbb := card.(map[string]interface{})
fmt.Println(bbb["markdown"])
}