diff options
-rw-r--r-- | article/article.go | 293 | ||||
-rw-r--r-- | atom/atom.go | 40 | ||||
-rw-r--r-- | config/config.go | 49 | ||||
-rw-r--r-- | database.sql | 24 | ||||
-rw-r--r-- | dbutils/dbutils.go | 60 | ||||
-rw-r--r-- | environment/environment.go | 58 | ||||
-rw-r--r-- | main.go | 53 | ||||
-rw-r--r-- | markdown/markdown.go | 35 | ||||
-rw-r--r-- | menu/menu.go | 92 | ||||
-rw-r--r-- | serve.go | 836 | ||||
-rw-r--r-- | templates/archive-day.html | 13 | ||||
-rw-r--r-- | templates/archive-month.html | 16 | ||||
-rw-r--r-- | templates/archive-year.html | 15 | ||||
-rw-r--r-- | templates/archive.html | 12 | ||||
-rw-r--r-- | templates/article.html | 7 | ||||
-rw-r--r-- | templates/blog.html | 12 | ||||
-rw-r--r-- | templates/content.html | 3 | ||||
-rw-r--r-- | templates/root.html | 66 | ||||
-rw-r--r-- | templates/search.html | 21 | ||||
-rw-r--r-- | templates/start.html | 8 | ||||
-rw-r--r-- | templates/tag.html | 13 | ||||
-rw-r--r-- | templates/tags.html | 6 | ||||
-rw-r--r-- | update.go | 90 | ||||
-rw-r--r-- | views.go | 526 |
24 files changed, 2348 insertions, 0 deletions
diff --git a/article/article.go b/article/article.go new file mode 100644 index 0000000..0a219a1 --- /dev/null +++ b/article/article.go @@ -0,0 +1,293 @@ +package article + +import ( + "bufio" + "database/sql" + "errors" + "html" + "io" + "os" + "path" + "regexp" + "strings" + "time" + + "code.laria.me/laria.me/dbutils" + "code.laria.me/laria.me/markdown" +) + +var ( + ErrBrokenHeader = errors.New("The article header is broken") + ErrMissingMandatoryHeaders = errors.New("The article header is missing some mandatory headers") +) + +type Article struct { + Slug string + Published time.Time + Hidden bool + Title string + SummaryHtml string + FullHtml string + Tags map[string]struct{} +} + +var reHtmlTag = regexp.MustCompile(`<([^>'"]+|'[^']*'|"[^"]*")*>`) +var reHtmlEntity = regexp.MustCompile(`&[^;]*;`) + +func stripTags(s string) string { + s = reHtmlTag.ReplaceAllString(s, "") + s = reHtmlEntity.ReplaceAllStringFunc(s, html.UnescapeString) + return s +} + +func (a Article) updateDbDetails(tx *sql.Tx, id int64) error { + _, err := tx.Exec(` + UPDATE article SET + published = ?, + hidden = ?, + title = ?, + summary_html = ?, + full_html = ?, + full_plain = ? + WHERE article_id = ? + `, a.Published.Format("2006-01-02 15:04:05"), a.Hidden, a.Title, a.SummaryHtml, a.FullHtml, stripTags(a.FullHtml), id) + + return err +} + +func (a Article) createInDb(tx *sql.Tx) (int64, error) { + res, err := tx.Exec(` + INSERT INTO article SET + slug = ?, + published = ?, + hidden = ?, + title = ?, + summary_html = ?, + full_html = ?, + full_plain = ? + `, a.Slug, a.Published.Format("2006-01-02 15:04:05"), a.Hidden, a.Title, a.SummaryHtml, a.FullHtml, stripTags(a.FullHtml)) + + if err != nil { + return 0, err + } + + return res.LastInsertId() +} + +func removeTags(tx *sql.Tx, id int64) error { + _, err := tx.Exec(`DELETE FROM article_tag WHERE article_id = ?`, id) + return err +} + +func setTags(tx *sql.Tx, id int64, tags map[string]struct{}) error { + stmt, err := tx.Prepare(`REPLACE INTO article_tag SET article_id = ?, tag = ?`) + if err != nil { + return err + } + defer stmt.Close() + + for tag := range tags { + if _, err = stmt.Exec(id, tag); err != nil { + return err + } + } + + return nil +} + +func (a Article) saveToDb(tx *sql.Tx) (int64, error) { + var id int64 + switch err := tx.QueryRow(`SELECT article_id FROM article WHERE slug = ?`, a.Slug).Scan(&id); err { + case nil: + if err = a.updateDbDetails(tx, id); err != nil { + return 0, err + } + case sql.ErrNoRows: + id, err = a.createInDb(tx) + if err != nil { + return 0, err + } + default: + return 0, err + } + + if err := removeTags(tx, id); err != nil { + return 0, err + } + + if err := setTags(tx, id, a.Tags); err != nil { + return 0, err + } + + return id, nil +} + +func (a Article) SaveToDb(db *sql.DB) (int64, error) { + tx, err := db.Begin() + if err != nil { + return 0, err + } + + id, err := a.saveToDb(tx) + err = dbutils.TxCommitIfOk(tx, err) + return id, err +} + +func DeleteArticlesFromDbExcept(db *sql.DB, slugs []string) error { + if len(slugs) == 0 { + return nil + } + + query := new(strings.Builder) + query.WriteString("DELETE FROM article WHERE slug NOT IN (?") + + for i := 1; i < len(slugs); i++ { // intentionally starting at 1, since the first '?' is already there + query.WriteString(",?") + } + + query.WriteString(")") + + slugsAsInterfaces := make([]interface{}, 0, len(slugs)) + for _, slug := range slugs { + slugsAsInterfaces = append(slugsAsInterfaces, interface{}(slug)) + } + + _, err := db.Exec(query.String(), slugsAsInterfaces...) + return err +} + +func splitTags(s string) map[string]struct{} { + tags := make(map[string]struct{}) + + parts := strings.Split(s, ",") + for _, part := range parts { + tag := strings.TrimSpace(part) + if tag != "" { + tags[tag] = struct{}{} + } + } + + return tags +} + +func parseDate(s string) (time.Time, error) { + return time.Parse("2006-01-02 15:04:05", s) +} + +func parseHeader(scanner *bufio.Scanner) (Article, error) { + var article Article + var err error + + seenTitle := false + seenPublished := false + + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + + if line == "" { + break + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return Article{}, ErrBrokenHeader + } + + key := strings.ToLower(strings.TrimSpace(parts[0])) + value := strings.TrimSpace(parts[1]) + + switch key { + case "title": + article.Title = value + seenTitle = true + case "tags": + article.Tags = splitTags(value) + case "date": + if article.Published, err = parseDate(value); err != nil { + return Article{}, err + } + seenPublished = true + case "hidden": + article.Hidden = strings.ToLower(value) == "yes" + } + } + + if err = scanner.Err(); err != nil { + return Article{}, err + } + + if !seenTitle || !seenPublished { + return Article{}, ErrMissingMandatoryHeaders + } + + return article, nil +} + +var reMore = regexp.MustCompile(`^\s*~~+(?i:more)~~+\s*$`) + +func parseText(scanner *bufio.Scanner, article *Article) error { + var err error + + builder := new(strings.Builder) + + for scanner.Scan() { + line := scanner.Text() + + if reMore.MatchString(line) { + if article.SummaryHtml, err = markdown.Parse(builder.String()); err != nil { + return err + } + + continue + } + + builder.WriteString(line) + builder.WriteRune('\n') + } + + if err = scanner.Err(); err != nil { + return err + } + + if article.FullHtml, err = markdown.Parse(builder.String()); err != nil { + return err + } + + return nil +} + +func ParseArticle(r io.Reader) (Article, error) { + var article Article + + scanner := bufio.NewScanner(r) + article, err := parseHeader(scanner) + if err != nil { + return Article{}, err + } + + if err = parseText(scanner, &article); err != nil { + return Article{}, err + } + + return article, nil +} + +func LoadArticle(filename string) (Article, error) { + parts := strings.Split(path.Base(filename), ".") + slug := strings.Join(parts[:len(parts)-1], ".") + + f, err := os.Open(filename) + if err != nil { + return Article{}, err + } + defer f.Close() + + article, err := ParseArticle(f) + if err != nil { + return Article{}, err + } + + article.Slug = slug + return article, nil +} diff --git a/atom/atom.go b/atom/atom.go new file mode 100644 index 0000000..a74a981 --- /dev/null +++ b/atom/atom.go @@ -0,0 +1,40 @@ +package atom + +import "time" + +type Link struct { + XMLName struct{} `xml:"link"` + + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` +} + +type Summary struct { + XMLName struct{} `xml:"summary"` + + Type string `xml:"type,attr"` + Content string `xml:",chardata"` +} + +type Entry struct { + XMLName struct{} `xml:"entry"` + + Title string `xml:"title"` + Id string `xml:"id"` + Updated time.Time `xml:"updated"` + Summary Summary + Links []Link +} + +type Feed struct { + XMLName struct{} `xml:"http://www.w3.org/2005/Atom feed"` + + Title string `xml:"title"` + Links []Link + Id string `xml:"id"` + AuthorName string `xml:"author>name"` + AuthorEmail string `xml:"author>email"` + AuthorUri string `xml:"author>uri"` + Updated time.Time `xml:"updated"` + Entries []Entry +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..074c24c --- /dev/null +++ b/config/config.go @@ -0,0 +1,49 @@ +package config + +import ( + "encoding/json" + "os" + "path" +) + +type Config struct { + ContentRoot string + DbDsn string + TemplatePath string + StaticPath string `json:",omitempty"` + HttpLaddr string + Secret string + UpdateUrl string +} + +func loadConfig(configPath string) (*Config, error) { + f, err := os.Open(configPath) + if err != nil { + return nil, err + } + defer f.Close() + + var conf Config + + dec := json.NewDecoder(f) + if err = dec.Decode(&conf); err != nil { + return nil, err + } + + return &conf, nil +} + +func LoadConfig(configPath string) (*Config, error) { + if configPath == "" { + configDir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + configPath = path.Join(configDir, "laria.me", "config.json") + + return loadConfig(configPath) + } + + return loadConfig(configPath) +} diff --git a/database.sql b/database.sql new file mode 100644 index 0000000..30d2216 --- /dev/null +++ b/database.sql @@ -0,0 +1,24 @@ +CREATE TABLE article ( + article_id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + slug VARCHAR(200) NOT NULL UNIQUE, + published DATETIME NOT NULL, + hidden TINYINT UNSIGNED NOT NULL DEFAULT 0, + title TEXT NOT NULL, + summary_html LONGTEXT NOT NULL, + full_html LONGTEXT NOT NULL, + full_plain LONGTEXT NOT NULL, + FULLTEXT(full_plain), + FULLTEXT(title) +); + +CREATE INDEX by_slug ON article (slug); +CREATE INDEX by_publish_date ON article (published); + +CREATE TABLE article_tag ( + article_id INT UNSIGNED NOT NULL REFERENCES article (article_id) ON UPDATE CASCADE ON DELETE CASCADE, + tag VARCHAR(200) NOT NULL, + PRIMARY KEY(article_id, tag), + CONSTRAINT article_fk FOREIGN KEY (article_id) REFERENCES article (article_id) ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE INDEX by_tag ON article_tag (tag); diff --git a/dbutils/dbutils.go b/dbutils/dbutils.go new file mode 100644 index 0000000..4082efd --- /dev/null +++ b/dbutils/dbutils.go @@ -0,0 +1,60 @@ +package dbutils + +import ( + "database/sql" + "fmt" + "strings" +) + +func TxCommitIfOk(tx *sql.Tx, err error) error { + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return fmt.Errorf("Failed rolling back transaction with error \"%s\" while handling error %w", rollbackErr, err) + } + + return err + } else { + if err = tx.Commit(); err != nil { + return err + } + + return nil + } +} + +func BuildInIdsSqlAndArgs(field string, ids []int) (string, []interface{}) { + if len(ids) == 0 { + return "0", []interface{}{} + } + + sb := new(strings.Builder) + sb.WriteString(field) + sb.WriteString(" IN (?") + + for i := 1; i < len(ids); i++ { + sb.WriteString(",?") + } + sb.WriteRune(')') + + args := make([]interface{}, 0, len(ids)) + for _, id := range ids { + args = append(args, interface{}(id)) + } + + return sb.String(), args +} + +// GetIndexedValues +// func GetIndexedValues(db *sql.DB, m interface{}, query, args ...interface{}) error { +// mv := reflect.ValueOf(m) +// mt := mv.Type() +// if mt.Kind() != reflect.Map { +// panic("GetIndexValue needs a map as argument m") +// } +// if mt. + +// rows, err := db.Query(query, args...) +// if err != nil { +// return err +// } +// } diff --git a/environment/environment.go b/environment/environment.go new file mode 100644 index 0000000..a4ae719 --- /dev/null +++ b/environment/environment.go @@ -0,0 +1,58 @@ +// Package environment provides the Env type for commonly used data in the application. +package environment + +import ( + "database/sql" + + _ "github.com/go-sql-driver/mysql" + + "code.laria.me/laria.me/config" +) + +// Env provides commonly used data in the application +type Env struct { + configPath string + + config *config.Config + db *sql.DB +} + +func New(configPath string) *Env { + return &Env{ + configPath: configPath, + } +} + +func (e *Env) Config() (*config.Config, error) { + if e.config != nil { + return e.config, nil + } + + conf, err := config.LoadConfig(e.configPath) + if err != nil { + return nil, err + } + + e.config = conf + return conf, nil +} + +func (e *Env) DB() (*sql.DB, error) { + if e.db != nil { + return e.db, nil + } + + conf, err := e.Config() + if err != nil { + return nil, err + } + + var db *sql.DB + db, err = sql.Open("mysql", conf.DbDsn) + if err != nil { + return nil, err + } + + e.db = db + return db, nil +} @@ -0,0 +1,53 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "code.laria.me/laria.me/environment" +) + +type subcmd func(progname string, env *environment.Env, args []string) + +func main() { + subcmds := map[string]subcmd{ + "serve": cmdServe, + "update": cmdUpdate, + } + + progname := os.Args[0] + + flagSet := flag.NewFlagSet(progname, flag.ExitOnError) + flagSet.Usage = func() { + fmt.Fprintf(flagSet.Output(), "Usage: %s [options] <command> [command specific options]\n", progname) + fmt.Fprintln(flagSet.Output(), "Where options can be any of:") + flagSet.PrintDefaults() + fmt.Fprintln(flagSet.Output(), "") + fmt.Fprintln(flagSet.Output(), "And command is one of:") + for n := range subcmds { + fmt.Fprintf(flagSet.Output(), " %s\n", n) + } + } + + configPath := flagSet.String("config", "", "An optional config path") + flagSet.Parse(os.Args[1:]) + + args := flagSet.Args() + if len(args) == 0 { + flagSet.Usage() + os.Exit(1) + } + + cmdName := args[0] + args = args[1:] + + env := environment.New(*configPath) + cmd, ok := subcmds[cmdName] + if !ok { + fmt.Fprintf(os.Stderr, "%s: Unknown command %s\nSee %s -help for valid commands\n", progname, cmdName, progname) + os.Exit(1) + } + + cmd(progname, env, args) +} diff --git a/markdown/markdown.go b/markdown/markdown.go new file mode 100644 index 0000000..3a4beba --- /dev/null +++ b/markdown/markdown.go @@ -0,0 +1,35 @@ +package markdown + +import ( + "bytes" + + "github.com/alecthomas/chroma/formatters/html" + "github.com/yuin/goldmark" + highlighting "github.com/yuin/goldmark-highlighting" + goldmarkHtml "github.com/yuin/goldmark/renderer/html" +) + +func Parse(s string) (string, error) { + markdown := goldmark.New( + goldmark.WithExtensions( + highlighting.NewHighlighting( + highlighting.WithStyle("monokai"), + highlighting.WithFormatOptions( + // html.WithAllClasses(true), + html.WithClasses(true), + html.WithLineNumbers(false), + ), + ), + ), + goldmark.WithRendererOptions( + goldmarkHtml.WithUnsafe(), + ), + ) + + buf := new(bytes.Buffer) + if err := markdown.Convert([]byte(s), buf); err != nil { + return "", err + } + + return buf.String(), nil +} diff --git a/menu/menu.go b/menu/menu.go new file mode 100644 index 0000000..3916649 --- /dev/null +++ b/menu/menu.go @@ -0,0 +1,92 @@ +package menu + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +type MenuItem struct { + Title string + Ident string + Url string + Children []*MenuItem + Parent *MenuItem +} + +func (it MenuItem) IsRoot() bool { + return it.Parent == nil +} + +type Menu struct { + byIdent map[string]*MenuItem + root *MenuItem +} + +func (m Menu) Root() *MenuItem { + return m.root +} + +func (m Menu) ByIdent(ident string) *MenuItem { + return m.byIdent[ident] +} + +type JsonMenuItem struct { + Title string + Ident string + Url string + Children []JsonMenuItem `json:",omitempty"` +} + +type JsonMenu []JsonMenuItem + +func (m *Menu) addJsonMenuItems(jsonItems []JsonMenuItem, parent *MenuItem) { + for _, jsonItem := range jsonItems { + item := MenuItem{ + Title: jsonItem.Title, + Ident: jsonItem.Ident, + Url: jsonItem.Url, + Children: make([]*MenuItem, 0, len(jsonItem.Children)), + Parent: parent, + } + + parent.Children = append(parent.Children, &item) + m.byIdent[jsonItem.Ident] = &item + + m.addJsonMenuItems(jsonItem.Children, &item) + } +} + +func (jm JsonMenu) toMenu() *Menu { + root := MenuItem{ + Children: make([]*MenuItem, 0, len(jm)), + } + + menu := &Menu{ + byIdent: make(map[string]*MenuItem), + root: &root, + } + + menu.addJsonMenuItems(jm, &root) + + return menu +} + +func Parse(r io.Reader) (*Menu, error) { + var jsonMenu JsonMenu + if err := json.NewDecoder(r).Decode(&jsonMenu); err != nil { + return nil, err + } + return jsonMenu.toMenu(), nil +} + +func LoadFromFile(filename string) (*Menu, error) { + f, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("Could not load menu from %s: %v", filename, err) + } + defer f.Close() + + return Parse(f) +} diff --git a/serve.go b/serve.go new file mode 100644 index 0000000..4f3aef8 --- /dev/null +++ b/serve.go @@ -0,0 +1,836 @@ +package main + +import ( + "bytes" + "database/sql" + "encoding/xml" + "errors" + "fmt" + "html/template" + "io" + "log" + "math" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/go-sql-driver/mysql" + "github.com/gorilla/mux" + + "code.laria.me/laria.me/atom" + "code.laria.me/laria.me/dbutils" + "code.laria.me/laria.me/environment" + "code.laria.me/laria.me/markdown" + "code.laria.me/laria.me/menu" +) + +type serveContext struct { + env *environment.Env + rwMutex *sync.RWMutex + pages map[string]template.HTML + menu *menu.Menu + views Views +} + +func newServeContext(env *environment.Env) (*serveContext, error) { + context := &serveContext{ + env: env, + rwMutex: new(sync.RWMutex), + pages: make(map[string]template.HTML), + } + if err := context.update(); err != nil { + return nil, err + } + return context, nil +} + +var rePageName = regexp.MustCompile(`^(?:[^/]*/)*([^\.]+).*$`) + +func pageName(filename string) string { + m := rePageName.FindStringSubmatch(filename) + if m == nil { + return "" + } + + return m[1] +} + +func loadPage(filename string) (template.HTML, error) { + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + + buf := new(bytes.Buffer) + + if _, err := io.Copy(buf, f); err != nil { + return "", err + } + + html, err := markdown.Parse(buf.String()) + return template.HTML(html), err +} + +func readPages(pagesPath string) (map[string]template.HTML, error) { + f, err := os.Open(pagesPath) + if err != nil { + return nil, err + } + defer f.Close() + + infos, err := f.Readdir(-1) + if err != nil { + return nil, err + } + + pages := make(map[string]template.HTML) + for _, info := range infos { + if info.IsDir() { + continue + } + + name := pageName(info.Name()) + if name == "" { + continue + } + + html, err := loadPage(filepath.Join(pagesPath, info.Name())) + if err != nil { + return nil, err + } + + pages[name] = html + } + + return pages, nil +} + +func (ctx *serveContext) update() error { + conf, err := ctx.env.Config() + if err != nil { + return err + } + + menuPath := path.Join(conf.ContentRoot, "menu.json") + menu, err := menu.LoadFromFile(menuPath) + if err != nil { + return fmt.Errorf("Failed loading menu %s: %w", menuPath, err) + } + + pagesPath := path.Join(conf.ContentRoot, "pages") + pages, err := readPages(pagesPath) + if err != nil { + return fmt.Errorf("Failed loading pages from %s: %w", pagesPath, err) + } + + views, err := LoadViews(conf.TemplatePath) + if err != nil { + return fmt.Errorf("Failed loading templates: %w", err) + } + + ctx.rwMutex.Lock() + defer ctx.rwMutex.Unlock() + + ctx.menu = menu + ctx.pages = pages + ctx.views = views + + return nil +} + +func (ctx *serveContext) handleUpdate(w http.ResponseWriter, r *http.Request) { + conf, err := ctx.env.Config() + if err != nil { + panic(err) + } + + if r.Method != "POST" { + w.WriteHeader(405) + return + } + + if err := r.ParseForm(); err != nil { + log.Printf("Could not ParseForm: %s", err) + w.WriteHeader(500) + return + } + + if r.PostForm.Get("secret") != conf.Secret { + w.WriteHeader(401) + } + + if err := ctx.update(); err != nil { + log.Printf("Could not update: %s", err) + w.WriteHeader(500) + return + } + + w.WriteHeader(200) +} + +var reHtmlHeadline = regexp.MustCompile(`<\s*h\d\b`) + +func rewriteHeadlines(html template.HTML, sub int) template.HTML { + if sub == 0 { + return html + } + + str := string(html) + str = reHtmlHeadline.ReplaceAllStringFunc(str, func(s string) string { + _n, _ := strconv.ParseInt(s[len(s)-1:], 10, 8) + n := int(_n) + n -= sub + if n < 1 { + n = 1 + } + if n > 6 { + n = 6 + } + + return fmt.Sprintf("<h%d", n) + }) + return template.HTML(str) +} + +// viewArticlesFromDb creates ViewArticles from an SQL query. +// The quey should select these values: ID, Published, Slug, Title, Content, ReadMore +func viewArticlesFromDb(db *sql.DB, headlineSub int, query string, args ...interface{}) ([]ViewArticle, int, error) { + tx, err := db.Begin() + if err != nil { + return nil, 0, err + } + + articles, count, err := viewArticlesFromDbTx(tx, headlineSub, query, args...) + err = dbutils.TxCommitIfOk(tx, err) + return articles, count, err +} + +func viewArticlesFromDbTx(tx *sql.Tx, headlineSub int, query string, args ...interface{}) ([]ViewArticle, int, error) { + rows, err := tx.Query(query, args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + ids := make([]int, 0) + articlesById := make(map[int]*ViewArticle) + for rows.Next() { + var va ViewArticle + var id int + + var t mysql.NullTime + + if err := rows.Scan( + &id, + &t, + &va.Slug, + &va.Title, + &va.Content, + &va.ReadMore, + ); err != nil { + return nil, 0, err + } + + if t.Valid { + va.Published = t.Time + } + + va.Content = rewriteHeadlines(va.Content, headlineSub) + + ids = append(ids, id) + articlesById[id] = &va + } + + if err = rows.Err(); err != nil { + return nil, 0, err + } + + var total int + err = tx.QueryRow(`SELECT FOUND_ROWS()`).Scan(&total) + if err != nil { + return nil, 0, err + } + + if len(ids) > 0 { + inSql, inArgs := dbutils.BuildInIdsSqlAndArgs("article_id", ids) + + rows, err := tx.Query(` + SELECT article_id, tag + FROM article_tag + WHERE `+inSql+` + ORDER BY article_id, tag + `, inArgs...) + + if err != nil { + return nil, 0, err + } + defer rows.Close() + + for rows.Next() { + var id int + var tag string + + if err := rows.Scan(&id, &tag); err != nil { + return nil, 0, err + } + + article, ok := articlesById[id] + if ok { + article.Tags = append(article.Tags, tag) + } + } + + if err := rows.Err(); err != nil { + return nil, 0, err + } + } + + viewArticles := make([]ViewArticle, 0, len(ids)) + for _, id := range ids { + viewArticles = append(viewArticles, *articlesById[id]) + } + + return viewArticles, total, nil +} + +type responseWriterWithHeaderSentFlag struct { + http.ResponseWriter + headersSent bool +} + +func (w *responseWriterWithHeaderSentFlag) Write(p []byte) (int, error) { + w.headersSent = true + return w.ResponseWriter.Write(p) +} + +func (w *responseWriterWithHeaderSentFlag) WriteHeader(statusCode int) { + w.headersSent = true + w.ResponseWriter.WriteHeader(statusCode) +} + +var errNotFound = errors.New("not found") + +func wrapHandleFunc( + name string, + f func(http.ResponseWriter, *http.Request) error, +) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + wWrap := &responseWriterWithHeaderSentFlag{w, false} + + err := f(wWrap, r) + switch err { + case nil: + return + case errNotFound: + // TODO: A better 404 page + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(404) + if _, err = fmt.Fprintln(w, "404 Not Found"); err != nil { + log.Printf("%s: Failed sending 404: %s", name, err) + } + default: + log.Printf("%s: %s", name, err) + if !wWrap.headersSent { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(500) + if _, err = fmt.Fprintln(w, "500 Internal Server Error"); err != nil { + log.Printf("%s: Failed sending 500: %s", name, err) + } + } + } + } +} + +func (ctx *serveContext) handleArticle(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + vars := mux.Vars(r) + year, _ := strconv.Atoi(vars["year"]) + month, _ := strconv.Atoi(vars["month"]) + day, _ := strconv.Atoi(vars["day"]) + slug := vars["slug"] + + articles, _, err := viewArticlesFromDb(db, 0, ` + SELECT article_id, published, slug, title, full_html, 0 AS ReadMore + FROM article + WHERE + slug = ? + AND YEAR(published) = ? + AND MONTH(published) = ? + AND DAY(published) = ? + AND NOT hidden + `, slug, year, month, day) + + if err != nil { + return err + } + + if len(articles) != 1 { + return errNotFound + } + + return ctx.views.RenderArticle(w, ctx.menu, "blog", articles[0]) +} + +func (ctx *serveContext) handleArticleQuicklink(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + vars := mux.Vars(r) + slug := vars["slug"] + + articles, _, err := viewArticlesFromDb(db, 0, ` + SELECT article_id, published, slug, title, full_html, 0 AS ReadMore + FROM article + WHERE + slug = ? + AND NOT hidden + `, slug) + + if err != nil { + return err + } + + if len(articles) != 1 { + return errNotFound + } + + article := articles[0] + y, m, d := article.Published.Date() + + w.Header().Set("Location", fmt.Sprintf("/blog/%d/%d/%d/%s", y, m, d, slug)) + w.WriteHeader(301) + return nil +} + +func (ctx *serveContext) handleArchiveDay(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + vars := mux.Vars(r) + year, _ := strconv.Atoi(vars["year"]) + month, _ := strconv.Atoi(vars["month"]) + day, _ := strconv.Atoi(vars["day"]) + + articles, _, err := viewArticlesFromDb(db, 1, ` + SELECT article_id, published, slug, title, full_html, 0 AS ReadMore + FROM article + WHERE + YEAR(published) = ? + AND MONTH(published) = ? + AND DAY(published) = ? + AND NOT hidden + ORDER BY published ASC + `, year, month, day) + + if err != nil { + return err + } + + return ctx.views.RenderArchiveDay(w, ctx.menu, "archive", year, month, day, articles) +} + +func countArticlesBy(db *sql.DB, byExpr, whereExpr string, whereArgs ...interface{}) (map[int]int, error) { + rows, err := db.Query(` + SELECT `+byExpr+`, COUNT(*) + FROM article + WHERE `+whereExpr+` AND NOT hidden + GROUP BY `+byExpr+` + `, whereArgs...) + + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[int]int) + for rows.Next() { + var k, v int + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + + counts[k] = v + } + + return counts, nil +} + +func (ctx *serveContext) handleArchiveMonth(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + vars := mux.Vars(r) + year, _ := strconv.Atoi(vars["year"]) + month, _ := strconv.Atoi(vars["month"]) + + counts, err := countArticlesBy(db, "DAY(published)", "YEAR(published) = ? AND MONTH(published) = ?", year, month) + if err != nil { + return err + } + + return ctx.views.RenderArchiveMonth(w, ctx.menu, "archive", year, month, counts) +} + +func (ctx *serveContext) handleArchiveYear(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + vars := mux.Vars(r) + year, _ := strconv.Atoi(vars["year"]) + + counts, err := countArticlesBy(db, "MONTH(published)", "YEAR(published) = ?", year) + if err != nil { + return err + } + + return ctx.views.RenderArchiveYear(w, ctx.menu, "archive", year, counts) +} + +func (ctx *serveContext) handleArchive(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + counts, err := countArticlesBy(db, "YEAR(published)", "1") + if err != nil { + return err + } + + return ctx.views.RenderArchive(w, ctx.menu, "archive", counts) +} + +const articles_per_page = 30 + +func getPageArgument(r *http.Request) int { + vals, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + return 1 + } + + if _, ok := vals["page"]; !ok { + return 1 + } + + rawPage := vals.Get("page") + page, err := strconv.ParseInt(rawPage, 10, 32) + if err != nil { + return 1 + } + + if page < 1 { + return 1 + } + + return int(page) +} + +func calcPages(total int) int { + return int(math.Ceil(float64(total) / articles_per_page)) +} + +func (ctx *serveContext) handleTag(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + vars := mux.Vars(r) + tag := vars["tag"] + + page := getPageArgument(r) + + articles, total, err := viewArticlesFromDb(db, 1, ` + SELECT SQL_CALC_FOUND_ROWS + a.article_id, + a.published, + a.slug, + a.title, + IF(a.summary_html = '', a.full_html, a.summary_html), + a.summary_html != '' AS ReadMore + FROM article_tag t + INNER JOIN article a + ON a.article_id = t.article_id + WHERE + t.tag = ? + AND NOT a.hidden + ORDER BY published DESC + LIMIT ? OFFSET ? + `, tag, articles_per_page, (page-1)*articles_per_page) + + if err != nil { + return err + } + + pages := calcPages(total) + return ctx.views.RenderTag(w, ctx.menu, "tags", tag, articles, pages, page) +} + +func countTags(db *sql.DB) (map[string]int, error) { + rows, err := db.Query(` + SELECT + at.tag, + COUNT(*) + FROM article_tag at + INNER JOIN article a + ON a.article_id = at.article_id + WHERE NOT a.hidden + GROUP BY at.tag + `) + + if err != nil { + return nil, err + } + defer rows.Close() + + counts := make(map[string]int) + for rows.Next() { + var k string + var v int + + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + + counts[k] = v + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return counts, nil +} + +func (ctx *serveContext) handleTags(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + counts, err := countTags(db) + if err != nil { + return err + } + + return ctx.views.RenderTags(w, ctx.menu, "tags", counts) +} + +func getSearchQueryArgument(r *http.Request) string { + vals, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + return "" + } + + if _, ok := vals["q"]; !ok { + return "" + } + + q := vals.Get("q") + + return strings.TrimSpace(q) +} + +func (ctx *serveContext) handleSearch(w http.ResponseWriter, r *http.Request) error { + db, err := ctx.env.DB() + if err != nil { + return err + } + + q := getSearchQueryArgument(r) + page := getPageArgument(r) + + articles := []ViewArticle{} + total := 0 + + if q != "" { + articles, total, err = viewArticlesFromDb(db, 1, ` + SELECT SQL_CALC_FOUND_ROWS + article_id, + published, + slug, + title, + IF(summary_html = '', full_html, summary_html), + summary_html != '' AS ReadMore + FROM article + WHERE + (MATCH(full_plain) AGAINST(?) OR MATCH(title) AGAINST (?)) + AND NOT hidden + ORDER BY published DESC + LIMIT ? OFFSET ? + `, q, q, articles_per_page, (page-1)*articles_per_page) + + if err != nil { + return err + } + } + + return ctx.views.RenderSearch( + w, + ctx.menu, + "search", + q, + total, + articles, + calcPages(total), + page, + ) +} + +func (ctx *serveContext) getBlogData(limit, offset int) ([]ViewArticle, int, error) { + db, err := ctx.env.DB() + if err != nil { + return nil, 0, err + } + + return viewArticlesFromDb(db, 1, ` + SELECT SQL_CALC_FOUND_ROWS + article_id, + published, + slug, + title, + IF(summary_html = '', full_html, summary_html), + summary_html != '' AS ReadMore + FROM article + WHERE NOT hidden + ORDER BY published DESC + LIMIT ? OFFSET ? + `, limit, offset) +} + +const numFeedEntries = 30 + +func (ctx *serveContext) handleFeed(w http.ResponseWriter, r *http.Request) error { + articles, _, err := ctx.getBlogData(numFeedEntries, 0) + + if err != nil { + return err + } + + entries := make([]atom.Entry, 0, len(articles)) + for _, article := range articles { + y, m, d := article.Published.Date() + url := fmt.Sprintf("http://laria.me/blog/%d/%d/%d/%s", y, m, d, article.Slug) + entries = append(entries, atom.Entry{ + Title: article.Title, + Id: url, + Updated: article.Published, // TODO: Or should modification time be tracked? + Summary: atom.Summary{ + Type: "html", + Content: string(article.Content), + }, + Links: []atom.Link{ + atom.Link{Rel: "alternate", Href: url}, + }, + }) + } + + feed := atom.Feed{ + Title: "laria.me Blog", + Links: []atom.Link{ + atom.Link{Href: "http://laria.me/"}, + atom.Link{Href: "http://laria.me/blog/feed.xml", Rel: "self"}, + }, + Id: "http://laria.me/blog", + AuthorName: "Laria Carolin Chabowski", + AuthorEmail: "laria-blog@laria.me", + AuthorUri: "http://laria.me", + Updated: articles[0].Published, // TODO: This should be able to deal with articles being empty + Entries: entries, + } + + return xml.NewEncoder(w).Encode(feed) +} + +func (ctx *serveContext) handleBlog(w http.ResponseWriter, r *http.Request) error { + page := getPageArgument(r) + + articles, total, err := ctx.getBlogData(articles_per_page, (page-1)*articles_per_page) + + if err != nil { + return err + } + + pages := calcPages(total) + return ctx.views.RenderBlog(w, ctx.menu, "blog", articles, pages, page) +} + +func (ctx *serveContext) handlePage(w http.ResponseWriter, r *http.Request) error { + vars := mux.Vars(r) + pageName := vars["page"] + + page, ok := ctx.pages[pageName] + if !ok { + return errNotFound + } + + return ctx.views.RenderContent(w, ctx.menu, pageName, page) +} + +const blogArticlesOnHomepage = 3 + +func (ctx *serveContext) handleHome(w http.ResponseWriter, r *http.Request) error { + articles, _, err := ctx.getBlogData(blogArticlesOnHomepage, 0) + + if err != nil { + return err + } + + return ctx.views.RenderStart(w, ctx.menu, "", ctx.pages["hello"], articles) +} + +func cmdServe(progname string, env *environment.Env, args []string) { + config, err := env.Config() + if err != nil { + log.Fatalf("Could not load config: %s", err) + } + + ctx, err := newServeContext(env) + if err != nil { + log.Fatalf("Could not create serveContext: %s", err) + } + + r := mux.NewRouter() + + if config.StaticPath != "" { + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir(config.StaticPath)))) + } + + r.HandleFunc("/__update", ctx.handleUpdate) + r.HandleFunc("/blog/q/{slug}", wrapHandleFunc("article-quicklink", ctx.handleArticleQuicklink)) + r.HandleFunc("/blog/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}/{slug}", wrapHandleFunc("article", ctx.handleArticle)) + r.HandleFunc("/blog/{year:[0-9]+}/{month:[0-9]+}/{day:[0-9]+}", wrapHandleFunc("archiveDay", ctx.handleArchiveDay)) + r.HandleFunc("/blog/{year:[0-9]+}/{month:[0-9]+}", wrapHandleFunc("archiveMonth", ctx.handleArchiveMonth)) + r.HandleFunc("/blog/{year:[0-9]+}", wrapHandleFunc("archiveYear", ctx.handleArchiveYear)) + r.HandleFunc("/blog/archive", wrapHandleFunc("archive", ctx.handleArchive)) + r.HandleFunc("/blog/tags/{tag}", wrapHandleFunc("tag", ctx.handleTag)) + r.HandleFunc("/blog/tags", wrapHandleFunc("tags", ctx.handleTags)) + r.HandleFunc("/blog/search", wrapHandleFunc("search", ctx.handleSearch)) + r.HandleFunc("/blog/feed.xml", wrapHandleFunc("feed", ctx.handleFeed)) + r.HandleFunc("/blog", wrapHandleFunc("blog", ctx.handleBlog)) + r.HandleFunc("/{page}", wrapHandleFunc("page", ctx.handlePage)) + r.HandleFunc("/", wrapHandleFunc("home", ctx.handleHome)) + + if err := http.ListenAndServe(config.HttpLaddr, r); err != nil { + log.Fatalln(err) + } +} diff --git a/templates/archive-day.html b/templates/archive-day.html new file mode 100644 index 0000000..16bd09a --- /dev/null +++ b/templates/archive-day.html @@ -0,0 +1,13 @@ +{{define "main"}} +{{- $year := .Year -}} +{{- $month := .Month -}} +{{- $day := .Day -}} +<h1>{{nth .Day}} <a href="{{archive_link .Year .Month}}">{{.MonthText}}</a> <a href="{{archive_link .Year}}">{{.Year}}</a></h1> +{{with .Articles}} +{{template "article_list" .}} +{{else}} +<p>Nothing for this day.</p> +{{end}} + +<p><a href="{{archive_link .Year .Month (add .Day -1)}}" rel="prev">< Previous</a> | <a href="{{archive_link .Year .Month (add .Day 1)}}" rel="next">Next ></a></p> +{{end}} diff --git a/templates/archive-month.html b/templates/archive-month.html new file mode 100644 index 0000000..1d872a9 --- /dev/null +++ b/templates/archive-month.html @@ -0,0 +1,16 @@ +{{define "main"}} +{{- $year := .Year -}} +{{- $month := .Month -}} +<h1>{{month_text .Month}} <a href="{{archive_link .Year}}">{{.Year}}</a></h1> +{{with .Days}} +<ul> + {{range .}} + <li><a href="{{archive_link $year $month .Num}}">{{day_text $year $month .Num}} [{{.Count}}]</a> + {{end}} +</ul> +{{else}} +<p>Nothing for this month.</p> +{{end}} + +<p><a href="{{archive_link .Year (add .Month -1)}}" rel="prev">< Previous</a> | <a href="{{archive_link .Year (add .Month 1)}}" rel="next">Next ></a></p> +{{end}} diff --git a/templates/archive-year.html b/templates/archive-year.html new file mode 100644 index 0000000..c4aa7ee --- /dev/null +++ b/templates/archive-year.html @@ -0,0 +1,15 @@ +{{define "main"}} +{{- $year := .Year -}} +<h1>{{.Year}}</h1> +{{with .Months}} +<ul> + {{range .}} + <li><a href="{{archive_link $year .Num}}">{{month_text .Num}} {{$year}} [{{.Count}}]</a> + {{end}} +</ul> +{{else}} +<p>Nothing for this year.</p> +{{end}} + +<p><a href="{{archive_link (add .Year -1)}}" rel="prev">< Previous</a> | <a href="{{archive_link (add .Year 1)}}" rel="next">Next ></a></p> +{{end}} diff --git a/templates/archive.html b/templates/archive.html new file mode 100644 index 0000000..42eba08 --- /dev/null +++ b/templates/archive.html @@ -0,0 +1,12 @@ +{{define "main"}} +<h1>Archive</h1> +{{with .Years}} +<ul> + {{range .}} + <li><a href="{{archive_link .Num}}">{{.Num}} [{{.Count}}]</a> + {{end}} +</ul> +{{else}} +<p>Nothing yet :(</p> +{{end}} +{{end}} diff --git a/templates/article.html b/templates/article.html new file mode 100644 index 0000000..a208c75 --- /dev/null +++ b/templates/article.html @@ -0,0 +1,7 @@ +{{define "main"}} +<article> + <h1>{{.Title}}</h1> + {{template "article_meta" .}} + <div class="content">{{.Content}}</div> +</article> +{{end}} diff --git a/templates/blog.html b/templates/blog.html new file mode 100644 index 0000000..117f5ab --- /dev/null +++ b/templates/blog.html @@ -0,0 +1,12 @@ +{{define "main"}} +{{with .Articles}} + {{template "article_list" .}} +{{else}} + <p>Nothing found</p> +{{end}} + +{{if gt .Pages 1}} + <p>{{pagination .Pages .Page "/blog"}}</p> +{{end}} + +{{end}} diff --git a/templates/content.html b/templates/content.html new file mode 100644 index 0000000..ba58e3c --- /dev/null +++ b/templates/content.html @@ -0,0 +1,3 @@ +{{define "main"}} +<article>{{.}}</article> +{{end}} diff --git a/templates/root.html b/templates/root.html new file mode 100644 index 0000000..abc66bf --- /dev/null +++ b/templates/root.html @@ -0,0 +1,66 @@ +{{- define "article_meta"}} + <dl class="meta"> + <div> + <dt>Published</dt> + <dd><time datetime="2006-01-02T15:04:05-0700">{{.Published.Format "Mon, Jan 2 2006, 15:04"}}</time></dd> + </div> + {{with .Tags}} + <div> + <dt>Tags</dt> + <dd><ul class="article-tags">{{range .}} + <li><a href="/blog/tags/{{.}}">{{.}}</a></li> + {{end}}</ul></dd> + </div> + {{end}} + </dl> +{{end -}} +{{- define "article_list"}} + {{range .}} + {{- $year := .Published.Format "2006" -}} + {{- $month := .Published.Format "01" -}} + {{- $day := .Published.Format "02" -}} + <article> + <h2><a href="/blog/{{$year}}/{{$month}}/{{$day}}/{{.Slug}}">{{.Title}}</a></h2> + {{template "article_meta" .}} + <div class="content">{{.Content}}</div> + {{if .ReadMore -}} + <p class="readmore-outer"><a href="/blog/{{$year}}/{{$month}}/{{$day}}/{{.Slug}}">Read more ...</a></p> + {{- end}} + </article> + {{end}} +{{end -}} +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{with .Title}}{{.}} - {{end}}laria.me</title> + <link rel="stylesheet" type="text/css" href="/static/style.css"> + <link rel="stylesheet" type="text/css" href="/static/syntax.css"> + <link rel="alternate" type="application/atom+xml" href="/blog/feed.xml" title="Atom-Feed of the blog"> + <link rel="author" href="/about-me" /> + <meta name="author" content="Laria Carolin Chabowski" /> + <meta name="description" content="Laria's website. They mainly write about adventures in programming but will also occasionally write about other things that interest them." /> + <meta name="keywords" content="programming,blog,golang,php,music,links,gsrm,lgbt,lgbtq,genderqueer,trans,technology,web,opensource" /> +</head> +<body> + <a href="#maincontent" class="skip-to-main-content">Skip to main content</a> + <header> + <a href="/" class="logolink">laria.me</a> + <nav> + {{ range .Menu }} + <!-- TODO: Label menus for screenreaders --> + <ul class="menu-level">{{ range . }} + <li {{ if .Active }}class="cur"{{ end }}><a href="{{ .Url }}">{{ .Title }}</a></li> + {{ end }}</ul> + {{ end }} + </nav> + </header> + <main id="maincontent">{{ template "main" .Main }}</main> + <footer> + <p>Contents of this page is copyrighted under the [WTFPL](http://www.wtfpl.net), unless noted otherwise. The content of the linked pages is © of their respective owners. You can contact me via email: <code>laria (minus) blog (at) laria (dot) me</code>.</p> + <p>If you really need more info, use the <a href="/impressum">Impressum</a>.</p> + </footer> +</body> +</html> diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..9a6dbf7 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,21 @@ +{{define "main"}} +<form action="/blog/search" method="get"> + <label>Search terms: <input type="text" name="q" value="{{.Q}}"></label> + <button type="submit">Go</button> + <!-- TODO: Form looks ugly, especially in dark mode --> +</form> + +{{if .Q}} +{{if gt .Total 0}} + <h1>Results ({{.Total}} total)</h1> + {{template "article_list" .Results}} + + {{if gt .Pages 1}} + <p>{{pagination .Pages .Page "/blog/search" "q" .Q}}</p> + {{end}} +{{else}} + <p>Nothing found</p> +{{end}} +{{end}} + +{{end}} diff --git a/templates/start.html b/templates/start.html new file mode 100644 index 0000000..cd97639 --- /dev/null +++ b/templates/start.html @@ -0,0 +1,8 @@ +{{block "main" .}} +<article class="main-content">{{.Content}}</article> +{{with .Blog}} + <p>Here are my latest blog entries:</p> + {{template "article_list" .}} + <p><a href="/blog">Read more blog entries</a>.</p> +{{end}} +{{end}} diff --git a/templates/tag.html b/templates/tag.html new file mode 100644 index 0000000..02fe5b3 --- /dev/null +++ b/templates/tag.html @@ -0,0 +1,13 @@ +{{define "main"}} +<h1>Tag: {{.Tag}}</h1> +{{with .Articles}} + {{template "article_list" .}} +{{else}} + <p>Nothing found</p> +{{end}} + +{{if gt .Pages 1}} + <p>{{pagination .Pages .Page (concat "/blog/tags/" .Tag)}}</p> +{{end}} + +{{end}} diff --git a/templates/tags.html b/templates/tags.html new file mode 100644 index 0000000..7df7351 --- /dev/null +++ b/templates/tags.html @@ -0,0 +1,6 @@ +{{define "main"}} +<h1>Tags</h1> +{{with .Tags}}<ul class="tagcloud">{{range .}} + <li class="tc-{{.SizeClass}}"><a href="/blog/tags/{{.Tag}}">{{.Tag}}</a></li> +{{end}}</ul>{{end}} +{{end}} diff --git a/update.go b/update.go new file mode 100644 index 0000000..82024b7 --- /dev/null +++ b/update.go @@ -0,0 +1,90 @@ +package main + +import ( + "database/sql" + "log" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + + "code.laria.me/laria.me/article" + "code.laria.me/laria.me/config" + "code.laria.me/laria.me/environment" +) + +func allArticlesFromDir(dir string) ([]article.Article, error) { + log.Printf("allArticlesFromDir(%s)", dir) + f, err := os.Open(dir) + if err != nil { + return nil, err + } + defer f.Close() + + infos, err := f.Readdir(-1) + if err != nil { + return nil, err + } + + articles := make([]article.Article, 0) + + for _, info := range infos { + if info.IsDir() { + continue + } + + fullname := filepath.Join(dir, info.Name()) + a, err := article.LoadArticle(fullname) + if err != nil { + return nil, err + } + + articles = append(articles, a) + } + + return articles, nil +} + +func updateArticles(conf *config.Config, db *sql.DB) { + articles, err := allArticlesFromDir(path.Join(conf.ContentRoot, "articles")) + if err != nil { + log.Fatalf("allArticlesFromDir(): %s", err) + } + + slugs := make([]string, 0, len(articles)) + for _, article := range articles { + if _, err = article.SaveToDb(db); err != nil { + log.Fatalf("SaveToDb: %s", err) + } + + slugs = append(slugs, article.Slug) + } + + if err = article.DeleteArticlesFromDbExcept(db, slugs); err != nil { + log.Fatalf("DeleteArticlesFromDbExcept: %s", err) + } +} + +func cmdUpdate(progname string, env *environment.Env, args []string) { + conf, err := env.Config() + if err != nil { + log.Fatalf("env.Config() failed: %s", err) + } + + db, err := env.DB() + if err != nil { + log.Fatalf("env.Config() failed: %s", err) + } + + updateArticles(conf, db) + + resp, err := http.PostForm(conf.UpdateUrl, url.Values{"secret": {conf.Secret}}) + if err != nil { + log.Fatalf("triggering server update failed: %s", err) + } + + if resp.StatusCode != 200 { + log.Fatalf("server update unexpectedly responded with %d %s", resp.StatusCode, resp.Status) + } +} diff --git a/views.go b/views.go new file mode 100644 index 0000000..60c6d7e --- /dev/null +++ b/views.go @@ -0,0 +1,526 @@ +package main + +import ( + "bytes" + "fmt" + "html/template" + "io" + "math" + "path" + "sort" + "strings" + "time" + + "code.laria.me/laria.me/menu" +) + +type ViewMenuItem struct { + Active bool + Url string + Title string +} + +type ViewMenu [][]ViewMenuItem + +func buildViewMenuLevel( + item *menu.MenuItem, + current string, + withNextLevel bool, +) (level []ViewMenuItem, nextLevel []ViewMenuItem) { + level = make([]ViewMenuItem, 0, len(item.Children)) + + for _, child := range item.Children { + isCur := child.Ident == current + + level = append(level, ViewMenuItem{ + Active: isCur, + Url: child.Url, + Title: child.Title, + }) + + if isCur && withNextLevel { + nextLevel, _ = buildViewMenuLevel(child, "", false) + } + } + + return +} + +func buildViewMenuLevels(item *menu.MenuItem, current string, withNextLevel bool) ViewMenu { + if item == nil { + return nil + } + + viewMenu := buildViewMenuLevels(item.Parent, item.Ident, false) + + level, nextLevel := buildViewMenuLevel(item, current, withNextLevel) + + if len(level) > 0 { + viewMenu = append(viewMenu, level) + } + + if len(nextLevel) > 0 { + viewMenu = append(viewMenu, nextLevel) + } + + return viewMenu +} + +func BuildViewMenu(menu *menu.Menu, current string) ViewMenu { + curMenu := menu.Root() + + curMenuItem := menu.ByIdent(current) + if curMenuItem != nil { + curMenu = curMenuItem.Parent + } + + return buildViewMenuLevels(curMenu, current, true) +} + +type RootData struct { + Menu ViewMenu + Title string + Main interface{} +} + +type ViewArticle struct { + Published time.Time + Slug string + Title string + Content template.HTML + ReadMore bool + Tags []string +} + +type Views struct { + archiveDay *template.Template + archive *template.Template + archiveMonth *template.Template + archiveYear *template.Template + article *template.Template + blog *template.Template + content *template.Template + search *template.Template + start *template.Template + tag *template.Template + tags *template.Template +} + +func monthText(m int) string { + switch m { + case 1: + return "January" + case 2: + return "February" + case 3: + return "March" + case 4: + return "April" + case 5: + return "May" + case 6: + return "June" + case 7: + return "July" + case 8: + return "August" + case 9: + return "September" + case 10: + return "October" + case 11: + return "November" + case 12: + return "December" + default: + return fmt.Sprintf("<unknown month %d>", m) + } +} + +func nth(n int) string { + switch n { + case 1: + return "1st" + case 2: + return "2nd" + case 3: + return "3rd" + default: + return fmt.Sprintf("%dth", n) + } +} + +type paginationArg struct { + K, V string +} + +type paginationTemplateData struct { + Action string + Args []paginationArg + Cur int + Pages int +} + +var paginationTemplate = template.Must(template.New("").Funcs(template.FuncMap{"seq": func(max int) <-chan int { + ch := make(chan int) + go func() { + defer close(ch) + for i := 1; i <= max; i++ { + ch <- i + } + }() + return ch +}}).Parse(`<form action="{{.Action}}" method="get" class="pagination"> + {{- range .Args -}} + <input type="hidden" name="{{.K}}" value="{{.V}}"> + {{- end -}} + {{- $cur := .Cur -}} + <label for="pagination-select">Page:</label> + <select name="page" id="pagination-select">{{- range (seq .Pages) -}} + <option {{if eq . $cur}}selected{{end}} value="{{.}}">{{.}}</option> + {{- end -}}</select> + <button type="submit">Go to</button> +</form>`)) + +func normalizeDate(y, m, d int) (int, int, int) { + t := time.Date(y, time.Month(m), d, 0, 0, 0, 0, time.UTC) + y, month, d := t.Date() + + return y, int(month), d +} + +func dayText(y, m, d int) string { + return fmt.Sprintf("%s %s %d", nth(d), monthText(m), y) +} + +func LoadViews(templatesDir string) (Views, error) { + views := Views{} + + root, err := template.New("root.html").ParseFiles(path.Join(templatesDir, "root.html")) + + if err != nil { + return views, err + } + + root.Funcs(template.FuncMap{ + "add": func(nums ...int) int { + sum := 0 + for _, i := range nums { + sum = sum + i + } + return sum + }, + "concat": func(ss ...string) string { + sb := new(strings.Builder) + for _, s := range ss { + sb.WriteString(s) + } + return sb.String() + }, + "nth": nth, + "day_text": dayText, + "month_text": monthText, + "pagination": func(pages, page int, path string, queryArgs ...string) (template.HTML, error) { + if len(queryArgs)%2 != 0 { + return "", fmt.Errorf("pagination: need even number of query args") + } + + args := make([]paginationArg, 0, len(queryArgs)/2) + for i := 0; i < len(queryArgs); i += 2 { + args = append(args, paginationArg{K: queryArgs[i], V: queryArgs[i+1]}) + } + + buf := new(bytes.Buffer) + + err := paginationTemplate.Execute(buf, paginationTemplateData{ + Action: path, + Args: args, + Cur: page, + Pages: pages, + }) + + if err != nil { + return "", err + } + + return template.HTML(buf.String()), nil + }, + "archive_link": func(components ...int) (string, error) { + switch len(components) { + case 0: + return "/blog/archive", nil + case 1: + y := components[0] + return fmt.Sprintf("/blog/%d", y), nil + case 2: + y := components[0] + m := components[1] + + y, m, _ = normalizeDate(y, m, 1) + + return fmt.Sprintf("/blog/%d/%d", y, m), nil + case 3: + y := components[0] + m := components[1] + d := components[2] + + y, m, d = normalizeDate(y, m, d) + + return fmt.Sprintf("/blog/%d/%d/%d", y, m, d), nil + default: + return "", fmt.Errorf("archive_link accepts at most 3 arguments") + } + }, + }) + + for name, t := range map[string]**template.Template{ + "archive-day": &(views.archiveDay), + "archive": &(views.archive), + "archive-month": &(views.archiveMonth), + "archive-year": &(views.archiveYear), + "article": &(views.article), + "blog": &(views.blog), + "content": &(views.content), + "search": &(views.search), + "start": &(views.start), + "tag": &(views.tag), + "tags": &(views.tags), + } { + templateFile := path.Join(templatesDir, name+".html") + + if *t, err = template.Must(root.Clone()).ParseFiles(templateFile); err != nil { + return views, fmt.Errorf("Failed loading template %s: %w", name, err) + } + } + + return views, nil +} + +func (v Views) RenderArchiveDay( + w io.Writer, + menu *menu.Menu, + curMenu string, + y, m, d int, + articles []ViewArticle, +) error { + return v.archiveDay.Execute(w, RootData{BuildViewMenu(menu, curMenu), dayText(y, m, d), struct { + Year, Month, Day int + MonthText string + Articles []ViewArticle + }{ + Year: y, + Month: m, + Day: d, + MonthText: monthText(m), + Articles: articles, + }}) +} + +type archiveEntryWithCount struct { + Num int + Count int +} + +type archiveEntriesWithCount []archiveEntryWithCount + +func (a archiveEntriesWithCount) Len() int { return len(a) } +func (a archiveEntriesWithCount) Less(i, j int) bool { return a[i].Num < a[j].Num } +func (a archiveEntriesWithCount) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func buildArchiveEntries(keyedCounts map[int]int) archiveEntriesWithCount { + entries := make(archiveEntriesWithCount, 0, len(keyedCounts)) + for k, v := range keyedCounts { + entries = append(entries, archiveEntryWithCount{ + Num: k, + Count: v, + }) + } + + sort.Sort(entries) + + return entries +} + +func (v Views) RenderArchive( + w io.Writer, + menu *menu.Menu, + curMenu string, + countByYear map[int]int, +) error { + return v.archive.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Archive", struct { + Years archiveEntriesWithCount + }{Years: buildArchiveEntries(countByYear)}}) +} + +func (v Views) RenderArchiveMonth( + w io.Writer, + menu *menu.Menu, + curMenu string, + year, month int, + countByDay map[int]int, +) error { + title := fmt.Sprintf("%s %d", monthText(month), year) + + return v.archiveMonth.Execute(w, RootData{BuildViewMenu(menu, curMenu), title, struct { + Year, Month int + Days archiveEntriesWithCount + }{ + Year: year, + Month: month, + Days: buildArchiveEntries(countByDay), + }}) +} + +func (v Views) RenderArchiveYear( + w io.Writer, + menu *menu.Menu, + curMenu string, + year int, + countByMonth map[int]int, +) error { + return v.archiveYear.Execute(w, RootData{BuildViewMenu(menu, curMenu), string(year), struct { + Year int + Months archiveEntriesWithCount + }{ + Year: year, + Months: buildArchiveEntries(countByMonth), + }}) +} + +func (v Views) RenderArticle( + w io.Writer, + menu *menu.Menu, + curMenu string, + article ViewArticle, +) error { + return v.article.Execute(w, RootData{BuildViewMenu(menu, curMenu), article.Title, article}) +} + +func (v Views) RenderContent( + w io.Writer, + menu *menu.Menu, + curMenu string, + html template.HTML, +) error { + return v.content.Execute(w, RootData{BuildViewMenu(menu, curMenu), "", html}) +} + +func (v Views) RenderSearch( + w io.Writer, + menu *menu.Menu, + curMenu string, + query string, + total int, + results []ViewArticle, + pages int, + page int, +) error { + return v.search.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Search", struct { + Q string + Total int + Results []ViewArticle + Pages, Page int + }{ + Q: query, + Total: total, + Results: results, + Pages: pages, + Page: page, + }}) +} + +func (v Views) RenderStart( + w io.Writer, + menu *menu.Menu, + curMenu string, + content template.HTML, + blogArticles []ViewArticle, +) error { + return v.start.Execute(w, RootData{BuildViewMenu(menu, curMenu), "", struct { + Content template.HTML + Blog []ViewArticle + }{ + Content: content, + Blog: blogArticles, + }}) +} + +func (v Views) RenderTag( + w io.Writer, + menu *menu.Menu, + curMenu string, + tag string, + articles []ViewArticle, + pages, page int, +) error { + return v.tag.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Tag " + tag, struct { + Tag string + Articles []ViewArticle + Pages, Page int + }{ + Tag: tag, + Articles: articles, + Pages: pages, + Page: page, + }}) +} + +func (v Views) RenderBlog( + w io.Writer, + menu *menu.Menu, + curMenu string, + articles []ViewArticle, + pages, page int, +) error { + return v.blog.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Blog", struct { + Articles []ViewArticle + Pages, Page int + }{ + Articles: articles, + Pages: pages, + Page: page, + }}) +} + +type tagcloudTag struct { + Tag string + SizeClass int +} + +type tagcloudTags []tagcloudTag + +func (t tagcloudTags) Len() int { return len(t) } +func (t tagcloudTags) Less(i, j int) bool { + return strings.ToLower(t[i].Tag) < strings.ToLower(t[j].Tag) +} +func (t tagcloudTags) Swap(i, j int) { t[i], t[j] = t[j], t[i] } + +const tagcloudCategories = 5 + +func (v Views) RenderTags(w io.Writer, menu *menu.Menu, curMenu string, tagCounts map[string]int) error { + tags := make(tagcloudTags, 0, len(tagCounts)) + + maxCount := 0 + for tag, count := range tagCounts { + tags = append(tags, tagcloudTag{ + Tag: tag, + SizeClass: count, + }) + + if count > maxCount { + maxCount = count + } + } + + for i, tag := range tags { + tags[i].SizeClass = int(math.Ceil((float64(tag.SizeClass) / float64(maxCount)) * tagcloudCategories)) + } + + sort.Sort(tags) + + return v.tags.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Tags", struct { + Tags tagcloudTags + }{ + Tags: tags, + }}) +} |