package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"github.com/coopernurse/gorp"
_ "github.com/mattn/go-sqlite3"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
)
/*
TODO:
- Move structs to own file
- Move db stuff to own file
- Stylesheets
*/
// CSS Paths
var cssBasePath = "https://developer.salesforce.com/resource/stylesheets"
var cssFiles = []string{"holygrail.min.css", "docs.min.css", "syntax-highlighter.min.css"}
// JSON Structs
// AtlasTOC represents the meta documenation from Salesforce
type AtlasTOC struct {
AvailableVersions []VersionInfo `json:"available_versions"`
Content string
ContentDocumentID string `json:"content_document_id"`
Deliverable string
DocTitle string `json:"doc_title"`
Locale string
Language LanguageInfo
PDFUrl string `json:"pdf_url"`
TOCEntries []TOCEntry `json:"toc"`
Title string
Version VersionInfo
}
// LanguageInfo contains information for linking and displaying the language
type LanguageInfo struct {
Label string
Locale string
URL string
}
// VersionInfo representes a Salesforce documentation version
type VersionInfo struct {
DocVersion string `json:"doc_version"`
ReleaseVersion string `json:"release_version"`
VersionText string `json:"version_text"`
VersionURL string `json:"version_url"`
}
// TOCEntry represents a single Table of Contents item
type TOCEntry struct {
Text string
ID string
LinkAttr LinkAttr `json:"a_attr,omitempty"`
Children []TOCEntry
ComputedFirstTopic bool
ComputedResetPageLayout bool
}
// LinkAttr represents all attributes bound to a link
type LinkAttr struct {
Href string
}
// TOCContent contains content information for a piece of documenation
type TOCContent struct {
ID string
Title string
Content string
}
// Sqlite Struct
// SearchIndex is the database table that indexes the docs
type SearchIndex struct {
ID int64 `db:id`
Name string `db:name`
Type string `db:type`
Path string `db:path`
}
var dbmap *gorp.DbMap
var wg sync.WaitGroup
const maxConcurrency = 16
var throttle = make(chan int, maxConcurrency)
func parseFlags() (locale string, deliverables []string, silent bool) {
flag.StringVar(
&locale, "locale", "en-us",
"locale to use for documentation (default: en-us)",
)
flag.BoolVar(
&silent, "silent", false, "this flag supresses warning messages",
)
flag.Parse()
// All other args are for deliverables
// apexcode or pages
deliverables = flag.Args()
return
}
// getTOC Retrieves the TOC JSON and Unmarshals it
func getTOC(locale string, deliverable string) (toc *AtlasTOC, err error) {
var tocURL = fmt.Sprintf("https://developer.salesforce.com/docs/get_document/atlas.%s.%s.meta", locale, deliverable)
resp, err := http.Get(tocURL)
if err != nil {
return
}
// Read the downloaded JSON
defer resp.Body.Close()
contents, err := ioutil.ReadAll(resp.Body)
if err != nil {
return
}
// Load into Struct
toc = new(AtlasTOC)
err = json.Unmarshal([]byte(contents), toc)
return
}
// verifyVersion ensures that the version retrieved is the latest
func verifyVersion(toc *AtlasTOC) error {
currentVersion := toc.Version.DocVersion
topVersion := toc.AvailableVersions[0].DocVersion
if currentVersion != topVersion {
return NewFormatedError("verifyVersion : retrieved version is not the latest. Found %s, latest is %s", currentVersion, topVersion)
}
return nil
}
func printSuccess(toc *AtlasTOC) {
fmt.Println("Success:", toc.DocTitle, "-", toc.Version.VersionText)
}
func saveMainContent(toc *AtlasTOC) {
filePath := fmt.Sprintf("%s.html", toc.Deliverable)
// Make sure file doesn't exist first
if _, err := os.Stat(filePath); os.IsNotExist(err) {
content := toc.Content
err = os.MkdirAll(filepath.Dir(filePath), 0755)
ExitIfError(err)
// TODO: Do something to format full page here
ofile, err := os.Create(filePath)
ExitIfError(err)
defer ofile.Close()
_, err = ofile.WriteString(
"" +
content,
)
ExitIfError(err)
}
}
func main() {
locale, deliverables, silent := parseFlags()
if silent {
WithoutWarning()
}
// Download CSS
for _, cssFile := range cssFiles {
throttle <- 1
wg.Add(1)
go downloadCSS(cssFile, &wg)
}
// Init the Sqlite db
dbmap = initDb()
err := dbmap.TruncateTables()
ExitIfError(err)
for _, deliverable := range deliverables {
toc, err := getTOC(locale, deliverable)
ExitIfError(err)
saveMainContent(toc)
err = verifyVersion(toc)
WarnIfError(err)
// Download each entry
for _, entry := range toc.TOCEntries {
if entry.ID == "apex_reference" || entry.ID == "pages_compref" {
processChildReferences(entry, nil, toc)
}
}
printSuccess(toc)
}
wg.Wait()
}
// SupportedType contains information for generating indexes for types we care about
type SupportedType struct {
TypeName, TitleSuffix string
PushName, AppendParents, IsContainer, NoTrim, ShowNamespace, ParseContent bool
}
var supportedTypes = []SupportedType{
SupportedType{
TypeName: "Method",
TitleSuffix: "Methods",
AppendParents: true,
IsContainer: true,
ShowNamespace: true,
},
SupportedType{
TypeName: "Constructor",
TitleSuffix: "Constructors",
AppendParents: true,
IsContainer: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Class",
TitleSuffix: "Class",
PushName: true,
AppendParents: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Namespace",
TitleSuffix: "Namespace",
PushName: true,
AppendParents: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Interface",
TitleSuffix: "Interface",
PushName: true,
AppendParents: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Statement",
TitleSuffix: "Statement",
ShowNamespace: false,
},
SupportedType{
TypeName: "Enum",
TitleSuffix: "Enum",
AppendParents: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Property",
TitleSuffix: "Properties",
AppendParents: true,
IsContainer: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Guide",
TitleSuffix: "Example Implementation",
NoTrim: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Statement",
TitleSuffix: "Statements",
NoTrim: true,
AppendParents: false,
IsContainer: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Field",
TitleSuffix: "Fields",
AppendParents: true,
PushName: true,
IsContainer: true,
ShowNamespace: false,
},
SupportedType{
TypeName: "Exception",
TitleSuffix: "Exceptions",
NoTrim: true,
AppendParents: true,
ShowNamespace: false,
ParseContent: true,
},
SupportedType{
TypeName: "Constant",
TitleSuffix: "Constants",
NoTrim: true,
AppendParents: true,
ShowNamespace: false,
ParseContent: true,
},
SupportedType{
TypeName: "Class",
TitleSuffix: "Class (Base Email Methods)",
PushName: true,
AppendParents: true,
ShowNamespace: false,
},
}
// IsType indicates that the TOCEntry is of a given SupportedType
// This is done by checking the suffix of the entry text
func (entry TOCEntry) IsType(t SupportedType) bool {
return strings.HasSuffix(entry.Text, t.TitleSuffix)
}
// CleanTitle trims known suffix from TOCEntry titles
func (entry TOCEntry) CleanTitle(t SupportedType) string {
if t.NoTrim {
return entry.Text
}
return strings.TrimSuffix(entry.Text, " "+t.TitleSuffix)
}
// GetRelLink extracts only the relative link from the Link Href
func (entry TOCEntry) GetRelLink(removeAnchor bool) (relLink string) {
if entry.LinkAttr.Href == "" {
return
}
// Get the JSON file
relLink = entry.LinkAttr.Href
if removeAnchor {
anchorIndex := strings.LastIndex(relLink, "#")
if anchorIndex > 0 {
relLink = relLink[0:anchorIndex]
}
}
return
}
// GetContent retrieves Content for this TOCEntry from the API
func (entry TOCEntry) GetContent(toc *AtlasTOC) (content *TOCContent, err error) {
relLink := entry.GetRelLink(true)
if relLink == "" {
return
}
url := fmt.Sprintf(
"https://developer.salesforce.com/docs/get_document_content/%s/%s/%s/%s",
toc.Deliverable,
relLink,
toc.Locale,
toc.Version.DocVersion,
)
// fmt.Println(url)
resp, err := http.Get(url)
if err != nil {
return
}
// Read the downloaded JSON
defer resp.Body.Close()
contents, err := ioutil.ReadAll(resp.Body)
if err != nil {
return
}
// Load into Struct
content = new(TOCContent)
err = json.Unmarshal([]byte(contents), content)
if err != nil {
fmt.Println("Error reading JSON")
fmt.Println(resp.Status)
fmt.Println(url)
fmt.Println(string(contents))
return
}
return
}
func getEntryType(entry TOCEntry) (*SupportedType, error) {
if strings.HasPrefix(entry.ID, "pages_compref_") {
return &SupportedType{
TypeName: "Tag",
NoTrim: true,
}, nil
}
for _, t := range supportedTypes {
if entry.IsType(t) {
return &t, nil
}
}
return nil, NewTypeNotFoundError(entry)
}
var entryHierarchy []string
func processChildReferences(entry TOCEntry, entryType *SupportedType, toc *AtlasTOC) {
if entryType != nil && entryType.PushName {
entryHierarchy = append(entryHierarchy, entry.CleanTitle(*entryType))
}
for _, child := range entry.Children {
// fmt.Println("Processing: " + child.Text)
var err error
var childType *SupportedType
if child.LinkAttr.Href != "" {
throttle <- 1
wg.Add(1)
go downloadContent(child, toc, &wg)
childType, err = getEntryType(child)
if childType == nil && (entryType != nil && entryType.IsContainer) {
saveSearchIndex(dbmap, child, entryType, toc)
} else if childType != nil && !childType.IsContainer {
saveSearchIndex(dbmap, child, childType, toc)
} else {
WarnIfError(err)
}
}
if len(child.Children) > 0 {
processChildReferences(child, childType, toc)
}
}
// fmt.Println("Done processing children for " + entry.Text)
if entryType != nil && entryType.PushName {
entryHierarchy = entryHierarchy[:len(entryHierarchy)-1]
}
}
// GetContentFilepath returns the filepath that should be used for the content
func (entry TOCEntry) GetContentFilepath(toc *AtlasTOC, removeAnchor bool) string {
relLink := entry.GetRelLink(removeAnchor)
if relLink == "" {
ExitIfError(NewFormatedError("Link not found for %s", entry.ID))
}
return fmt.Sprintf("atlas.%s.%s.meta/%s/%s", toc.Locale, toc.Deliverable, toc.Deliverable, relLink)
}
func downloadContent(entry TOCEntry, toc *AtlasTOC, wg *sync.WaitGroup) {
defer wg.Done()
filePath := entry.GetContentFilepath(toc, true)
// Make sure file doesn't exist first
if _, err := os.Stat(filePath); os.IsNotExist(err) {
content, err := entry.GetContent(toc)
ExitIfError(err)
err = os.MkdirAll(filepath.Dir(filePath), 0755)
ExitIfError(err)
// TODO: Do something to format full page here
ofile, err := os.Create(filePath)
ExitIfError(err)
header := "" +
"\n"
for _, cssFile := range cssFiles {
header += fmt.Sprintf("", cssFile)
}
header += ""
defer ofile.Close()
_, err = ofile.WriteString(
header + content.Content,
)
ExitIfError(err)
}
<-throttle
}
func downloadCSS(fileName string, wg *sync.WaitGroup) {
defer wg.Done()
if _, err := os.Stat(fileName); os.IsNotExist(err) {
err = os.MkdirAll(filepath.Dir(fileName), 0755)
ExitIfError(err)
ofile, err := os.Create(fileName)
ExitIfError(err)
defer ofile.Close()
cssURL := cssBasePath + "/" + fileName
response, err := http.Get(cssURL)
ExitIfError(err)
defer response.Body.Close()
_, err = io.Copy(ofile, response.Body)
ExitIfError(err)
}
<-throttle
}
/**********************
Database
**********************/
func saveSearchIndex(dbmap *gorp.DbMap, entry TOCEntry, entryType *SupportedType, toc *AtlasTOC) {
if entry.LinkAttr.Href == "" || entryType == nil {
return
}
relLink := entry.GetContentFilepath(toc, false)
name := entry.CleanTitle(*entryType)
if entryType.ShowNamespace && len(entryHierarchy) > 0 {
// Show namespace for methods
name = entryHierarchy[len(entryHierarchy)-1] + "." + name
}
// fmt.Println("Storing: " + name)
si := SearchIndex{
Name: name,
Type: entryType.TypeName,
Path: relLink,
}
dbmap.Insert(&si)
}
func initDb() *gorp.DbMap {
db, err := sql.Open("sqlite3", "docSet.dsidx")
ExitIfError(err)
dbmap := &gorp.DbMap{Db: db, Dialect: gorp.SqliteDialect{}}
dbmap.AddTableWithName(SearchIndex{}, "searchIndex").SetKeys(true, "ID")
err = dbmap.CreateTablesIfNotExists()
ExitIfError(err)
err = dbmap.TruncateTables()
ExitIfError(err)
return dbmap
}