~pkal/llist

70b211cde2ebf327c8ad7af7378f3061eae8df65 — Philip K 6 months ago 73fe25b master
Modernize codebase
M .gitignore => .gitignore +1 -1
@@ 1,2 1,2 @@
llist
*.sqlite
*.db

D README => README +0 -71
@@ 1,71 0,0 @@
			 ______________________

			   LLIST - LINK LIST

			       Philip K.
			  philippija@gmail.com
			 ______________________


llist is a simple and stupid HN/lobste.rs clone, without voting, written
in [Golang]. It's published under a 2-clause BSD, see LICENSE.

1 Setup
=======

  After having downloaded the source, `cd' into the source directory and
  run
  ,----
  | go get
  | go build
  `----

  `go get' will download the [Go SQL driver for Sqlite] and the [Go
  Libravatar library]. After `go build' is run, a `llist' binary will
  appear in the working directory.

1.1 User file
~~~~~~~~~~~~~

  The user file lists all users, their hashed password, their email
  address and a short description. Each entry is in it's own line, and
  each section is separated by semicolons (`:'). The hashed password is
  the result of applying sha256 to a string containing the username, a
  semicolon and the password (eg. `john:sEkr3t').

  To easily add new entires to the user file, use the `add_user.sh'
  shell script. `$USERF' (see below) has to be specified for this to
  work.


2 Use
=====

  llist can either be run as a standalone binary, as a cgi- or a fcgi
  executable. The latter two options are enabled by letting the binary
  file end with `.cgi' and `.fcgi' respectively.

  Users can now submit links and comment on them. To deactivate
  commenting, one has to change to `$users' variable from `true'
  (default) to `false' in `list.gtml'.

  It is generally recommended to tweak the templates to fit the specific
  needs of the situation, either in regards to styling or template
  variables.


2.1 Environmental variables
~~~~~~~~~~~~~~~~~~~~~~~~~~~

  `PORT': Specified port to listen on when run as a standalone
          binary. Will fail if empty.
  `GTML': Sets template directory, defaults to `./gtml/'.
  `USERF': Tells llist where to look for user file, defaults to
           `/etc/llist_users'.
	   

[Golang] https://golang.org

[Go SQL driver for Sqlite] https://github.com/mattn/go-sqlite3

[Go Libravatar library] https://strk.kbt.io/projects/go/libravatar/

A README.md => README.md +59 -0
@@ 0,0 1,59 @@
llist
=====

llist is a simple and stupid HN/lobste.rs clone, without voting, written
in [Golang][go]. It's published under a 2-clause BSD, see
[LICENSE](./LICENSE).

Build
-----

After downloading the source, run

	go get
	go build

in the source directory. `go get` will download the [Go SQL driver for
Sqlite][go:sqlite] and the [Go Libravatar library][go:libr]. After `go
build` is run, a `llist` binary will appear in the working directory.

User file
---------

The user file lists all users, their hashed password, their email
address and a short description. Each entry is in it's own line, and
each section is separated by semicolons (`:`). The hashed password is
the result of applying sha256 to a string containing the username, a
semicolon and the password (eg. `john:sEkr3t`).

To easily add new entires to the user file, use the `add_user.sh`
shell script. `$USERF` (see below) has to be specified for this to
work.

Use
---

llist can either be run as a standalone binary, as a cgi- or a fcgi
executable. The latter two options are enabled by letting the binary
file end with `.cgi` and `.fcgi` respectively.

Users can now submit links and comment on them. To deactivate
commenting, one has to change to `$users` variable from `true`
(default) to `false` in `list.gtml`.

It is generally recommended to tweak the templates to fit the specific
needs of the situation, either in regards to styling or template
variables.

Environmental variables
-----------------------

- `PORT`: Specified port to listen on when run as a standalone binary.
  Will fail if empty.
- `GTML`: Sets template directory, defaults to `./gtml/`.
- `USERF`: Tells llist where to look for user file, defaults to
  `/etc/llist_users`.
	   
[go]: https://golang.org
[go:sqlite]: https://github.com/mattn/go-sqlite3
[go:libr]: https://strk.kbt.io/projects/go/libravatar/

M add_user.sh => add_user.sh +14 -9
@@ 2,19 2,21 @@
# A short shell script to add a new user to the user file

if [ ! "$USERF" ]; then
    echo "\$USERF isn't defined" &1>2
    echo "\$USERF isn't defined" 1>&2
    exit 1
fi

if [ ! -w "$USERF" ]; then
    echo "$USERF isn't writable" &1>2
    echo "$USERF isn't writable" 1>&2
    exit 2
fi

read -p "User name: " USER
echo "User name: "
read -r USER
if grep "^$USER:" "$USERF" -q; then
    echo "$USER already specified in $USERF"
    read -p "Do you want to replace it? [yes] " ANS
	echo "Do you want to replace it? [yes] "
    read -r ANS
    if [ "$ANS" = "yes" ]; then
	sed -i "/$USER/d" "$USERF"
    else


@@ 22,9 24,12 @@ if grep "^$USER:" "$USERF" -q; then
	exit 0
    fi
fi
read -p "Password: " PASSW
read -p "Email: " EMAIL
read -p "Description: " DESC
echo "Password: "
read -r
echo "Email: "PASSW
read -r
echo "Description: "EMAIL
read -r DESC

HASH=$(echo -n "$USER:$PASS" | sha256sum | cut -d" " -f1)
echo "$USER:$HASH:$EMAIL:$DESC" >> $USERF
HASH="$(printf "%s:%s" "$USER" "$PASS" | sha256sum | cut -d" " -f1)"
echo "$USER:$HASH:$EMAIL:$DESC" >> "$USERF"

M delete.go => delete.go +2 -2
@@ 21,12 21,12 @@ func delete(rw http.ResponseWriter, req *http.Request) {
	}
	switch ty {
	case "post":
		if _, err = db.Exec(DE_LINK, id); err != nil {
		if _, err = db.Exec(DB_DELETE_LINK, id); err != nil {
			http.Error(rw, err.Error(), http.StatusInternalServerError)
			return
		}
	case "comm":
		if _, err = db.Exec(DE_COMM, id); err != nil {
		if _, err = db.Exec(DB_DELETE_COMMENT, id); err != nil {
			http.Error(rw, err.Error(), http.StatusInternalServerError)
			return
		}

M genlist.go => genlist.go +6 -5
@@ 20,11 20,12 @@ func genlist(rw http.ResponseWriter, req *http.Request) {
	}

	if err := tmpl.ExecuteTemplate(rw, "list.gtml", struct {
		Page int
		Data []Link
		Tag  string
		Next bool
	}{page, ent, tag, len(ent) > ITEMS}); err != nil {
		Page        int
		Data        []Link
		Tag         string
		Next        bool
		EnableUsers bool
	}{page, ent, tag, len(ent) > ITEMS, true}); err != nil {
		log.Fatal(err)
	}
}

M genrss.go => genrss.go +2 -16
@@ 5,21 5,7 @@ import (
	"text/template"
)

const RSStmpl = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"><channel>
<title>Philip's link list</title>
<description>A collection of cyperspace links. </description>
<link>http://phi.k.vu/llist.cgi</link>
{{ range . }}
<item>
  <title>{{ .Title }}</title>
  <link>{{ .Url }}</link>
  <pubDate>{{ .Posted.Format "Mon Jan _2 15:04:05 2006"  }}</pubDate>
</item>
{{ end }}
</channel></rss>`

var RSS = template.Must(template.New("").Parse(RSStmpl))
var rss_template = template.Must(template.ParseFS(static, "static/*.tmpl"))

func genrss(rw http.ResponseWriter, req *http.Request) {
	ent, err := queryLinks(1, "")


@@ 29,7 15,7 @@ func genrss(rw http.ResponseWriter, req *http.Request) {
	}

	rw.Header().Set("Content-Type:", "text/xml; charset=UTF-8")
	err = RSS.Execute(rw, ent)
	err = rss_template.ExecuteTemplate(rw, "rss.tmpl", ent)
	if err != nil {
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return

M genuser.go => genuser.go +4 -0
@@ 18,6 18,10 @@ func genuser(rw http.ResponseWriter, req *http.Request) {
		cpage = 1
	}
	links, comms, err := queryUser(name, lpage, cpage)
	if err != nil {
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return
	}

	if err := tmpl.ExecuteTemplate(rw, "user.gtml", struct {
		Name  string

A go.mod => go.mod +8 -0
@@ 0,0 1,8 @@
module git.sr.ht/~zge/llist

go 1.16

require (
	github.com/mattn/go-sqlite3 v1.14.7
	strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251
)

A go.sum => go.sum +4 -0
@@ 0,0 1,4 @@
github.com/mattn/go-sqlite3 v1.14.7 h1:fxWBnXkxfM6sRiuH3bqJ4CfzZojMOLVc0UTsTglEghA=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 h1:mUcz5b3FJbP5Cvdq7Khzn6J9OCUQJaBwgBkCR+MOwSs=
strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251/go.mod h1:FJGmPh3vz9jSos1L/F91iAgnC/aejc0wIIrF2ZwJxdY=

M main.go => main.go +122 -128
@@ 1,13 1,9 @@
package main

import (
	"path"

	_ "github.com/mattn/go-sqlite3"
	"strk.kbt.io/projects/go/libravatar"

	"bufio"
	"database/sql"
	"embed"
	"fmt"
	"html/template"
	"log"


@@ 17,126 13,73 @@ import (
	"net/url"
	"os"
	"os/signal"
	"path"
	"strings"
	"syscall"
	"time"
)

const (
	ITEMS    = 24
	UITEMS   = 6
	DBNAME   = "llist.sqlite"
	TMPLFILE = "llist.gtml"

	CR_LINKS = `CREATE TABLE IF NOT EXISTS links (
                         id INTEGER PRIMARY KEY,
                         title TEXT, url TEXT,
                         name TEXT, posted DATETIME)`
	CR_COMM = `CREATE TABLE IF NOT EXISTS comments (
                         id INTEGER PRIMARY KEY,
                         link INTEGER NOT NULL, reply INTEGER,
                         name TEXT, content TEXT, posted DATETIME,
                         FOREIGN KEY (link) REFERENCES links (id) ON DELETE CASCADE,
                         FOREIGN KEY (reply) REFERENCES comments (id) ON DELETE CASCADE)`
	CR_TAGS = `CREATE TABLE IF NOT EXISTS tags (
                         id INTEGER PRIMARY KEY,
                         name TEXT, link INTEGER,
                         FOREIGN KEY (link) REFERENCES links (id) ON DELETE CASCADE)`
	SE_LINK = `SELECT id, title, url, posted, name,
                           (SELECT count(1) FROM comments WHERE comments.link = links.id)
                     FROM links WHERE links.id = ?`
	SE_LINKS = `SELECT id, title, url, posted, name,
                           (SELECT count(1) FROM comments WHERE comments.link = links.id)
                     FROM links
                     ORDER BY posted DESC LIMIT ? OFFSET ?`
	SE_COMM = `SELECT id, posted, name, content 
                     FROM comments
                     WHERE link = ? AND reply = ? 
                     ORDER BY posted`
	SE_BY_TAG = `SELECT links.id, title, url, posted, links.name,
                            (SELECT count(1) FROM comments WHERE comments.link = links.id)
                     FROM links
                     LEFT JOIN tags ON tags.link = links.id
                     WHERE tags.name = ?
                     ORDER BY posted DESC LIMIT ? OFFSET ?`
	SE_LINK_BY_USER = `SELECT id, title, url, posted,
                           (SELECT count(1) FROM comments WHERE comments.link = links.id)
                           FROM links
                           WHERE name = ?
                           ORDER BY posted DESC LIMIT ? OFFSET ?`
	SE_COMM_BY_USER = `SELECT id, link, content, posted
                           FROM comments
                           WHERE name = ?
                           ORDER BY posted DESC LIMIT ? OFFSET ?`
	SE_TAG_BY_OCC = `SELECT a.name FROM tags a
                         INNER JOIN tags b ON (a.name = b.name)
                         GROUP BY a.name
                         ORDER BY count(1) DESC
                         LIMIT 10;`
	SE_TAGS  = `SELECT name FROM tags WHERE link = ?`
	SE_TITLE = `SELECT title FROM links WHERE id = ?`
	IN_LINK  = `INSERT INTO links (title, url, posted, name)
                   values (?, ?, datetime("now"), ?)`
	IN_COMM = `INSERT INTO comments (link, reply, posted, name, content)
                   values (?, ?, datetime("now"), ?, ?)`
	IN_TAG  = `INSERT INTO tags (name, link) values (?, ?)`
	DE_LINK = `DELETE FROM links WHERE id = ?`
	DE_COMM = `DELETE FROM comments WHERE id = ?`
)
	_ "github.com/mattn/go-sqlite3"

var (
	tmpl   *template.Template
	db     *sql.DB
	users  map[string]User
	titles map[int]string
	"io/fs"

	"strk.kbt.io/projects/go/libravatar"
)

type User struct {
	passwd string
	Email  string
	Descr  string
}
//go:embed static/sql/init.sql
var DB_INIT string

type Comment struct {
	Id       int
	Posted   time.Time
	Name     string
	Text     string
	Replies  []Comment
	Response int
}
//go:embed static/sql/select-link.sql
var DB_SELECT_LINK string

type Link struct {
	Id       int
	Posted   time.Time
	Name     string
	Title    string
	Url      string
	Comments []Comment
	CCount   int
	Tags     []string
}
//go:embed static/sql/select-links.sql
var DB_SELECT_LINKS string

func init() {
	var err error
	if db, err = sql.Open("sqlite3", DBNAME); err != nil {
		log.Fatal(err)
	}
//go:embed static/sql/select-by-user.sql
var DB_SELECT_BY_USER string

	if _, err = db.Exec(CR_LINKS); err != nil {
		log.Fatal(err)
	}
	if _, err = db.Exec(CR_TAGS); err != nil {
		log.Fatal(err)
	}
	if _, err = db.Exec(CR_COMM); err != nil {
		log.Fatal(err)
	}
//go:embed static/sql/select-by-tag.sql
var DB_SELECT_BY_TAG string

	var dir = "gtml"
	if os.Getenv("GTML") != "" {
		dir = os.Getenv("GTML")
	}
//go:embed static/sql/delete-comment.sql
var DB_DELETE_COMMENT string

//go:embed static/sql/delete-link.sql
var DB_DELETE_LINK string

//go:embed static/sql/insert-tag.sql
var DB_INSERT_TAG string

//go:embed static/sql/insert-comment.sql
var DB_INSERT_COMMENT string

//go:embed static/sql/insert-link.sql
var DB_INSERT_LINK string

//go:embed static/sql/select-title.sql
var DB_SELECT_TITLE string

//go:embed static/sql/select-tags.sql
var DB_SELECT_TAGS string

//go:embed static/sql/select-tags-by-frequency.sql
var DB_SELECT_TAGS_BY_FREQUENCY string

//go:embed static/sql/select-comments-by-user.sql
var DB_SELECT_COMMENTS_BY_USER string

//go:embed static/sql/select-comments.sql
var DB_SELECT_COMMENTS string

//go:embed static
var static embed.FS

const (
	ITEMS  = 24
	UITEMS = 6
	DBNAME = "llist.db"
)

var (
	tmpl = template.Must(template.New("").Funcs(template.FuncMap{
		"inc": func(i int) int {
			return i + 1


@@ 161,22 104,29 @@ func init() {
			return r
		}, "since": func(p time.Time) string {
			d := time.Since(p)
			var num int
			var unit, plural string

			switch {
			case d.Hours() > 24*365:
				return fmt.Sprintf("%d years ago",
					int(d.Hours()/(24*365)))
				num = int(d.Hours() / (24 * 365))
				unit = "year"
			case d.Hours() > 24:
				return fmt.Sprintf("%d days ago",
					int(d.Hours()/24))
				num = int(d.Hours() / 24)
				unit = "days"
			case d.Hours() > 1:
				return fmt.Sprintf("%d hours ago",
					int(d.Hours()))
			case d.Minutes() > 1:
				return fmt.Sprintf("%d minutes ago",
					int(d.Minutes()))
			default: // if posted less than 1m ago
				num = int(d.Hours())
				unit = "hour"
			case d.Minutes() > 5:
				num = int(d.Minutes())
				unit = "minute"
			default: // if posted less than 5m ago
				return "just now"
			}
			if num > 1 {
				plural = "s"
			}
			return fmt.Sprintf("%d %s%s ago", num, unit, plural)
		}, "cGetTitle": func(id int) string {
			title, err := queryTitle(id)
			if err != nil {


@@ 184,7 134,36 @@ func init() {
			}
			return title
		},
	}).ParseGlob(dir + "/*.gtml"))
	}).ParseFS(static, "static/*.gtml"))
	db     *sql.DB
	users  map[string]User
	titles map[int]string
)

type User struct {
	passwd string
	Email  string
	Descr  string
}

type Comment struct {
	Id       int
	Posted   time.Time
	Name     string
	Text     string
	Replies  []Comment
	Response int
}

type Link struct {
	Id       int
	Posted   time.Time
	Name     string
	Title    string
	Url      string
	Comments []Comment
	CCount   int
	Tags     []string
}

func forceLoadUsers() {


@@ 233,6 212,15 @@ func loadUsers() {
func main() {
	defer db.Close()

	var err error
	if db, err = sql.Open("sqlite3", DBNAME); err != nil {
		log.Fatal(err)
	}

	if _, err = db.Exec(DB_INIT); err != nil {
		log.Fatal(err)
	}

	// reaload users when SIGUSER1 is caught
	c := make(chan os.Signal)
	signal.Notify(c, syscall.SIGUSR1)


@@ 250,6 238,12 @@ func main() {
	http.HandleFunc("/rss", genrss)
	http.HandleFunc("/", genlist)

	assets, err := fs.Sub(static, "static")
	if err != nil {
		log.Fatal(err)
	}
	http.Handle("/assets/", http.FileServer(http.FS(assets)))

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
		rw.Header().Set("Content-Type", "text/html")


@@ 266,10 260,10 @@ func main() {
	} else if strings.HasSuffix(os.Args[0], ".fcgi") {
		log.Println(fcgi.Serve(nil, mux))
	} else {
		if port := os.Getenv("PORT"); port == "" {
			log.Fatal("$PORT not defined")
		} else {
			log.Fatal(http.ListenAndServe(":"+port, nil))
		listen := os.Getenv("LISTEN")
		if listen == "" {
			listen = ":8080"
		}
		log.Fatal(http.ListenAndServe(listen, nil))
	}
}

A static/assets/style.css => static/assets/style.css +133 -0
@@ 0,0 1,133 @@
html {
    background: honeydew;
}

body {
    margin: auto;
    max-width: 80ch;
    padding: 16px;
    font-family: sans-serif;
}

nav {
    padding-bottom: 8px;
    border-bottom: 2px solid silver;
    margin-bottom: 8px;
}

ul.replies {
    padding-left: 12px;
    border-left: 2px solid silver;
}

a.mlink {
    font-weight: bold;
    text-decoration: none;
}

ul.replies > li {
    list-style: none;
}

.posted, span.domain, a#clear {
    color: gray;
    font-size: 0.75em;
    text-decoration: none;
}

span.domain, a#clear, input, textarea {
    font-family: monospace, monospace;
}

div.c > a {
    text-decoration: none;
    cursor: pointer;
}

li {
    margin: 4px;
}

a.mlink {
    font-weight: bold;
    text-decoration: none;
}

span.posted, span.domain, a#clear {
    color: gray;
    font-size: 0.75em;
    text-decoration: none;
}

span.tags > a, span.tag {
    margin: 2px;
    border: solid 1px #ffdd00;
    background: #ffee88;
    border-width: 1px;
    border-radius: 4px;
    border-style: solid;
    text-decoration: none;
    color: inherit;
    font-size: 0.8em;
    padding: 2px;
}

span.tags > a.type, span.type {
    border: solid 1px #00a2ff;
    background: #88d5ff;
}

span.domain, a#clear {
    font-family: monospace, monospace;
}

div#ttags span {
    margin: 2px;
    padding: 2px;
    border: solid 1px #ffdd00;
    background: #ffee88;
    border-radius: 4px;
    text-decoration: none;
    color: inherit;
    font-size: 0.8em;
    cursor: pointer;
}

main {
    width: 100%;
}

img#ppic {
    float: right;
}

@media (min-width: 550px) {
    div#comms, div#posts {
	width: 50%;
	margin: 0;
	float: left;
    }
}

li {
    margin: 4px;
}

a.mlink {
    font-weight: bold;
    text-decoration: none;
}

a.plink {
    font-style: italic;
}

span.posted, span.domain, a#clear {
    color: gray;
    font-size: 0.75em;
    text-decoration: none;
}

span.domain, a#clear {
    font-family: monospace, monospace;
}

A static/header.gtml => static/header.gtml +10 -0
@@ 0,0 1,10 @@
<!DOCTYPE html>
<title>Link list</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/assets/style.css">
<meta name="viewport" content="initial-scale=1.0,maximum-scale=1.0,width=device-width">
<nav>
    <strong><a href="/">A link list</a></strong> -
    <a href="/subm">Submit</a> |
    <a href="/rss">RSS</a>
</nav>

R gtml/list.gtml => static/list.gtml +8 -59
@@ 1,63 1,12 @@
<!doctype html>
<title>Link list</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/style.css">
<meta name="viewport" content="initial-scale=1.0,maximum-scale=1.0,width=device-width">
{{ $home := "/" }}
{{ $base := "" }}
{{ $source := "http://phi.k.vu/cgit.cgi/llist" }}
{{ $users := true }}
<style>
 li {
     margin: 4px;
 }
 
 a.mlink {
     font-weight: bold;
     text-decoration: none;
 }
 
 span.posted, span.domain, a#clear {
     color: gray;
     font-size: 0.75em;
     text-decoration: none;
 }
 
 span.tags > a, span.tag {
     margin: 2px;
     border: solid 1px #ffdd00;
     background: #ffee88;
     border-width: 1px;
     border-radius: 4px;
     border-style: solid;
     text-decoration: none;
     color: inherit;
     font-size: 0.8em;
     padding: 2px;
 }

 span.tags > a.type, span.type {
     border: solid 1px #00a2ff;
     background: #88d5ff;
 }

 span.domain, a#clear {
     font-family: monospace, monospace;
 }
</style>
<nav>
    <strong><a href="{{ $home }}">My</a> link list</strong> -
    <a href="{{ $base }}/subm">Submit</a> |
    <a href="{{ $base }}/rss">RSS</a> |
    <a href="{{ $source }}">Source</a>
</nav>
{{template "header.gtml"}}
{{ $users := .EnableUsers }}
<main>
    <p>A collection of <em>cyperspace</em> links. </p>
    {{ if .Tag }}
	<p>
	    Limited by tag:
	    <span class="tag {{ if isType .Tag }} type {{ end }}">{{ .Tag }}</span>
	    <a id="clear" href="{{ $base }}">(clear)</a>
	    <a id="clear" href="/">(clear)</a>
	</p>
	<hr/>
    {{ end}}


@@ 70,7 19,7 @@
		    <span class="tags">
			{{ range . }}
			    {{ if isType . }}
				<a class="type" href="{{ $base }}?tag={{ . }}">{{ . }}</a>
				<a class="type" href="/?tag={{ . }}">{{ . }}</a>
			    {{ else }}
				<a href="?tag={{ . }}">{{ . }}</a>
			    {{ end }}


@@ 81,11 30,11 @@
		{{ if $users }}
		    <br/>
		    <span class="posted">
			via <a href="{{ $base }}/user?id={{ .Name }}">{{ .Name }}</a> |
			via <a href="/user?id={{ .Name }}">{{ .Name }}</a> |
			<span title="{{ .Posted.Format "02 Jan 06 15:04" }}">{{ since .Posted}}</span> |
			<a href="https://archive.is/{{ .Url }}" rel="nofollow"
			   target="_new">cached</a> |
			<a href="{{ $base }}/post?id={{ printf "%x" .Id }}">{{ .CCount }} comments</a>
			<a href="/post?id={{ printf "%x" .Id }}">{{ .CCount }} comments</a>
		    </span>
		{{ else }}
		    <span title="{{ .Posted.Format "02 Jan 06 15:04" }}">{{ since .Posted}}</span> |


@@ 96,10 45,10 @@
	{{ end}}
    </ol>
    {{ if gt .Page 1 }}
	<a href="{{ $base }}?p={{ dec .Page }}">prev</a> //
	<a href="/?p={{ dec .Page }}">prev</a> /
    {{ end }}
    Page {{ .Page }}
    {{ if .Next }}
	// <a href="{{ $base }}?p={{ inc .Page }}">next</a>
	/ <a href="/?p={{ inc .Page }}">next</a>
    {{ end}}
</main>

R gtml/post.gtml => static/post.gtml +3 -46
@@ 1,47 1,4 @@
<!doctype html>
<title>Link list</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/style.css">
<meta name="viewport" content="initial-scale=1.0,maximum-scale=1.0,width=device-width">
{{ $home := "/" }}
{{ $base := "" }}
{{ $source := "http://phi.k.vu/cgit.cgi/llist" }}
<style>
 ul.replies {
     padding-left: 12px;
     border-left: 2px solid silver;
 }
 
 a.mlink {
     font-weight: bold;
     text-decoration: none;
 }
 
 ul.replies > li {
     list-style: none;
 }
 
 .posted, span.domain, a#clear {
     color: gray;
     font-size: 0.75em;
     text-decoration: none;
 }
  
 span.domain, a#clear, input, textarea {
     font-family: monospace, monospace;
 }

 div.c > a {
     text-decoration: none;
     cursor: pointer;
 }
</style>
<nav>
    <strong><a href="{{ $home }}">My</a> link list</strong> -
    <a href="{{ $base }}">List</a> |
    <a href="{{ $base }}/rss">RSS</a> |
    <a href="{{ $source }}">Source</a>
</nav>
{{template "header.gtml"}}
<main>
    <header>
	<a href="{{ .Url }}" class="mlink">{{ .Title }}</a>


@@ 50,7 7,7 @@
	    <span class="tags">
		{{ range . }}
		    {{ if isType . }}
			<a class="type" href="{{ $base }}?tag={{ . }}">{{ . }}</a>
			<a class="type" href="/?tag={{ . }}">{{ . }}</a>
		    {{ else }}
			<a href="?tag={{ . }}">{{ . }}</a>
		    {{ end }}


@@ 59,7 16,7 @@
	{{ end }}
	<br/>
	<span class="posted">
	    posted by <a href="{{ $base }}/user?id={{ .Name }}">{{ .Name }}</a> |
	    posted by <a href="/user?id={{ .Name }}">{{ .Name }}</a> |
	    <span class="posted" title="{{ .Posted.Format "02 Jan 06 15:04" }}">{{ since .Posted }}</span> |
	    <a href="https://archive.is/{{ .Url }}" rel="nofollow"
	       target="_new">cached</a> |

A static/rss.tmpl => static/rss.tmpl +13 -0
@@ 0,0 1,13 @@
<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"><channel>
<title>Philip's link list</title>
<description>A collection of cyperspace links. </description>
<link>http://phi.k.vu/llist.cgi</link>
{{ range . }}
<item>
  <title>{{ .Title }}</title>
  <link>{{ .Url }}</link>
  <pubDate>{{ .Posted.Format "Mon Jan _2 15:04:05 2006"  }}</pubDate>
</item>
{{ end }}
</channel></rss>
\ No newline at end of file

A static/sql/delete-comment.sql => static/sql/delete-comment.sql +3 -0
@@ 0,0 1,3 @@
-- Delete a comment -*- sql-dialect: sqlite; -*-

DELETE FROM comments WHERE id = ?

A static/sql/delete-link.sql => static/sql/delete-link.sql +3 -0
@@ 0,0 1,3 @@
-- Delete a link -*- sql-dialect: sqlite; -*-

DELETE FROM links WHERE id = ?

A static/sql/init.sql => static/sql/init.sql +18 -0
@@ 0,0 1,18 @@
-- Initialize Database -*- sql-dialect: sqlite; -*-

CREATE TABLE IF NOT EXISTS links (
	id INTEGER PRIMARY KEY,
	title TEXT, url TEXT,
	name TEXT, posted DATETIME);

CREATE TABLE IF NOT EXISTS comments (
	id INTEGER PRIMARY KEY,
	link INTEGER NOT NULL, reply INTEGER,
	name TEXT, content TEXT, posted DATETIME,
	FOREIGN KEY (link) REFERENCES links (id) ON DELETE CASCADE,
	FOREIGN KEY (reply) REFERENCES comments (id) ON DELETE CASCADE);

CREATE TABLE IF NOT EXISTS tags (
	id INTEGER PRIMARY KEY,
	name TEXT, link INTEGER,
	FOREIGN KEY (link) REFERENCES links (id) ON DELETE CASCADE);

A static/sql/insert-comment.sql => static/sql/insert-comment.sql +4 -0
@@ 0,0 1,4 @@
-- Add a new comment -*- sql-dialect: sqlite; -*-

INSERT INTO comments (link, reply, posted, name, content)
values (?, ?, datetime("now"), ?, ?)

A static/sql/insert-link.sql => static/sql/insert-link.sql +4 -0
@@ 0,0 1,4 @@
-- Add a new link -*- sql-dialect: sqlite; -*-

INSERT INTO links (title, url, posted, name)
values (?, ?, datetime("now"), ?)

A static/sql/insert-tag.sql => static/sql/insert-tag.sql +3 -0
@@ 0,0 1,3 @@
-- Add a new tag -*- sql-dialect: sqlite; -*-

INSERT INTO tags (name, link) values (?, ?)

A static/sql/select-by-tag.sql => static/sql/select-by-tag.sql +8 -0
@@ 0,0 1,8 @@
-- Select by a tag -*- sql-dialect: sqlite; -*-

SELECT links.id, links.title, links.url, links.posted, links.name, count(1)-1
FROM links LEFT JOIN comments ON comments.link = links.id
     	   INNER JOIN tags ON tags.link = links.id
WHERE tags.name = ?
GROUP BY links.id
ORDER BY links.posted DESC LIMIT ? OFFSET ?

A static/sql/select-by-user.sql => static/sql/select-by-user.sql +7 -0
@@ 0,0 1,7 @@
-- Select by username -*- sql-dialect: sqlite; -*-

SELECT links.id, links.title, links.url, links.posted, count(1)-1
FROM links LEFT JOIN comments ON comments.link = links.id
WHERE links.name = ?
GROUP BY links.id
ORDER BY links.posted DESC LIMIT ? OFFSET ?

A static/sql/select-comments-by-user.sql => static/sql/select-comments-by-user.sql +6 -0
@@ 0,0 1,6 @@
-- Select by username -*- sql-dialect: sqlite; -*-

SELECT id, link, content, posted
FROM comments
WHERE name = ?
ORDER BY posted DESC LIMIT ? OFFSET ?

A static/sql/select-comments.sql => static/sql/select-comments.sql +6 -0
@@ 0,0 1,6 @@
-- Select all comments -*- sql-dialect: sqlite; -*-

SELECT id, posted, name, content 
FROM comments
WHERE link = ? AND reply = ? 
ORDER BY posted

A static/sql/select-link.sql => static/sql/select-link.sql +6 -0
@@ 0,0 1,6 @@
-- Select a specific link -*- sql-dialect: sqlite; -*-

SELECT links.id, links.title, links.url, links.posted, links.name, count(1)-1
FROM links LEFT JOIN comments ON comments.link = links.id
WHERE links.id = ?
GROUP BY links.id

A static/sql/select-links.sql => static/sql/select-links.sql +6 -0
@@ 0,0 1,6 @@
-- Select a list of link -*- sql-dialect: sqlite; -*-

SELECT links.id, links.title, links.url, links.posted, links.name, count(1)-1
FROM links LEFT JOIN comments ON comments.link = links.id
GROUP BY links.id
ORDER BY links.posted DESC LIMIT ? OFFSET ?

A static/sql/select-tags-by-frequency.sql => static/sql/select-tags-by-frequency.sql +7 -0
@@ 0,0 1,7 @@
-- Select tags by frequency -*- sql-dialect: sqlite; -*-

SELECT a.name
FROM tags a CROSS JOIN tags b ON a.name = b.name
GROUP BY a.name
ORDER BY count(1) DESC
LIMIT 10

A static/sql/select-tags.sql => static/sql/select-tags.sql +3 -0
@@ 0,0 1,3 @@
-- Select tags -*- sql-dialect: sqlite; -*-

SELECT name FROM tags WHERE link = ?

A static/sql/select-title.sql => static/sql/select-title.sql +3 -0
@@ 0,0 1,3 @@
-- Select title -*- sql-dialect: sqlite; -*-

SELECT title FROM links WHERE id = ?

R gtml/submit.gtml => static/submit.gtml +1 -27
@@ 1,30 1,4 @@
<!doctype html>
<title>Link list</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/style.css">
<meta name="viewport" content="initial-scale=1.0,maximum-scale=1.0,width=device-width">
<style>
 div#ttags span {
     margin: 2px;
     padding: 2px;
     border: solid 1px #ffdd00;
     background: #ffee88;
     border-radius: 4px;
     text-decoration: none;
     color: inherit;
     font-size: 0.8em;
     cursor: pointer;
 }
</style>
{{ $home := "/" }}
{{ $base := "" }}
{{ $source := "http://phi.k.vu/cgit.cgi/llist" }}
<nav>
    <strong><a href="{{ $home }}">My</a> link list</strong> -
    <a href="{{ $base }}">List</a> |
    <a href="{{ $base }}/rss">RSS</a> |
    <a href="{{ $source }}">Source</a>
</nav>
{{template "header.gtml"}}
<main>
    <p>Submit a link. Tags are separated by whitespaces.</p>
    <form method="POST">

R gtml/user.gtml => static/user.gtml +12 -65
@@ 1,57 1,4 @@
<!doctype html>
<title>Link list</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="/style.css">
<meta name="viewport" content="initial-scale=1.0,maximum-scale=1.0,width=device-width">
{{ $home := "/" }}
{{ $base := "" }}
{{ $source := "http://phi.k.vu/cgit.cgi/llist" }}
<style>
 main {
     width: 100%;
 }
 
 img#ppic {
     float: right;
 }

 @media (min-width: 550px) {
     div#comms, div#posts {
	 width: 50%;
	 margin: 0;
	 float: left;
     }
 }

 li {
     margin: 4px;
 }

 a.mlink {
     font-weight: bold;
     text-decoration: none;
 }

 a.plink {
     font-style: italic;
 }

 span.posted, span.domain, a#clear {
     color: gray;
     font-size: 0.75em;
     text-decoration: none;
 }

 span.domain, a#clear {
     font-family: monospace, monospace;
 }
</style>
<nav>
    <strong><a href="{{ $home }}">My</a> link list</strong> -
    <a href="{{ $base }}/">List</a> |
    <a href="{{ $base }}/rss">RSS</a> |
    <a href="{{ $source }}">Source</a>
</nav>
{{template "header.gtml"}}
<main>
    <img id="ppic" src="{{ libr .Usr.Email }}"/>
    <h1>User: <em>{{ .Name }}</em></h1>


@@ 66,12 13,12 @@
		    <span class="domain">{{ domain .Url }}</span>
		    <br/>
		    <span class="posted">
			via <a href="{{ $base }}/user?id={{ .Name }}">{{ .Name }}</a> |
			<span title="{{ .Posted.Format "02 Jan 06 15:04" }}">{{ since .Posted}}</span> |
			via <a href="/user?id={{ .Name }}">{{ .Name }}</a> |
			<span title="{{ .Posted.Format "02 Jan 2006 15:04" }}">{{ since .Posted}}</span> |
			<a href="https://archive.is/{{ .Url }}" rel="nofollow"
			   target="_new">cached</a> |
			<a href="{{ $base }}/post?id={{ printf "%x" .Id }}">{{ .CCount }} comments</a> |
			<a href="{{ $base }}/delete?t=post&id={{ .Id }}">delete</a>
			<a href="/post?id={{ printf "%x" .Id }}">{{ .CCount }} comments</a> |
			<a href="/delete?t=post&id={{ .Id }}">delete</a>
		    </span>
		</li>
	    {{ else }}


@@ 80,11 27,11 @@
	</ul>

	{{ if gt .Lpage 1 }}
	    <a href="{{ $base }}/user?id={{ .Name }}&lp={{ dec .Lpage }}&cp={{ .Cpage }}">prev</a> //
	    <a href="/user?id={{ .Name }}&lp={{ dec .Lpage }}&cp={{ .Cpage }}">prev</a> /
	{{ end }}
	Page {{ .Lpage }}
	{{ if .Lnext }}
	    // <a href="{{ $base }}/user?id={{ .Name }}&lp={{ inc .Lpage }}&cp={{ .Cpage }}">next</a>
	    / <a href="/user?id={{ .Name }}&lp={{ inc .Lpage }}&cp={{ .Cpage }}">next</a>
	{{ end}}

    </div>


@@ 93,10 40,10 @@
	<ul>
	    {{ range .Comms }}
		<li class="c" name="{{ .Id }}">
		    <a class="plink" href="../post?id={{ .Response }}#{{ .Id }}">{{ cGetTitle .Response }}</a>	    
		    <a class="plink" href="../post?id={{ .Response }}#{{ .Id }}">{{ cGetTitle .Response }}</a>
		    <span  class="posted">
			<span title="{{ .Posted.Format "02 Jan 06 15:04" }}">{{ since .Posted }}</span> |
			<a href="{{ $base }}/delete?t=comm&id={{ .Id }}">delete</a>
			<span title="{{ .Posted.Format "02 Jan 2006 15:04" }}">{{ since .Posted }}</span> |
			<a href="/delete?t=comm&id={{ .Id }}">delete</a>
		    </span>
		    <br/>
		    <p>{{ .Text }}</p>


@@ 107,11 54,11 @@
	</ul>

	{{ if gt .Cpage 1 }}
	    <a href="{{ $base }}/user?id={{ .Name }}&lp={{ .Lpage }}&cp={{ dec .Cpage }}">prev</a> //
	    <a href="/user?id={{ .Name }}&lp={{ .Lpage }}&cp={{ dec .Cpage }}">prev</a> /
	{{ end }}
	Page {{ .Cpage }}
	{{ if .Cnext }}
	    // <a href="{{ $base }}/user?id={{ .Name }}&lp={{ .Lpage }}&cp={{ inc .Cpage }}">next</a>
	    / <a href="/user?id={{ .Name }}&lp={{ .Lpage }}&cp={{ inc .Cpage }}">next</a>
	{{ end}}
    </div>
    <div style="clear: both;"></div>

M util.go => util.go +13 -12
@@ 15,7 15,7 @@ func (e Link) addLink() error {
		return fmt.Errorf("URL not absolute")
	}

	r, err := db.Exec(IN_LINK, e.Title, e.Url, e.Name)
	r, err := db.Exec(DB_INSERT_LINK, e.Title, e.Url, e.Name)
	if err != nil {
		return err
	}


@@ 34,7 34,7 @@ func (e Link) addLink() error {
		if T == "" {
			continue
		}
		_, err := tx.Exec(IN_TAG, T, id)
		_, err := tx.Exec(DB_INSERT_TAG, T, id)
		if err != nil {
			return err
		}


@@ 44,7 44,7 @@ func (e Link) addLink() error {
}

func (c Comment) addComment(pid, cid int) (int64, error) {
	r, err := db.Exec(IN_COMM, pid, cid, c.Name, c.Text)
	r, err := db.Exec(DB_INSERT_COMMENT, pid, cid, c.Name, c.Text)
	if err != nil {
		return -1, nil
	}


@@ 52,7 52,7 @@ func (c Comment) addComment(pid, cid int) (int64, error) {
}

func queryLink(pid int) (Link, error) {
	row := db.QueryRow(SE_LINK, pid)
	row := db.QueryRow(DB_SELECT_LINK, pid)
	var l Link
	err := row.Scan(&l.Id, &l.Title, &l.Url, &l.Posted, &l.Name, &l.CCount)



@@ 71,10 71,11 @@ func queryLinks(page int, tag string) ([]Link, error) {
		err  error
		rows *sql.Rows
	)

	if tag == "" {
		rows, err = db.Query(SE_LINKS, ITEMS+1, (page-1)*ITEMS)
		rows, err = db.Query(DB_SELECT_LINKS, ITEMS+1, (page-1)*ITEMS)
	} else {
		rows, err = db.Query(SE_BY_TAG, tag, ITEMS+1, (page-1)*ITEMS)
		rows, err = db.Query(DB_SELECT_BY_TAG, tag, ITEMS+1, (page-1)*ITEMS)
	}
	if err != nil {
		return nil, err


@@ 85,7 86,7 @@ func queryLinks(page int, tag string) ([]Link, error) {
		var l Link
		rows.Scan(&l.Id, &l.Title, &l.Url, &l.Posted, &l.Name, &l.CCount)

		trows, err := db.Query(SE_TAGS, l.Id)
		trows, err := db.Query(DB_SELECT_TAGS, l.Id)
		if err != nil {
			return nil, err
		}


@@ 106,7 107,7 @@ func queryComments(postId, replyId int) ([]Comment, error) {

	var err error
	var rows *sql.Rows
	rows, err = db.Query(SE_COMM, postId, replyId)
	rows, err = db.Query(DB_SELECT_COMMENTS, postId, replyId)
	if err != nil {
		return nil, err
	}


@@ 139,7 140,7 @@ func queryUser(name string, lpage, cpage int) ([]Link, []Comment, error) {
	links := make([]Link, 0, UITEMS+1)
	comms := make([]Comment, 0, UITEMS+1)

	rows, err := db.Query(SE_LINK_BY_USER, name, UITEMS+1, (lpage-1)*UITEMS)
	rows, err := db.Query(DB_SELECT_BY_USER, name, UITEMS+1, (lpage-1)*UITEMS)
	if err != nil {
		return nil, nil, err
	}


@@ 160,7 161,7 @@ func queryUser(name string, lpage, cpage int) ([]Link, []Comment, error) {
		})
	}

	rows, err = db.Query(SE_COMM_BY_USER, name, UITEMS+1, (cpage-1)*UITEMS)
	rows, err = db.Query(DB_SELECT_COMMENTS_BY_USER, name, UITEMS+1, (cpage-1)*UITEMS)
	if err != nil {
		return nil, nil, err
	}


@@ 186,7 187,7 @@ func queryUser(name string, lpage, cpage int) ([]Link, []Comment, error) {
func queryTags() ([]string, error) {
	var tags []string

	rows, err := db.Query(SE_TAG_BY_OCC)
	rows, err := db.Query(DB_SELECT_TAGS_BY_FREQUENCY)
	if err != nil {
		return nil, err
	}


@@ 205,7 206,7 @@ func queryTitle(id int) (string, error) {
		return title, nil
	}

	row := db.QueryRow(SE_TITLE, id)
	row := db.QueryRow(DB_SELECT_TITLE, id)
	if err := row.Scan(&title); err != nil {
		return "", err
	}