diff --git a/api/posts.go b/api/posts.go index 851dead..3506d67 100644 --- a/api/posts.go +++ b/api/posts.go @@ -1,284 +1,284 @@ package api import ( "bufio" "fmt" "io" "io/ioutil" "os" "path/filepath" "strings" "time" "github.com/writeas/writeas-cli/config" "github.com/writeas/writeas-cli/fileutils" "github.com/writeas/writeas-cli/log" writeas "go.code.as/writeas.v2" cli "gopkg.in/urfave/cli.v1" ) const ( postsFile = "posts.psv" separator = `|` ) // Post holds the basic authentication information for a Write.as post. type Post struct { ID string EditToken string } // RemotePost holds addition information about published // posts type RemotePost struct { Post Title, Excerpt, Slug, Collection, EditToken string Updated time.Time } func AddPost(c *cli.Context, id, token string) error { f, err := os.OpenFile(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), postsFile), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) if err != nil { return fmt.Errorf("Error creating local posts list: %s", err) } defer f.Close() l := fmt.Sprintf("%s%s%s\n", id, separator, token) if _, err = f.WriteString(l); err != nil { return fmt.Errorf("Error writing to local posts list: %s", err) } return nil } func TokenFromID(c *cli.Context, id string) string { post := fileutils.FindLine(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), postsFile), id) if post == "" { return "" } parts := strings.Split(post, separator) if len(parts) < 2 { return "" } return parts[1] } func removePost(path, id string) { fileutils.RemoveLine(filepath.Join(config.UserDataDir(path), postsFile), id) } func GetPosts(c *cli.Context) *[]Post { lines := fileutils.ReadData(filepath.Join(config.UserDataDir(c.App.ExtraInfo()["configDir"]), postsFile)) posts := []Post{} if lines != nil && len(*lines) > 0 { parts := make([]string, 2) for _, l := range *lines { parts = strings.Split(l, separator) if len(parts) < 2 { continue } posts = append(posts, Post{ID: parts[0], EditToken: parts[1]}) } } return &posts } func GetUserPosts(c *cli.Context) ([]RemotePost, error) { waposts, err := DoFetchPosts(c) if err != nil { return nil, err } if len(waposts) == 0 { return nil, nil } posts := []RemotePost{} for _, p := range waposts { post := RemotePost{ Post: Post{ ID: p.ID, EditToken: p.Token, }, Title: p.Title, Excerpt: getExcerpt(p.Content), Slug: p.Slug, Updated: p.Updated, } if p.Collection != nil { post.Collection = p.Collection.Alias } posts = append(posts, post) } return posts, nil } // getExcerpt takes in a content string and returns // a concatenated version. limited to no more than // two lines of 80 chars each. delimited by '...' func getExcerpt(input string) string { length := len(input) if length <= 80 { return input - } else if length <= 160 { + } else if length < 160 { ln1, idx := trimToLength(input, 80) if idx == -1 { idx = 80 } ln2, _ := trimToLength(input[idx:], 80) return ln1 + "\n" + ln2 } else { - excerpt := input[:157] + excerpt := input[:158] ln1, idx := trimToLength(excerpt, 80) if idx == -1 { idx = 80 } ln2, _ := trimToLength(excerpt[idx:], 80) return ln1 + "\n" + ln2 + "..." } } func trimToLength(in string, l int) (string, int) { c := []rune(in) spaceIdx := -1 length := len(c) if length <= l { return in, spaceIdx } for i := l; i > 0; i-- { if c[i] == ' ' { spaceIdx = i break } } if spaceIdx > -1 { c = c[:spaceIdx] } return string(c), spaceIdx } func ComposeNewPost() (string, *[]byte) { f, err := fileutils.TempFile(os.TempDir(), "WApost", "txt") if err != nil { if config.Debug() { panic(err) } else { log.Errorln("Error creating temp file: %s", err) return "", nil } } f.Close() cmd := config.EditPostCmd(f.Name()) if cmd == nil { os.Remove(f.Name()) fmt.Println(config.NoEditorErr) return "", nil } cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr if err := cmd.Start(); err != nil { os.Remove(f.Name()) if config.Debug() { panic(err) } else { log.Errorln("Error starting editor: %s", err) return "", nil } } // If something fails past this point, the temporary post file won't be // removed automatically. Calling function should handle this. if err := cmd.Wait(); err != nil { if config.Debug() { panic(err) } else { log.Errorln("Editor finished with error: %s", err) return "", nil } } post, err := ioutil.ReadFile(f.Name()) if err != nil { if config.Debug() { panic(err) } else { log.Errorln("Error reading post: %s", err) return "", nil } } return f.Name(), &post } func WritePost(postsDir string, p *writeas.Post) error { postFilename := p.ID collDir := "" if p.Collection != nil { postFilename = p.Slug collDir = p.Collection.Alias } postFilename += PostFileExt txtFile := p.Content if p.Title != "" { txtFile = "# " + p.Title + "\n\n" + txtFile } return ioutil.WriteFile(filepath.Join(postsDir, collDir, postFilename), []byte(txtFile), 0644) } func HandlePost(fullPost []byte, c *cli.Context) (*writeas.Post, error) { tor := config.IsTor(c) if c.Int("tor-port") != 0 { TorPort = c.Int("tor-port") } if tor { log.Info(c, "Posting to hidden service...") } else { log.Info(c, "Posting...") } return DoPost(c, fullPost, c.String("font"), false, tor, c.Bool("code")) } func ReadStdIn() []byte { numBytes, numChunks := int64(0), int64(0) r := bufio.NewReader(os.Stdin) fullPost := []byte{} buf := make([]byte, 0, 1024) for { n, err := r.Read(buf[:cap(buf)]) buf = buf[:n] if n == 0 { if err == nil { continue } if err == io.EOF { break } log.ErrorlnQuit("Error reading from stdin: %v", err) } numChunks++ numBytes += int64(len(buf)) fullPost = append(fullPost, buf...) if err != nil && err != io.EOF { log.ErrorlnQuit("Error appending to end of post: %v", err) } } return fullPost } diff --git a/api/posts_test.go b/api/posts_test.go new file mode 100644 index 0000000..8630efe --- /dev/null +++ b/api/posts_test.go @@ -0,0 +1,103 @@ +package api + +import "testing" + +func TestTrimToLength(t *testing.T) { + tt := []struct { + Name string + Data string + Length int + ResultData string + ResultIDX int + }{ + { + "English, longer than trim length", + "This is a string, let's truncate it.", + 12, + "This is a", + 9, + }, { + "English, equal to length", + "Some other string.", + 18, + "Some other string.", + -1, + }, { + "English, shorter than trim length", + "I'm short!", + 20, + "I'm short!", + -1, + }, { + "Multi-byte, longer than trim length", + "這是一個較長的廣東話。 有許多特性可以確保足夠長的輸出。", + 14, + "這是一個較長的廣東話。", + 11, + }, { + "Multi-byte, equal to length", + "這是一個簡短的廣東話。", + 11, + "這是一個簡短的廣東話。", + -1, + }, { + "Multi-byte, shorter than trim length", + "我也很矮! 有空間。", + 20, + "我也很矮! 有空間。", + -1, + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + out, idx := trimToLength(tc.Data, tc.Length) + if out != tc.ResultData { + t.Errorf("Incorrect output, expecting \"%s\" but got \"%s\"", tc.ResultData, out) + } + + if idx != tc.ResultIDX { + t.Errorf("Incorrect last index, expected \"%d\" but got \"%d\"", tc.ResultIDX, idx) + } + }) + } +} + +func TestGetExcerpt(t *testing.T) { + tt := []struct { + Name string + Data string + Result string + }{ + { + "Shorter than one line", + "This is much less than 80 chars", + "This is much less than 80 chars", + }, { + "Exact length, one line", + "This will be only 80 chars. Maybe all the way to column 88, that will do it. ---", + "This will be only 80 chars. Maybe all the way to column 88, that will do it. ---", + }, { + "Shorter than two lines", + "This will be more than one line but shorter than two. It should break at the 80th or less character. Let's check it out.", + "This will be more than one line but shorter than two. It should break at the\n 80th or less character. Let's check it out.", + }, { + "Exact length, two lines", + "This should be the exact length for two lines. There should ideally be no trailing periods to indicate further text. However trimToLength breaks on word bounds.", + "This should be the exact length for two lines. There should ideally be no\n trailing periods to indicate further text. However trimToLength breaks on word...", + }, { + "Longer than two lines", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque volutpat sagittis aliquet. Ut eu rutrum nisl. Proin molestie ante in dui vulputate dictum. Proin ac bibendum eros. Nulla porta congue tellus, sed vehicula sem bibendum eu. Donec vehicula erat viverra fermentum mattis. Integer volutpat.", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque volutpat\n sagittis aliquet. Ut eu rutrum nisl. Proin molestie ante in dui vulputate...", + }, + } + + for _, tc := range tt { + t.Run(tc.Name, func(t *testing.T) { + out := getExcerpt(tc.Data) + if out != tc.Result { + t.Errorf("Output does not match:\nexpected \"%s\"\nbut got \"%s\"", tc.Result, out) + } + }) + } +}