Merge pull request #2 from seletskiy/master

page create, extended format & permission lock
This commit is contained in:
Egor Kovetskiy 2016-11-30 21:22:11 +07:00 committed by GitHub
commit 41762517df
7 changed files with 884 additions and 138 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/mark

View File

@ -1,41 +1,64 @@
# Mark # Mark
Mark it's the tool for syncing your markdown documentation with Atlassian Mark tool for syncing your markdown documentation with Atlassian Confluence
Confluence pages. pages.
This is very usable if you store documentation to your orthodox software in git This is very usable if you store documentation to your orthodox software in git
repository and don't want to do a handjob with updating Confluence page using repository and don't want to do a handjob with updating Confluence page using
fucking tinymce wysiwyg enterprise core editor. fucking tinymce wysiwyg enterprise core editor.
You can store a user credentials in the configuration file, which should be You can store a user credentials in the configuration file, which should be
located in `~/.config/mark` with following format: located in ~/.config/mark with following format:
```
```toml
username = "smith" username = "smith"
password = "matrixishere" password = "matrixishere"
base_url = "http://confluence.local"
``` ```
Mark can read Confluence page URL and markdown file path from another specified Mark can read Confluence page URL and markdown file path from another specified
configuration file, which you can specify using `-c <file>` flag. It is very configuration file, which you can specify using -c <file> flag. It is very
usable for git hooks. That file should have following format: usable for git hooks. That file should have following format:
```toml ```toml
url = "http://confluence.local/pages/viewpage.action?pageId=123456" url = "http://confluence.local/pages/viewpage.action?pageId=123456"
file = "docs/README.md" file = "docs/README.md"
``` ```
Mark understands extended file format, which, still being valid markdown,
contains several metadata headers, which can be used to locate page inside
Confluence instance and update it accordingly.
File in extended format should follow specification
```markdown
[]:# (X-Space: <space key>)
[]:# (X-Parent: <parent 1>)
[]:# (X-Parent: <parent 2>)
[]:# (X-Title: <title>)
<page contents>
```
There can be any number of 'X-Parent' headers, if mark can't find specified
parent by title, it will be created.
## Usage: ## Usage:
``` ```
mark [--dry-run] [-u <username>] [-p <password>] -l <url> -f <file> mark [options] [-u <username>] [-p <password>] [-k] [-l <url>] -f <file>
mark [--dry-run] [-u <username>] [-p <password>] -c <file> mark [options] [-u <username>] [-p <password>] [-k] [-n] -c <file>
mark -v | --version mark -v | --version
mark -h | --help mark -h | --help
``` ```
- `-u <username>` - Use specified username for updating Confluence page. - `-u <username>` — Use specified username for updating Confluence page.
- `-p <password>` - Use specified password for updating Confluence page. - `-p <password>` — Use specified password for updating Confluence page.
- `-l <url>` - Edit specified Confluence page. - `-l <url>` — Edit specified Confluence page.
- `-f <file>` - Use specified markdown file for converting to html. If -l is not specified, file should contain metadata (see above).
- `-c <file>` - Specify configuration file which should be used for reading - `-f <file>` — Use specified markdown file for converting to html.
Confluence page URL and markdown file path. - `-c <file>` — Specify configuration file which should be used for reading
- `--dry-run` - Show resulting HTML and don't update Confluence page content. Confluence page URL and markdown file path.
- `-v | --version` - Show version. - `-k` — Lock page editing to current user only to prevent accidental
- `-h | --help` - Show help screen and call 911. manual edits over Confluence Web UI.
- `--dry-run` — Show resulting HTML and don't update Confluence page content.
- `--trace` — Enable trace logs.
- `-v | --version` — Show version.
- `-h | --help` — Show help screen and call 911.

278
api.go Normal file
View File

@ -0,0 +1,278 @@
package main
import (
"fmt"
"io/ioutil"
"github.com/bndr/gopencils"
)
type RestrictionOperation string
const (
RestrictionEdit RestrictionOperation = `Edit`
RestrictionView = `View`
)
type Restriction struct {
User string `json:"userName"`
Group string `json:"groupName",omitempty`
}
type API struct {
rest *gopencils.Resource
// it's deprecated accordingly to Atlassian documentation,
// but it's only way to set permissions
json *gopencils.Resource
}
func NewAPI(baseURL string, username string, password string) *API {
auth := &gopencils.BasicAuth{username, password}
return &API{
rest: gopencils.Api(baseURL+"/rest/api", auth),
json: gopencils.Api(
baseURL+"/rpc/json-rpc/confluenceservice-v2",
auth,
),
}
}
func (api *API) findRootPage(space string) (*PageInfo, error) {
page, err := api.findPage(space, ``)
if err != nil {
return nil, fmt.Errorf(
`can't obtain first page from space '%s': %s`,
space,
err,
)
}
if len(page.Ancestors) == 0 {
return nil, fmt.Errorf(
"page '%s' from space '%s' has no parents",
page.Title,
space,
)
}
return &PageInfo{
ID: page.Ancestors[0].Id,
Title: page.Ancestors[0].Title,
}, nil
}
func (api *API) findPage(space string, title string) (*PageInfo, error) {
result := struct {
Results []PageInfo `json:"results"`
}{}
payload := map[string]string{
"spaceKey": space,
"expand": "ancestors,version",
}
if title != "" {
payload["title"] = title
}
request, err := api.rest.Res(
"content/", &result,
).Get(payload)
if err != nil {
return nil, err
}
if request.Raw.StatusCode == 401 {
return nil, fmt.Errorf("authentification failed")
}
if request.Raw.StatusCode != 200 {
return nil, fmt.Errorf(
"Confluence REST API returns unexpected non-200 HTTP status: %s",
request.Raw.Status,
)
}
if len(result.Results) == 0 {
return nil, nil
}
return &result.Results[0], nil
}
func (api *API) getPageByID(pageID string) (*PageInfo, error) {
request, err := api.rest.Res(
"content/"+pageID, &PageInfo{},
).Get(map[string]string{"expand": "ancestors,version"})
if err != nil {
return nil, err
}
if request.Raw.StatusCode == 401 {
return nil, fmt.Errorf("authentification failed")
}
if request.Raw.StatusCode == 404 {
return nil, fmt.Errorf(
"page with id '%s' not found, Confluence REST API returns 404",
pageID,
)
}
if request.Raw.StatusCode != 200 {
return nil, fmt.Errorf(
"Confluence REST API returns unexpected HTTP status: %s",
request.Raw.Status,
)
}
return request.Response.(*PageInfo), nil
}
func (api *API) createPage(
space string,
parent *PageInfo,
title string,
body string,
) (*PageInfo, error) {
payload := map[string]interface{}{
"type": "page",
"title": title,
"space": map[string]interface{}{
"key": space,
},
"body": map[string]interface{}{
"storage": map[string]interface{}{
"representation": "storage",
"value": body,
},
},
}
if parent != nil {
payload["ancestors"] = []map[string]interface{}{
{"id": parent.ID},
}
}
request, err := api.rest.Res(
"content/", &PageInfo{},
).Post(payload)
if err != nil {
return nil, err
}
if request.Raw.StatusCode != 200 {
output, _ := ioutil.ReadAll(request.Raw.Body)
defer request.Raw.Body.Close()
return nil, fmt.Errorf(
"Confluence REST API returns unexpected non-200 HTTP status: %s, "+
"output: %s",
request.Raw.Status, output,
)
}
return request.Response.(*PageInfo), nil
}
func (api *API) updatePage(
page *PageInfo, newContent string,
) error {
nextPageVersion := page.Version.Number + 1
if len(page.Ancestors) == 0 {
return fmt.Errorf(
"page '%s' info does not contain any information about parents",
page.ID,
)
}
// picking only the last one, which is required by confluence
oldAncestors := []map[string]interface{}{
{"id": page.Ancestors[len(page.Ancestors)-1].Id},
}
payload := map[string]interface{}{
"id": page.ID,
"type": "page",
"title": page.Title,
"version": map[string]interface{}{
"number": nextPageVersion,
"minorEdit": false,
},
"ancestors": oldAncestors,
"body": map[string]interface{}{
"storage": map[string]interface{}{
"value": string(newContent),
"representation": "storage",
},
},
}
request, err := api.rest.Res(
"content/"+page.ID, &map[string]interface{}{},
).Put(payload)
if err != nil {
return err
}
if request.Raw.StatusCode != 200 {
output, _ := ioutil.ReadAll(request.Raw.Body)
defer request.Raw.Body.Close()
return fmt.Errorf(
"Confluence REST API returns unexpected non-200 HTTP status: %s, "+
"output: %s",
request.Raw.Status, output,
)
}
return nil
}
func (api *API) setPagePermissions(
page *PageInfo,
operation RestrictionOperation,
restrictions []Restriction,
) error {
var result interface{}
request, err := api.json.Res(
"setContentPermissions", &result,
).Post([]interface{}{
page.ID,
operation,
restrictions,
})
if err != nil {
return err
}
if request.Raw.StatusCode != 200 {
output, _ := ioutil.ReadAll(request.Raw.Body)
defer request.Raw.Body.Close()
return fmt.Errorf(
"Confluence JSON RPC returns unexpected non-200 HTTP status: %s, "+
"output: %s",
request.Raw.Status, output,
)
}
if success, ok := result.(bool); !ok || !success {
return fmt.Errorf(
"'true' response expected, but '%v' encountered",
result,
)
}
return nil
}

19
macro_code.go Normal file
View File

@ -0,0 +1,19 @@
package main
import "fmt"
type MacroCode struct {
lang string
code []byte
}
func (code MacroCode) Render() string {
return fmt.Sprintf(
`<ac:structured-macro ac:name="code">`+
`<ac:parameter ac:name="language">%s</ac:parameter>`+
`<ac:parameter ac:name="collapse">false</ac:parameter>`+
`<ac:plain-text-body><![CDATA[%s]]></ac:plain-text-body>`+
`</ac:structured-macro>`,
code.lang, code.code,
)
}

29
macro_layout.go Normal file
View File

@ -0,0 +1,29 @@
package main
import "fmt"
type MacroLayout struct {
layout string
columns [][]byte
}
func (layout MacroLayout) Render() string {
switch layout.layout {
case "plain":
return string(layout.columns[0])
case "article":
fallthrough
default:
return fmt.Sprintf(
`<ac:layout>`+
`<ac:layout-section ac:type="two_right_sidebar">`+
`<ac:layout-cell>%s</ac:layout-cell>`+
`<ac:layout-cell></ac:layout-cell>`+
`</ac:layout-section>`+
`</ac:layout>`,
string(layout.columns[0]),
)
}
}

621
main.go
View File

@ -1,24 +1,27 @@
package main package main
import ( import (
"bufio"
"bytes"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"log"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/bndr/gopencils" "github.com/kovetskiy/godocs"
"github.com/docopt/docopt-go" "github.com/kovetskiy/lorg"
"github.com/reconquest/colorgful"
"github.com/reconquest/ser-go"
"github.com/russross/blackfriday" "github.com/russross/blackfriday"
"github.com/zazab/zhash" "github.com/zazab/zhash"
) )
const ( const (
usage = `Mark usage = `mark - tool for updating Atlassian Confluence pages from markdown.
Mark it's tool for syncing your markdown files with Atlassian Confluence pages.
This is very usable if you store documentation to your orthodox software in git This is very usable if you store documentation to your orthodox software in git
repository and don't want to do a handjob with updating Confluence page using repository and don't want to do a handjob with updating Confluence page using
@ -26,36 +29,68 @@ fucking tinymce wysiwyg enterprise core editor.
You can store a user credentials in the configuration file, which should be You can store a user credentials in the configuration file, which should be
located in ~/.config/mark with following format: located in ~/.config/mark with following format:
username = "smith" username = "smith"
password = "matrixishere" password = "matrixishere"
where 'smith' it's your username, and 'matrixishere' it's your password. base_url = "http://confluence.local"
where 'smith' it's your username, 'matrixishere' it's your password and
'http://confluence.local' is base URL for your Confluence instance.
Mark can read Confluence page URL and markdown file path from another specified Mark can read Confluence page URL and markdown file path from another specified
configuration file, which you can specify using -c <file> flag. It is very configuration file, which you can specify using -c <file> flag. It is very
usable for git hooks. That file should have following format: usable for git hooks. That file should have following format:
url = "http://confluence.local/pages/viewpage.action?pageId=123456" url = "http://confluence.local/pages/viewpage.action?pageId=123456"
file = "docs/README.md" file = "docs/README.md"
Mark understands extended file format, which, still being valid markdown,
contains several metadata headers, which can be used to locate page inside
Confluence instance and update it accordingly.
File in extended format should follow specification:
[]:# (Space: <space key>)
[]:# (Parent: <parent 1>)
[]:# (Parent: <parent 2>)
[]:# (Title: <title>)
<page contents>
There can be any number of 'Parent' headers, if mark can't find specified
parent by title, it will be created.
Also, optional following headers are supported:
* []:# (Layout: <article|plain>)
- (default) article: content will be put in narrow column for ease of
reading;
- plain: content will fill all page;
Usage: Usage:
mark [--dry-run] [-u <username>] [-p <password>] -l <url> -f <file> mark [options] [-u <username>] [-p <password>] [-k] [-l <url>] -f <file>
mark [--dry-run] [-u <username>] [-p <password>] -c <file> mark [options] [-u <username>] [-p <password>] [-k] [-n] -c <file>
mark -v | --version mark -v | --version
mark -h | --help mark -h | --help
Options: Options:
-u <username> Use specified username for updating Confluence page. -u <username> Use specified username for updating Confluence page.
-p <password> Use specified password for updating Confluence page. -p <password> Use specified password for updating Confluence page.
-l <url> Edit specified Confluence page. -l <url> Edit specified Confluence page.
-f <file> Use specified markdown file for converting to html. If -l is not specified, file should contain metadata (see
-c <file> Specify configuration file which should be used for reading above).
Confluence page URL and markdown file path. -f <file> Use specified markdown file for converting to html.
--dry-run Show resulting HTML and don't update Confluence page content. -c <file> Specify configuration file which should be used for reading
-h --help Show this screen and call 911. Confluence page URL and markdown file path.
-v --version Show version. -k Lock page editing to current user only to prevent accidental
manual edits over Confluence Web UI.
--dry-run Show resulting HTML and don't update Confluence page content.
--trace Enable trace logs.
-h --help Show this screen and call 911.
-v --version Show version.
` `
) )
type PageInfo struct { type PageInfo struct {
ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Version struct { Version struct {
@ -63,12 +98,35 @@ type PageInfo struct {
} `json:"version"` } `json:"version"`
Ancestors []struct { Ancestors []struct {
Id string `json:"id"` Id string `json:"id"`
Title string `json:"title"`
} `json:"ancestors"` } `json:"ancestors"`
Links struct {
Full string `json:"webui"`
} `json:"_links"`
} }
const (
HeaderParent string = `Parent`
HeaderSpace = `Space`
HeaderTitle = `Title`
HeaderLayout = `Layout`
)
type Meta struct {
Parents []string
Space string
Title string
Layout string
}
var (
logger = lorg.NewLog()
)
func main() { func main() {
args, err := docopt.Parse(usage, nil, true, "mark 1.0", false, true) args, err := godocs.Parse(usage, "mark 1.0", godocs.UsePager)
if err != nil { if err != nil {
panic(err) panic(err)
} }
@ -79,24 +137,43 @@ func main() {
targetURL, _ = args["-l"].(string) targetURL, _ = args["-l"].(string)
targetFile, _ = args["-f"].(string) targetFile, _ = args["-f"].(string)
dryRun = args["--dry-run"].(bool) dryRun = args["--dry-run"].(bool)
editLock = args["-k"].(bool)
trace = args["--trace"].(bool)
optionsFile, shouldReadOptions = args["-c"].(string) optionsFile, shouldReadOptions = args["-c"].(string)
) )
if trace {
logger.SetLevel(lorg.LevelTrace)
}
logFormat := `${time} ${level:[%s]:right:true} %s`
if format := os.Getenv("LOG_FORMAT"); format != "" {
logFormat = format
}
logger.SetFormat(colorgful.MustApplyDefaultTheme(
logFormat,
colorgful.Default,
))
config, err := getConfig(filepath.Join(os.Getenv("HOME"), ".config/mark")) config, err := getConfig(filepath.Join(os.Getenv("HOME"), ".config/mark"))
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
log.Fatal(err) logger.Fatal(err)
} }
if shouldReadOptions { if shouldReadOptions {
optionsConfig, err := getConfig(optionsFile) optionsConfig, err := getConfig(optionsFile)
if err != nil { if err != nil {
log.Fatalf("can't read options config '%s': %s", optionsFile, err) logger.Fatalf(
"can't read options config '%s': %s", optionsFile, err,
)
} }
targetURL, err = optionsConfig.GetString("url") targetURL, err = optionsConfig.GetString("url")
if err != nil { if err != nil {
log.Fatal( logger.Fatal(
"can't read `url` value from options file (%s): %s", "can't read `url` value from options file (%s): %s",
optionsFile, err, optionsFile, err,
) )
@ -104,7 +181,7 @@ func main() {
targetFile, err = optionsConfig.GetString("file") targetFile, err = optionsConfig.GetString("file")
if err != nil { if err != nil {
log.Fatal( logger.Fatal(
"can't read `file` value from options file (%s): %s", "can't read `file` value from options file (%s): %s",
optionsFile, err, optionsFile, err,
) )
@ -113,10 +190,15 @@ func main() {
markdownData, err := ioutil.ReadFile(targetFile) markdownData, err := ioutil.ReadFile(targetFile)
if err != nil { if err != nil {
log.Fatal(err) logger.Fatal(err)
} }
htmlData := blackfriday.MarkdownCommon(markdownData) meta, err := extractMeta(markdownData)
if err != nil {
logger.Fatal(err)
}
htmlData := compileMarkdown(markdownData)
if dryRun { if dryRun {
fmt.Println(string(htmlData)) fmt.Println(string(htmlData))
@ -127,13 +209,15 @@ func main() {
username, err = config.GetString("username") username, err = config.GetString("username")
if err != nil { if err != nil {
if zhash.IsNotFound(err) { if zhash.IsNotFound(err) {
log.Fatal( logger.Fatal(
"Confluence username should be specified using -u " + "Confluence username should be specified using -u " +
"flag or be stored in configuration file", "flag or be stored in configuration file",
) )
} }
log.Fatalf("can't read username configuration variable: %s", err) logger.Fatalf(
"can't read username configuration variable: %s", err,
)
} }
} }
@ -141,132 +225,353 @@ func main() {
password, err = config.GetString("password") password, err = config.GetString("password")
if err != nil { if err != nil {
if zhash.IsNotFound(err) { if zhash.IsNotFound(err) {
log.Fatal( logger.Fatal(
"Confluence password should be specified using -p " + "Confluence password should be specified using -p " +
"flag or be stored in configuration file", "flag or be stored in configuration file",
) )
} }
log.Fatalf("can't read password configuration variable: %s", err) logger.Fatalf(
"can't read password configuration variable: %s", err,
)
} }
} }
url, err := url.Parse(targetURL) url, err := url.Parse(targetURL)
if err != nil { if err != nil {
log.Fatal(err) logger.Fatal(err)
} }
api := gopencils.Api( baseURL := url.Scheme + "://" + url.Host
"http://"+url.Host+"/rest/api",
&gopencils.BasicAuth{username, password}, if url.Host == "" {
) baseURL, err = config.GetString("base_url")
if err != nil {
if zhash.IsNotFound(err) {
logger.Fatal(
"Confluence base URL should be specified using -l " +
"flag or be stored in configuration file",
)
}
logger.Fatalf(
"can't read base_url configuration variable: %s", err,
)
}
}
baseURL = strings.TrimRight(baseURL, `/`)
api := NewAPI(baseURL, username, password)
pageID := url.Query().Get("pageId") pageID := url.Query().Get("pageId")
if pageID == "" {
log.Fatalf("URL should contains 'pageId' parameter") if pageID != "" && meta != nil {
logger.Warningf(
`specified file contains metadata, ` +
`but it will be ignore due specified command line URL`,
)
meta = nil
} }
pageInfo, err := getPageInfo(api, pageID) if pageID == "" && meta == nil {
logger.Fatalf(
`specified file doesn't contain metadata ` +
`and URL is not specified via command line ` +
`or doesn't contain pageId GET-parameter`,
)
}
var target *PageInfo
if meta != nil {
page, err := resolvePage(api, meta)
if err != nil {
logger.Fatal(err)
}
target = page
} else {
if pageID == "" {
logger.Fatalf("URL should provide 'pageId' GET-parameter")
}
page, err := api.getPageByID(pageID)
if err != nil {
logger.Fatal(err)
}
target = page
}
err = api.updatePage(
target,
MacroLayout{meta.Layout, [][]byte{htmlData}}.Render(),
)
if err != nil { if err != nil {
log.Fatal(err) logger.Fatal(err)
} }
err = updatePage(api, pageID, pageInfo, htmlData) if editLock {
if err != nil { logger.Infof(
log.Fatal(err) `edit locked on page '%s' by user '%s' to prevent manual edits`,
target.Title,
username,
)
err := api.setPagePermissions(target, RestrictionEdit, []Restriction{
{User: username},
})
if err != nil {
logger.Fatal(err)
}
} }
fmt.Printf("page %s successfully updated\n", targetURL) fmt.Printf(
"page successfully updated: %s\n",
baseURL+target.Links.Full,
)
} }
func updatePage( // compileMarkdown will replace tags like <ac:rich-tech-body> with escaped
api *gopencils.Resource, pageID string, // equivalent, because blackfriday markdown parser replaces that tags with
pageInfo PageInfo, newContent []byte, // <a href="ac:rich-text-body">ac:rich-text-body</a> for whatever reason.
) error { func compileMarkdown(markdown []byte) []byte {
nextPageVersion := pageInfo.Version.Number + 1 colon := regexp.MustCompile(`---BLACKFRIDAY-COLON---`)
if len(pageInfo.Ancestors) == 0 { tags := regexp.MustCompile(`<(/?\S+):(\S+)>`)
return fmt.Errorf(
"Page '%s' info does not contain any information about parents", markdown = tags.ReplaceAll(
pageID, markdown,
) []byte(`<$1`+colon.String()+`$2>`),
)
renderer := ConfluenceRenderer{
blackfriday.HtmlRenderer(
blackfriday.HTML_USE_XHTML|
blackfriday.HTML_USE_SMARTYPANTS|
blackfriday.HTML_SMARTYPANTS_FRACTIONS|
blackfriday.HTML_SMARTYPANTS_DASHES|
blackfriday.HTML_SMARTYPANTS_LATEX_DASHES,
"", "",
),
} }
// picking only the last one, which is required by confluence html := blackfriday.MarkdownOptions(
oldAncestors := []map[string]interface{}{ markdown,
{"id": pageInfo.Ancestors[len(pageInfo.Ancestors)-1].Id}, renderer,
} blackfriday.Options{
Extensions: blackfriday.EXTENSION_NO_INTRA_EMPHASIS |
payload := map[string]interface{}{ blackfriday.EXTENSION_TABLES |
"id": pageID, blackfriday.EXTENSION_FENCED_CODE |
"type": "page", blackfriday.EXTENSION_AUTOLINK |
"title": pageInfo.Title, blackfriday.EXTENSION_STRIKETHROUGH |
"version": map[string]interface{}{ blackfriday.EXTENSION_SPACE_HEADERS |
"number": nextPageVersion, blackfriday.EXTENSION_HEADER_IDS |
"minorEdit": false, blackfriday.EXTENSION_BACKSLASH_LINE_BREAK |
blackfriday.EXTENSION_DEFINITION_LISTS,
}, },
"ancestors": oldAncestors, )
"body": map[string]interface{}{
"storage": map[string]interface{}{
"value": string(newContent),
"representation": "storage",
},
},
}
request, err := api.Res( html = colon.ReplaceAll(html, []byte(`:`))
"content/"+pageID, &map[string]interface{}{},
).Put(payload)
if err != nil {
return err
}
if request.Raw.StatusCode != 200 { return html
output, _ := ioutil.ReadAll(request.Raw.Body)
defer request.Raw.Body.Close()
return fmt.Errorf(
"Confluence REST API returns unexpected HTTP status: %s, "+
"output: %s",
request.Raw.Status, output,
)
}
return nil
} }
func getPageInfo( func resolvePage(api *API, meta *Meta) (*PageInfo, error) {
api *gopencils.Resource, pageID string, page, err := api.findPage(meta.Space, meta.Title)
) (PageInfo, error) {
request, err := api.Res(
"content/"+pageID, &PageInfo{},
).Get(map[string]string{"expand": "ancestors,version"})
if err != nil { if err != nil {
return PageInfo{}, err return nil, ser.Errorf(
} err,
"error during finding page '%s': %s",
if request.Raw.StatusCode == 401 { meta.Title,
return PageInfo{}, fmt.Errorf("authentification failed")
}
if request.Raw.StatusCode == 404 {
return PageInfo{}, fmt.Errorf(
"page with id '%s' not found, Confluence REST API returns 404",
pageID,
) )
} }
if request.Raw.StatusCode != 200 { ancestry := meta.Parents
return PageInfo{}, fmt.Errorf( if page != nil {
"Confluence REST API returns unexpected HTTP status: %s", ancestry = append(ancestry, page.Title)
request.Raw.Status, }
if len(ancestry) > 0 {
page, err := validateAncestry(
api,
meta.Space,
ancestry,
)
if err != nil {
return nil, err
}
if page == nil {
logger.Warningf(
"page '%s' is not found ",
meta.Parents[len(ancestry)-1],
)
}
path := meta.Parents
path = append(path, meta.Title)
logger.Debugf(
"resolving page path: ??? > %s",
strings.Join(path, ` > `),
) )
} }
response := request.Response.(*PageInfo) parent, err := ensureAncestry(
api,
meta.Space,
meta.Parents,
)
if err != nil {
return nil, ser.Errorf(
err,
"can't create ancestry tree: %s; error: %s",
strings.Join(meta.Parents, ` > `),
)
}
return *response, nil titles := []string{}
for _, page := range parent.Ancestors {
titles = append(titles, page.Title)
}
titles = append(titles, parent.Title)
logger.Infof(
"page will be stored under path: %s > %s",
strings.Join(titles, ` > `),
meta.Title,
)
if page == nil {
page, err := api.createPage(meta.Space, parent, meta.Title, ``)
if err != nil {
return nil, ser.Errorf(
err,
"can't create page '%s': %s",
meta.Title,
)
}
return page, nil
}
return page, nil
}
func ensureAncestry(
api *API,
space string,
ancestry []string,
) (*PageInfo, error) {
var parent *PageInfo
rest := ancestry
for i, title := range ancestry {
page, err := api.findPage(space, title)
if err != nil {
return nil, ser.Errorf(
err,
`error during finding parent page with title '%s': %s`,
title,
)
}
if page == nil {
break
}
logger.Tracef("parent page '%s' exists: %s", title, page.Links.Full)
rest = ancestry[i:]
parent = page
}
if parent != nil {
rest = rest[1:]
} else {
page, err := api.findRootPage(space)
if err != nil {
return nil, ser.Errorf(
err,
"can't find root page for space '%s': %s", space,
)
}
parent = page
}
if len(rest) == 0 {
return parent, nil
}
logger.Debugf(
"empty pages under '%s' to be created: %s",
parent.Title,
strings.Join(rest, ` > `),
)
for _, title := range rest {
page, err := api.createPage(space, parent, title, ``)
if err != nil {
return nil, ser.Errorf(
err,
`error during creating parent page with title '%s': %s`,
title,
)
}
parent = page
}
return parent, nil
}
func validateAncestry(
api *API,
space string,
ancestry []string,
) (*PageInfo, error) {
page, err := api.findPage(space, ancestry[len(ancestry)-1])
if err != nil {
return nil, err
}
if page == nil {
return nil, nil
}
if len(page.Ancestors) < 1 {
return nil, fmt.Errorf(`page '%s' has no parents`, page.Title)
}
if len(page.Ancestors) < len(ancestry) {
return nil, fmt.Errorf(
"page '%s' has fewer parents than specified: %s",
page.Title,
strings.Join(ancestry, ` > `),
)
}
// skipping root article title
for i, ancestor := range page.Ancestors[1:len(ancestry)] {
if ancestor.Title != ancestry[i] {
return nil, fmt.Errorf(
"broken ancestry tree; expected tree: %s; "+
"encountered '%s' at position of '%s'",
strings.Join(ancestry, ` > `),
ancestor.Title,
ancestry[i],
)
}
}
return page, nil
} }
func getConfig(path string) (zhash.Hash, error) { func getConfig(path string) (zhash.Hash, error) {
@ -277,8 +582,80 @@ func getConfig(path string) (zhash.Hash, error) {
return zhash.NewHash(), err return zhash.NewHash(), err
} }
return zhash.NewHash(), fmt.Errorf("can't decode toml file: %s", err) return zhash.NewHash(), ser.Errorf(
err,
"can't decode toml file: %s",
)
} }
return zhash.HashFromMap(configData), nil return zhash.HashFromMap(configData), nil
} }
func extractMeta(data []byte) (*Meta, error) {
headerPattern := regexp.MustCompile(`\[\]:\s*#\s*\(([^:]+):\s*(.*)\)`)
var meta *Meta
scanner := bufio.NewScanner(bytes.NewBuffer(data))
for scanner.Scan() {
line := scanner.Text()
if err := scanner.Err(); err != nil {
return nil, err
}
matches := headerPattern.FindStringSubmatch(line)
if matches == nil {
break
}
if meta == nil {
meta = &Meta{}
}
header := strings.Title(matches[1])
switch header {
case HeaderParent:
meta.Parents = append(meta.Parents, matches[2])
case HeaderSpace:
meta.Space = strings.ToUpper(matches[2])
case HeaderTitle:
meta.Title = strings.TrimSpace(matches[2])
case HeaderLayout:
meta.Layout = strings.TrimSpace(matches[2])
default:
logger.Errorf(
`encountered unknown header '%s' line: %#v`,
header,
line,
)
continue
}
}
if meta == nil {
return nil, nil
}
if meta.Space == "" {
return nil, fmt.Errorf(
"space key is not set (%s header is not set)",
HeaderSpace,
)
}
if meta.Title == "" {
return nil, fmt.Errorf(
"page title is not set (%s header is not set)",
HeaderTitle,
)
}
return meta, nil
}

19
renderer.go Normal file
View File

@ -0,0 +1,19 @@
package main
import (
"bytes"
"github.com/russross/blackfriday"
)
type ConfluenceRenderer struct {
blackfriday.Renderer
}
func (renderer ConfluenceRenderer) BlockCode(
out *bytes.Buffer,
text []byte,
lang string,
) {
out.WriteString(MacroCode{lang, text}.Render())
}