Implement new UI, fix DB use

Amolith created

- Implement dual-column UI
- Swap project table index from URL to ID
- Enable WAL for concurrent reads
- Use a Mutex to protect writes

Change summary

cmd/willow.go                             |  14 +-
db/db.go                                  |  45 +++-------
db/migrations.go                          |  11 ++
db/posthooks.go                           |  32 +++++++
db/project.go                             |  49 +++++------
db/release.go                             |  47 +++++------
db/sql/2_swap_project_url_for_id.down.sql |  28 ++++++
db/sql/2_swap_project_url_for_id.up.sql   |  29 +++++++
db/users.go                               |   8 +
project/project.go                        | 101 +++++++++++++++++-------
ws/static/home.html                       |  76 ++++++++++++------
ws/static/login.html                      |   2 
ws/static/new.html                        |   2 
ws/static/select-release.html             |   7 
ws/static/styles.css                      |  88 ++++++++++++++++----
ws/ws.go                                  |  45 ++++++----
16 files changed, 393 insertions(+), 191 deletions(-)

Detailed changes

cmd/willow.go 🔗

@@ -11,6 +11,7 @@ import (
 	"net/http"
 	"os"
 	"strconv"
+	"sync"
 
 	"git.sr.ht/~amolith/willow/db"
 	"git.sr.ht/~amolith/willow/project"
@@ -22,9 +23,8 @@ import (
 
 type (
 	Config struct {
-		Server      server
-		CSVLocation string
-		DBConn      string
+		Server server
+		DBConn string
 		// TODO: Make cache location configurable
 		// CacheLocation string
 		FetchInterval int
@@ -90,17 +90,17 @@ func main() {
 		os.Exit(0)
 	}
 
-	fmt.Println("Starting refresh loop")
-	go project.RefreshLoop(dbConn, config.FetchInterval, &manualRefresh, &req, &res)
+	mu := sync.Mutex{}
 
-	var mutex sync.Mutex
+	fmt.Println("Starting refresh loop")
+	go project.RefreshLoop(dbConn, &mu, config.FetchInterval, &manualRefresh, &req, &res)
 
 	wsHandler := ws.Handler{
 		DbConn:        dbConn,
-		Mutex:         &mutex,
 		Req:           &req,
 		Res:           &res,
 		ManualRefresh: &manualRefresh,
+		Mu:            &mu,
 	}
 
 	mux := http.NewServeMux()

db/db.go 🔗

@@ -7,6 +7,8 @@ package db
 import (
 	"database/sql"
 	_ "embed"
+	"errors"
+	"sync"
 
 	_ "modernc.org/sqlite"
 )
@@ -14,46 +16,25 @@ import (
 //go:embed sql/schema.sql
 var schema string
 
+var mutex = &sync.Mutex{}
+
 // Open opens a connection to the SQLite database
 func Open(dbPath string) (*sql.DB, error) {
-	return sql.Open("sqlite", dbPath)
+	return sql.Open("sqlite", "file:"+dbPath+"?_pragma=journal_mode%3DWAL")
 }
 
 // VerifySchema checks whether the schema has been initalised and initialises it
 // if not
 func InitialiseDatabase(dbConn *sql.DB) error {
 	var name string
-	err := dbConn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&name)
-	if err == nil {
-		return nil
-	}
-
-	tables := []string{
-		"users",
-		"sessions",
-		"projects",
-		"releases",
-	}
-
-	for _, table := range tables {
-		name := ""
-		err := dbConn.QueryRow(
-			"SELECT name FROM sqlite_master WHERE type='table' AND name=@table",
-			sql.Named("table", table),
-		).Scan(&name)
-		if err != nil {
-			if err = loadSchema(dbConn); err != nil {
-				return err
-			}
+	err := dbConn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").Scan(&name)
+	if err != nil && errors.Is(err, sql.ErrNoRows) {
+		mutex.Lock()
+		defer mutex.Unlock()
+		if _, err := dbConn.Exec(schema); err != nil {
+			return err
 		}
+		return nil
 	}
-	return nil
-}
-
-// loadSchema loads the initial schema into the database
-func loadSchema(dbConn *sql.DB) error {
-	if _, err := dbConn.Exec(schema); err != nil {
-		return err
-	}
-	return nil
+	return err
 }

db/migrations.go 🔗

@@ -22,6 +22,10 @@ var (
 	migration1Up string
 	//go:embed sql/1_add_project_ids.down.sql
 	migration1Down string
+	//go:embed sql/2_swap_project_url_for_id.up.sql
+	migration2Up string
+	//go:embed sql/2_swap_project_url_for_id.down.sql
+	migration2Down string
 )
 
 var migrations = [...]migration{
@@ -35,6 +39,13 @@ var migrations = [...]migration{
 		downQuery: migration1Down,
 		postHook:  generateAndInsertProjectIDs,
 	},
+	2: {
+		upQuery:   migration2Up,
+		downQuery: migration2Down,
+	},
+	3: {
+		postHook: correctProjectIDs,
+	},
 }
 
 // Migrate runs all pending migrations

db/posthooks.go 🔗

@@ -55,3 +55,35 @@ func generateAndInsertProjectIDs(tx *sql.Tx) error {
 
 	return nil
 }
+
+// Basing the project's ID on when it was created (L37) was a bad idea.
+func correctProjectIDs(tx *sql.Tx) error {
+	rows, err := tx.Query("SELECT id, url, name, forge FROM projects")
+	if err != nil {
+		return fmt.Errorf("failed to list projects in projects_tmp: %w", err)
+	}
+	defer rows.Close()
+
+	for rows.Next() {
+		var (
+			old_id string
+			url    string
+			name   string
+			forge  string
+		)
+		if err := rows.Scan(&old_id, &url, &name, &forge); err != nil {
+			return fmt.Errorf("failed to scan row from projects_tmp: %w", err)
+		}
+		id := fmt.Sprintf("%x", sha256.Sum256([]byte(url+name+forge)))
+		_, err = tx.Exec(
+			"UPDATE projects SET id = @id WHERE id = @old_id",
+			sql.Named("id", id),
+			sql.Named("old_id", old_id),
+		)
+		if err != nil {
+			return fmt.Errorf("failed to insert project into projects: %w", err)
+		}
+	}
+
+	return nil
+}

db/project.go 🔗

@@ -4,32 +4,32 @@
 
 package db
 
-import "database/sql"
-
-// CreateProject adds a project to the database
-func CreateProject(db *sql.DB, url, name, forge, running string) error {
-	_, err := db.Exec("INSERT INTO projects (url, name, forge, version) VALUES (?, ?, ?, ?)", url, name, forge, running)
-	return err
-}
+import (
+	"database/sql"
+	"sync"
+)
 
 // DeleteProject deletes a project from the database
-func DeleteProject(db *sql.DB, url string) error {
-	_, err := db.Exec("DELETE FROM projects WHERE url = ?", url)
+func DeleteProject(db *sql.DB, mu *sync.Mutex, id string) error {
+	mu.Lock()
+	defer mu.Unlock()
+	_, err := db.Exec("DELETE FROM projects WHERE id = ?", id)
 	if err != nil {
 		return err
 	}
-	_, err = db.Exec("DELETE FROM releases WHERE project_url = ?", url)
+	_, err = db.Exec("DELETE FROM releases WHERE project_id = ?", id)
 	return err
 }
 
 // GetProject returns a project from the database
 func GetProject(db *sql.DB, url string) (map[string]string, error) {
-	var name, forge, version string
-	err := db.QueryRow("SELECT name, forge, version FROM projects WHERE url = ?", url).Scan(&name, &forge, &version)
+	var id, name, forge, version string
+	err := db.QueryRow("SELECT id, name, forge, version FROM projects WHERE url = ?", url).Scan(&id, &name, &forge, &version)
 	if err != nil {
 		return nil, err
 	}
 	project := map[string]string{
+		"id":      id,
 		"name":    name,
 		"url":     url,
 		"forge":   forge,
@@ -38,27 +38,23 @@ func GetProject(db *sql.DB, url string) (map[string]string, error) {
 	return project, nil
 }
 
-// UpdateProject updates an existing project in the database
-func UpdateProject(db *sql.DB, url, name, forge, running string) error {
-	_, err := db.Exec("UPDATE projects SET name=?, forge=?, version=? WHERE url=?", name, forge, running, url)
-	return err
-}
-
 // UpsertProject adds or updates a project in the database
-func UpsertProject(db *sql.DB, url, name, forge, running string) error {
-	_, err := db.Exec(`INSERT INTO projects (url, name, forge, version)
-		VALUES (?, ?, ?, ?)
-		ON CONFLICT(url) DO 
+func UpsertProject(db *sql.DB, mu *sync.Mutex, id, url, name, forge, running string) error {
+	mu.Lock()
+	defer mu.Unlock()
+	_, err := db.Exec(`INSERT INTO projects (id, url, name, forge, version)
+		VALUES (?, ?, ?, ?, ?)
+		ON CONFLICT(id) DO 
 			UPDATE SET
 				name = excluded.name,
 				forge = excluded.forge,
-				version = excluded.version;`, url, name, forge, running)
+				version = excluded.version;`, id, url, name, forge, running)
 	return err
 }
 
 // GetProjects returns a list of all projects in the database
 func GetProjects(db *sql.DB) ([]map[string]string, error) {
-	rows, err := db.Query("SELECT name, url, forge, version FROM projects")
+	rows, err := db.Query("SELECT id, name, url, forge, version FROM projects")
 	if err != nil {
 		return nil, err
 	}
@@ -66,12 +62,13 @@ func GetProjects(db *sql.DB) ([]map[string]string, error) {
 
 	var projects []map[string]string
 	for rows.Next() {
-		var name, url, forge, version string
-		err = rows.Scan(&name, &url, &forge, &version)
+		var id, name, url, forge, version string
+		err = rows.Scan(&id, &name, &url, &forge, &version)
 		if err != nil {
 			return nil, err
 		}
 		project := map[string]string{
+			"id":      id,
 			"name":    name,
 			"url":     url,
 			"forge":   forge,

db/release.go 🔗

@@ -6,34 +6,29 @@ package db
 
 import (
 	"database/sql"
+	"sync"
 )
 
-// AddRelease adds a release for a project with a given URL to the database
-
-// DeleteRelease deletes a release for a project with a given URL from the database
-
-// UpdateRelease updates a release for a project with a given URL in the database
-
 // UpsertRelease adds or updates a release for a project with a given URL in the
 // database
-func UpsertRelease(db *sql.DB, id, projectURL, releaseURL, tag, content, date string) error {
-	_, err := db.Exec(`INSERT INTO releases (id, project_url, release_url, tag, content, date)
+func UpsertRelease(db *sql.DB, mu *sync.Mutex, id, projectID, url, tag, content, date string) error {
+	mu.Lock()
+	defer mu.Unlock()
+	_, err := db.Exec(`INSERT INTO releases (id, project_id, url, tag, content, date)
 		VALUES (?, ?, ?, ?, ?, ?)
 		ON CONFLICT(id) DO 
 			UPDATE SET
-				release_url = excluded.release_url,
+				url = excluded.url,
 				content = excluded.content,
 				tag = excluded.tag,
 				content = excluded.content,
-				date = excluded.date;`, id, projectURL, releaseURL, tag, content, date)
+				date = excluded.date;`, id, projectID, url, tag, content, date)
 	return err
 }
 
-// GetRelease returns a release for a project with a given URL from the database
-
-// GetReleases returns all releases for a project with a given URL from the database
-func GetReleases(db *sql.DB, projectURL string) ([]map[string]string, error) {
-	rows, err := db.Query(`SELECT project_url, release_url, tag, content, date FROM releases WHERE project_url = ?`, projectURL)
+// GetReleases returns all releases for a project with a given id from the database
+func GetReleases(db *sql.DB, projectID string) ([]map[string]string, error) {
+	rows, err := db.Query(`SELECT id, url, tag, content, date FROM releases WHERE project_id = ?`, projectID)
 	if err != nil {
 		return nil, err
 	}
@@ -42,22 +37,22 @@ func GetReleases(db *sql.DB, projectURL string) ([]map[string]string, error) {
 	releases := make([]map[string]string, 0)
 	for rows.Next() {
 		var (
-			projectURL string
-			releaseURL string
-			tag        string
-			content    string
-			date       string
+			id      string
+			url     string
+			tag     string
+			content string
+			date    string
 		)
-		err := rows.Scan(&projectURL, &releaseURL, &tag, &content, &date)
+		err := rows.Scan(&id, &url, &tag, &content, &date)
 		if err != nil {
 			return nil, err
 		}
 		releases = append(releases, map[string]string{
-			"projectURL": projectURL,
-			"releaseURL": releaseURL,
-			"tag":        tag,
-			"content":    content,
-			"date":       date,
+			"id":      id,
+			"url":     url,
+			"tag":     tag,
+			"content": content,
+			"date":    date,
 		})
 	}
 	return releases, nil

db/sql/2_swap_project_url_for_id.down.sql 🔗

@@ -0,0 +1,28 @@
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: CC0-1.0
+
+ALTER TABLE releases RENAME TO releases_tmp;
+
+CREATE TABLE IF NOT EXISTS releases (
+    id          TEXT      NOT NULL PRIMARY KEY,
+    project_url TEXT      NOT NULL,
+    release_url TEXT      NOT NULL,
+    tag         TEXT      NOT NULL,
+    content     TEXT      NOT NULL,
+    date        TIMESTAMP NOT NULL,
+    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+INSERT INTO releases (id, project_url, release_url, tag, content, date)
+SELECT
+    r.id,
+    p.url,
+    r.url,
+    r.tag,
+    r.content,
+    r.date
+FROM releases_tmp r
+JOIN projects p ON r.project_url = p.url;
+
+DROP TABLE releases_tmp;

db/sql/2_swap_project_url_for_id.up.sql 🔗

@@ -0,0 +1,29 @@
+-- SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+--
+-- SPDX-License-Identifier: CC0-1.0
+
+ALTER TABLE releases RENAME TO releases_tmp;
+
+CREATE TABLE IF NOT EXISTS releases
+(
+    id         TEXT      NOT NULL PRIMARY KEY,
+    project_id TEXT      NOT NULL,
+    url        TEXT      NOT NULL,
+    tag        TEXT      NOT NULL,
+    content    TEXT      NOT NULL,
+    date       TIMESTAMP NOT NULL,
+    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+INSERT INTO releases (id, project_id, url, tag, content, date)
+SELECT
+    r.id,
+    p.id,
+    r.release_url,
+    r.tag,
+    r.content,
+    r.date
+FROM releases_tmp r
+JOIN projects p ON r.project_url = p.url;
+
+DROP TABLE releases_tmp;

db/users.go 🔗

@@ -12,12 +12,16 @@ import (
 // DeleteUser deletes specific user from the database and returns an error if it
 // fails
 func DeleteUser(db *sql.DB, user string) error {
+	mutex.Lock()
+	defer mutex.Unlock()
 	_, err := db.Exec("DELETE FROM users WHERE username = ?", user)
 	return err
 }
 
 // CreateUser creates a new user in the database and returns an error if it fails
 func CreateUser(db *sql.DB, username, hash, salt string) error {
+	mutex.Lock()
+	defer mutex.Unlock()
 	_, err := db.Exec("INSERT INTO users (username, hash, salt) VALUES (?, ?, ?)", username, hash, salt)
 	return err
 }
@@ -72,6 +76,8 @@ func GetSession(db *sql.DB, session string) (string, time.Time, error) {
 // InvalidateSession invalidates a session by setting the expiration date to the
 // provided time.
 func InvalidateSession(db *sql.DB, session string, expiry time.Time) error {
+	mutex.Lock()
+	defer mutex.Unlock()
 	_, err := db.Exec("UPDATE sessions SET expires = ? WHERE token = ?", expiry.Format(time.RFC3339), session)
 	return err
 }
@@ -79,6 +85,8 @@ func InvalidateSession(db *sql.DB, session string, expiry time.Time) error {
 // CreateSession creates a new session in the database and returns an error if
 // it fails
 func CreateSession(db *sql.DB, username, token string, expiry time.Time) error {
+	mutex.Lock()
+	defer mutex.Unlock()
 	_, err := db.Exec("INSERT INTO sessions (token, username, expires) VALUES (?, ?, ?)", token, username, expiry.Format(time.RFC3339))
 	return err
 }

project/project.go 🔗

@@ -11,6 +11,7 @@ import (
 	"log"
 	"sort"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/unascribed/FlexVer/go/flexver"
@@ -21,6 +22,7 @@ import (
 )
 
 type Project struct {
+	ID       string
 	URL      string
 	Name     string
 	Forge    string
@@ -29,26 +31,28 @@ type Project struct {
 }
 
 type Release struct {
-	ID      string
-	URL     string
-	Tag     string
-	Content string
-	Date    time.Time
+	ID        string
+	ProjectID string
+	URL       string
+	Tag       string
+	Content   string
+	Date      time.Time
 }
 
 // GetReleases returns a list of all releases for a project from the database
-func GetReleases(dbConn *sql.DB, proj Project) (Project, error) {
-	ret, err := db.GetReleases(dbConn, proj.URL)
+func GetReleases(dbConn *sql.DB, mu *sync.Mutex, proj Project) (Project, error) {
+	ret, err := db.GetReleases(dbConn, proj.ID)
 	if err != nil {
 		return proj, err
 	}
 
 	if len(ret) == 0 {
-		return fetchReleases(dbConn, proj)
+		return fetchReleases(dbConn, mu, proj)
 	}
 
 	for _, row := range ret {
 		proj.Releases = append(proj.Releases, Release{
+			ID:      row["id"],
 			Tag:     row["tag"],
 			Content: row["content"],
 			URL:     row["release_url"],
@@ -60,7 +64,7 @@ func GetReleases(dbConn *sql.DB, proj Project) (Project, error) {
 }
 
 // fetchReleases fetches releases from a project's forge given its URI
-func fetchReleases(dbConn *sql.DB, p Project) (Project, error) {
+func fetchReleases(dbConn *sql.DB, mu *sync.Mutex, p Project) (Project, error) {
 	var err error
 	switch p.Forge {
 	case "github", "gitea", "forgejo":
@@ -71,13 +75,13 @@ func fetchReleases(dbConn *sql.DB, p Project) (Project, error) {
 		}
 		for _, release := range rssReleases {
 			p.Releases = append(p.Releases, Release{
-				ID:      genReleaseID(p.URL, release.URL, release.Tag),
+				ID:      GenReleaseID(p.URL, release.URL, release.Tag),
 				Tag:     release.Tag,
 				Content: release.Content,
 				URL:     release.URL,
 				Date:    release.Date,
 			})
-			err = upsert(dbConn, p.URL, p.Releases)
+			err = upsertRelease(dbConn, mu, p.URL, p.Releases)
 			if err != nil {
 				log.Printf("Error upserting release: %v", err)
 				return p, err
@@ -90,13 +94,13 @@ func fetchReleases(dbConn *sql.DB, p Project) (Project, error) {
 		}
 		for _, release := range gitReleases {
 			p.Releases = append(p.Releases, Release{
-				ID:      genReleaseID(p.URL, release.URL, release.Tag),
+				ID:      GenReleaseID(p.URL, release.URL, release.Tag),
 				Tag:     release.Tag,
 				Content: release.Content,
 				URL:     release.URL,
 				Date:    release.Date,
 			})
-			err = upsert(dbConn, p.URL, p.Releases)
+			err = upsertRelease(dbConn, mu, p.URL, p.Releases)
 			if err != nil {
 				log.Printf("Error upserting release: %v", err)
 				return p, err
@@ -114,12 +118,12 @@ func SortReleases(releases []Release) []Release {
 	return releases
 }
 
-// upsert updates or inserts a project release into the database
-func upsert(dbConn *sql.DB, url string, releases []Release) error {
+// upsertRelease updates or inserts a release in the database
+func upsertRelease(dbConn *sql.DB, mu *sync.Mutex, url string, releases []Release) error {
 	for _, release := range releases {
 		date := release.Date.Format("2006-01-02 15:04:05")
-		id := genReleaseID(url, release.URL, release.Tag)
-		err := db.UpsertRelease(dbConn, id, url, release.URL, release.Tag, release.Content, date)
+		id := GenReleaseID(url, release.URL, release.Tag)
+		err := db.UpsertRelease(dbConn, mu, id, url, release.URL, release.Tag, release.Content, date)
 		if err != nil {
 			log.Printf("Error upserting release: %v", err)
 			return err
@@ -128,34 +132,40 @@ func upsert(dbConn *sql.DB, url string, releases []Release) error {
 	return nil
 }
 
-func genReleaseID(projectURL, releaseURL, tag string) string {
+// GenReleaseID generates a likely-unique ID from its project's URL, its release's URL, and its tag
+func GenReleaseID(projectURL, releaseURL, tag string) string {
 	idByte := sha256.Sum256([]byte(projectURL + releaseURL + tag))
 	return fmt.Sprintf("%x", idByte)
 }
 
-func Track(dbConn *sql.DB, manualRefresh *chan struct{}, name, url, forge, release string) {
-	err := db.UpsertProject(dbConn, url, name, forge, release)
+// GenProjectID generates a likely-unique ID from a project's URI, name, and forge
+func GenProjectID(url, name, forge string) string {
+	idByte := sha256.Sum256([]byte(url + name + forge))
+	return fmt.Sprintf("%x", idByte)
+}
+
+func Track(dbConn *sql.DB, mu *sync.Mutex, manualRefresh *chan struct{}, name, url, forge, release string) {
+	id := GenProjectID(url, name, forge)
+	err := db.UpsertProject(dbConn, mu, id, url, name, forge, release)
 	if err != nil {
 		fmt.Println("Error upserting project:", err)
 	}
 	*manualRefresh <- struct{}{}
 }
 
-func Untrack(dbConn *sql.DB, manualRefresh *chan struct{}, url string) {
-	err := db.DeleteProject(dbConn, url)
+func Untrack(dbConn *sql.DB, mu *sync.Mutex, id string) {
+	err := db.DeleteProject(dbConn, mu, id)
 	if err != nil {
 		fmt.Println("Error deleting project:", err)
 	}
 
-	*manualRefresh <- struct{}{}
-
-	err = git.RemoveRepo(url)
+	err = git.RemoveRepo(id)
 	if err != nil {
 		log.Println(err)
 	}
 }
 
-func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{}, res *chan []Project) {
+func RefreshLoop(dbConn *sql.DB, mu *sync.Mutex, interval int, manualRefresh, req *chan struct{}, res *chan []Project) {
 	ticker := time.NewTicker(time.Second * time.Duration(interval))
 
 	fetch := func() []Project {
@@ -164,7 +174,7 @@ func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{}
 			fmt.Println("Error getting projects:", err)
 		}
 		for i, p := range projectsList {
-			p, err := fetchReleases(dbConn, p)
+			p, err := fetchReleases(dbConn, mu, p)
 			if err != nil {
 				fmt.Println(err)
 				continue
@@ -175,7 +185,7 @@ func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{}
 			return strings.ToLower(projectsList[i].Name) < strings.ToLower(projectsList[j].Name)
 		})
 		for i := range projectsList {
-			err = upsert(dbConn, projectsList[i].URL, projectsList[i].Releases)
+			err = upsertRelease(dbConn, mu, projectsList[i].URL, projectsList[i].Releases)
 			if err != nil {
 				fmt.Println("Error upserting release:", err)
 				continue
@@ -202,12 +212,13 @@ func RefreshLoop(dbConn *sql.DB, interval int, manualRefresh, req *chan struct{}
 }
 
 // GetProject returns a project from the database
-func GetProject(dbConn *sql.DB, url string) (Project, error) {
-	projectDB, err := db.GetProject(dbConn, url)
+func GetProject(dbConn *sql.DB, id string) (Project, error) {
+	projectDB, err := db.GetProject(dbConn, id)
 	if err != nil {
 		return Project{}, err
 	}
 	p := Project{
+		ID:      projectDB["id"],
 		URL:     projectDB["url"],
 		Name:    projectDB["name"],
 		Forge:   projectDB["forge"],
@@ -216,6 +227,16 @@ func GetProject(dbConn *sql.DB, url string) (Project, error) {
 	return p, err
 }
 
+// GetProjectWithReleases returns a single project from the database along with its releases
+func GetProjectWithReleases(dbConn *sql.DB, mu *sync.Mutex, id string) (Project, error) {
+	project, err := GetProject(dbConn, id)
+	if err != nil {
+		return Project{}, err
+	}
+
+	return GetReleases(dbConn, mu, project)
+}
+
 // GetProjects returns a list of all projects from the database
 func GetProjects(dbConn *sql.DB) ([]Project, error) {
 	projectsDB, err := db.GetProjects(dbConn)
@@ -226,6 +247,7 @@ func GetProjects(dbConn *sql.DB) ([]Project, error) {
 	projects := make([]Project, len(projectsDB))
 	for i, p := range projectsDB {
 		projects[i] = Project{
+			ID:      p["id"],
 			URL:     p["url"],
 			Name:    p["name"],
 			Forge:   p["forge"],
@@ -235,3 +257,22 @@ func GetProjects(dbConn *sql.DB) ([]Project, error) {
 
 	return projects, nil
 }
+
+// GetProjectsWithReleases returns a list of all projects and all their releases
+// from the database
+func GetProjectsWithReleases(dbConn *sql.DB, mu *sync.Mutex) ([]Project, error) {
+	projects, err := GetProjects(dbConn)
+	if err != nil {
+		return nil, err
+	}
+
+	for i := range projects {
+		projects[i], err = GetReleases(dbConn, mu, projects[i])
+		if err != nil {
+			return nil, err
+		}
+		projects[i].Releases = SortReleases(projects[i].Releases)
+	}
+
+	return projects, nil
+}

ws/static/home.html 🔗

@@ -18,19 +18,51 @@
         <link rel="stylesheet" href="/static/styles.css" />
     </head>
     <body>
-        <h1>Willow &nbsp;&nbsp;&nbsp;<span><a href="/logout">Log out</a></span></h1>
-        <p><a href="/new">Track a new project</a></p>
-        <div class="projects">
-        <!-- Range through projects that aren't yet up-to-date -->
-        {{- range . -}}
-        {{- if ne .Running (index .Releases 0).Tag -}}
-        <div class="project">
-            <h2><a href="{{ .URL }}">{{ .Name }}</a>&nbsp;&nbsp;&nbsp;<span><a href="/new?action=delete&url={{ .URL }}">Delete?</a></span></h2>
-            <p>You've selected {{ .Running }}. <a href="/new?action=update&url={{ .URL }}&forge={{ .Forge }}&name={{ .Name }}">Modify?</a></p>
-            <p>Latest: <a href="{{ (index .Releases 0).URL }}">{{ (index .Releases 0).Tag }}</a></p>
-            <p>
-                <details>
-                    <summary>Expand release notes</summary>
+        <header class="wrapper">
+            <h1>Willow &nbsp;&nbsp;&nbsp;<span><a href="/logout">Log out</a></span></h1>
+            <p><a href="/new">Track a new project</a></p>
+        </header>
+        <div class="two_column">
+            <div class="projects">
+                <!-- Range through projects that aren't yet up-to-date -->
+                {{- range . -}}
+                {{- if ne .Running (index .Releases 0).Tag -}}
+                <h2>Outdated projects</h2>
+                {{- break -}}
+                {{- end -}}
+                {{- end -}}
+                {{- range . -}}
+                {{- if ne .Running (index .Releases 0).Tag -}}
+                <div class="project card">
+                    <h3><a href="{{ .URL }}">{{ .Name }}</a>&nbsp;&nbsp;&nbsp;<span class="delete"><a href="/new?action=delete&url={{ .URL }}">Delete?</a></span></h3>
+                    <p>You've selected {{ .Running }}. <a href="/new?action=update&url={{ .URL }}&forge={{ .Forge }}&name={{ .Name }}">Modify?</a></p>
+                    <p>Latest: <a href="{{ (index .Releases 0).URL }}">{{ (index .Releases 0).Tag }}</a></p>
+                    <p><a href="#{{ (index .Releases 0).ID }}">View release notes</a></p>
+                </div>
+                {{- end -}}
+                {{- end -}}
+
+                <!-- Range through projects that _are_ up-to-date -->
+                {{- range . -}}
+                {{- if eq .Running (index .Releases 0).Tag -}}
+                <h2>Up-to-date projects</h2>
+                {{- break -}}
+                {{- end -}}
+                {{- end -}}
+                {{- range . -}}
+                {{- if eq .Running (index .Releases 0).Tag -}}
+                <div class="project card">
+                    <h3><a href="{{ .URL }}">{{ .Name }}</a>&nbsp;&nbsp;&nbsp;<span class="delete"><a href="/new?action=delete&url={{ .URL }}">Delete?</a></span></h3>
+                    <p>You've selected <a href="#{{ (index .Releases 0).ID }}">{{ .Running }}</a>. <a href="/new?action=update&url={{ .URL }}&forge={{ .Forge }}&name={{ .Name }}">Modify?</a></p>
+                </div>
+                {{- end -}}
+                {{- end -}}
+            </div>
+            <div class="release_notes">
+                <h2>Release notes</h2>
+                {{- range . -}}
+                <div id="{{ (index .Releases 0).ID }}" class="release_note card">
+                    <h3>{{ .Name }}: release notes for <a href="{{ (index .Releases 0).URL }}">{{ (index .Releases 0).Tag }}</a> <span class="close"><a href="#">&#x2716;</a></span></h3>
                     {{- if eq .Forge "github" "gitea" "forgejo" -}}
                     {{- (index .Releases 0).Content -}}
                     {{- else -}}
@@ -38,20 +70,10 @@
                     {{- (index .Releases 0).Content -}}
                     </pre>
                     {{- end -}}
-                </details>
-            </p>
-        </div>
-        {{- end -}}
-        {{- end -}}
-
-        <!-- Range through projects that _are_ up-to-date -->
-        {{- range . -}}
-        {{- if eq .Running (index .Releases 0).Tag -}}
-        <div class="project">
-            <h2><a href="{{ .URL }}">{{ .Name }}</a>&nbsp;&nbsp;&nbsp;<span style="font-size: 12px;"><a href="/new?action=delete&url={{ .URL }}">Delete?</a></span></h2>
-            <p>You've selected {{ .Running }}. <a href="/new?action=update&url={{ .URL }}&forge={{ .Forge }}&name={{ .Name }}">Modify?</a></p>
+                    <p><a class="return_to_project" href="#{{ .ID }}">Back to project</a></p>
+                </div>
+                {{- end -}}
+            </div>
         </div>
-        {{- end -}}
-        {{- end -}}
     </body>
 </html>

ws/static/login.html 🔗

@@ -17,7 +17,7 @@
         <link rel="preload" href="/static/styles.css" as="style" />
         <link rel="stylesheet" href="/static/styles.css" />
     </head>
-    <body>
+    <body class="wrapper">
         <h1>Willow</h1>
         <form method="post">
             <div class="input">

ws/static/new.html 🔗

@@ -17,7 +17,7 @@
         <link rel="preload" href="/static/styles.css" as="style" />
         <link rel="stylesheet" href="/static/styles.css" />
     </head>
-    <body>
+    <body class="wrapper">
         <h1>Willow</h1>
         <form method="post">
             <div class="input">

ws/static/select-release.html 🔗

@@ -17,7 +17,7 @@
         <link rel="preload" href="/static/styles.css" as="style" />
         <link rel="stylesheet" href="/static/styles.css" />
     </head>
-    <body>
+    <body class="wrapper">
         <h1>Willow</h1>
         <form method="post">
             <div class="input">
@@ -31,9 +31,9 @@
                 <label for="{{ .Tag }}"><a href="{{ .URL }}">{{ .Tag }}</a></label><br>
                 {{- else -}}
                 {{- if eq $forge "sourcehut" -}}
-                <label for="{{ .Tag }}"><a href="{{ $url }}/refs/{{ .Tag }}">{{ .Tag }}</label><br>
+                <label for="{{ .Tag }}"><a href="{{ $url }}/refs/{{ .Tag }}">{{ .Tag }}</a></label><br>
                 {{- else if eq $forge "gitlab" -}}
-                <label for="{{ .Tag }}"><a href="{{ $url }}/-releases/{{ .Tag }}">{{ .Tag }}</label><br>
+                <label for="{{ .Tag }}"><a href="{{ $url }}/-releases/{{ .Tag }}">{{ .Tag }}</a></label><br>
                 {{- else -}}
                 <label for="{{ .Tag }}">{{ .Tag }}</label><br>
                 {{- end -}}
@@ -43,6 +43,7 @@
             <input type="hidden" name="url" value="{{ .URL }}">
             <input type="hidden" name="name" value="{{ .Name }}">
             <input type="hidden" name="forge" value="{{ .Forge }}">
+            <input type="hidden" name="id" value="{{ .ID }}">
             <input class="button" type="submit" formaction="/new" value="Track future releases">
         </form>
         <!-- Append these if they ever start limiting RSS entries: `(eq $forge "gitea") (eq $forge "forgejo")` -->

ws/static/styles.css 🔗

@@ -37,12 +37,11 @@
 }
 
 html {
-    max-width: 500px;
     margin: auto auto;
     color: #2f2f2f;
     background: white;
     font-family: 'Atkinson Hyperlegible', sans-serif;
-    padding-bottom: 20px;
+    scroll-behavior: smooth;
 }
 
 a {
@@ -53,8 +52,28 @@ a:visited {
     color: #0640e0;
 }
 
-.project {
-    max-width: 500px;
+.two_column {
+    display: flex;
+    gap: 30px;
+    flex-direction: row;
+    margin: auto auto;
+    max-width: 1000px;
+    height: 92vh;
+}
+
+.projects, .release_notes {
+    overflow: scroll;
+    flex: 0 0 500px;
+}
+
+.release_note.card:not(:target) { display: none;  }
+.release_note.card:target       { display: block; }
+
+.return_to_project {
+    display: none;
+}
+
+.card {
     border: 2px solid #2f2f2f;
     background: #f8f8f8;
     border-radius: 5px;
@@ -63,34 +82,35 @@ a:visited {
     box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19);
 }
 
-.project > h2 {
+.card > h3 {
     margin-top: 0;
 }
 
-.project > p:first-of-type {
+.card > p:first-of-type {
     margin-bottom: 16px;
 }
 
-.project > h2 > span {
-    font-size: 12px;
-    float: right;
+.card > p:last-of-type {
+    margin-bottom: 16px;
 }
 
-body > h1 > span {
-    font-size: 12px;
-    float: right;
+.close, .delete { float: right; }
+.delete { font-size: 12px; }
+.close > a {
+    text-decoration: none;
+    color: #2f2f2f;
 }
 
-.project > details > pre {
-    overflow: scroll;
-}
+.card > pre, .card > div > pre { overflow: scroll; }
 
-summary {
-    cursor: pointer;
+.wrapper {
+    max-width: 500px;
+    margin: auto auto;
 }
 
-details summary > * {
-    display: inline;
+header > h1 > span {
+    font-size: 12px;
+    float: right;
 }
 
 @media (prefers-color-scheme: dark) {
@@ -107,8 +127,36 @@ details summary > * {
         color: #5582ff;
     }
 
-    .project {
+    .card {
         border: 2px solid #ccc;
         background: #1c1c1c;
     }
+
+    .close > a {
+        color: #ccc;
+    }
 }
+
+@media only screen and (max-width: 1000px) {
+    div[id] {
+        display: block;
+    }
+
+    .two_column {
+        flex-direction: column;
+    }
+
+    .projects, .release_notes {
+        overflow: visible;
+        flex: 0 0 100%;
+    }
+
+    .return_to_project {
+        display: block;
+    }
+
+    .close {
+        display: none;
+    }
+}
+

ws/ws.go 🔗

@@ -16,18 +16,17 @@ import (
 	"text/template"
 	"time"
 
-	"git.sr.ht/~amolith/willow/users"
-
 	"git.sr.ht/~amolith/willow/project"
+	"git.sr.ht/~amolith/willow/users"
 	"github.com/microcosm-cc/bluemonday"
 )
 
 type Handler struct {
 	DbConn        *sql.DB
-	Mutex         *sync.Mutex
 	Req           *chan struct{}
 	ManualRefresh *chan struct{}
 	Res           *chan []project.Project
+	Mu            *sync.Mutex
 }
 
 //go:embed static
@@ -41,8 +40,16 @@ func (h Handler) RootHandler(w http.ResponseWriter, r *http.Request) {
 		http.Redirect(w, r, "/login", http.StatusSeeOther)
 		return
 	}
-	*h.Req <- struct{}{}
-	data := <-*h.Res
+	data, err := project.GetProjectsWithReleases(h.DbConn, h.Mu)
+	if err != nil {
+		fmt.Println(err)
+		w.WriteHeader(http.StatusInternalServerError)
+		_, err := w.Write([]byte("Internal Server Error"))
+		if err != nil {
+			fmt.Println(err)
+		}
+		return
+	}
 	tmpl := template.Must(template.ParseFS(fs, "static/home.html"))
 	if err := tmpl.Execute(w, data); err != nil {
 		fmt.Println(err)
@@ -114,7 +121,8 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
 
 			}
 
-			proj, err = project.GetReleases(h.DbConn, proj)
+			proj.ID = project.GenProjectID(proj.URL, proj.Name, proj.Forge)
+			proj, err = project.GetReleases(h.DbConn, h.Mu, proj)
 			if err != nil {
 				w.WriteHeader(http.StatusBadRequest)
 				_, err := w.Write([]byte(fmt.Sprintf("Error getting releases: %s", err)))
@@ -129,8 +137,8 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
 				fmt.Println(err)
 			}
 		} else if action == "delete" {
-			submittedURL := params.Get("url")
-			if submittedURL == "" {
+			submittedID := params.Get("id")
+			if submittedID == "" {
 				w.WriteHeader(http.StatusBadRequest)
 				_, err := w.Write([]byte("No URL provided"))
 				if err != nil {
@@ -139,7 +147,7 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
 				return
 			}
 
-			project.Untrack(h.DbConn, h.ManualRefresh, submittedURL)
+			project.Untrack(h.DbConn, h.Mu, submittedID)
 			http.Redirect(w, r, "/", http.StatusSeeOther)
 		}
 	}
@@ -149,28 +157,29 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
 		if err != nil {
 			fmt.Println(err)
 		}
+		idValue := bmStrict.Sanitize(r.FormValue("id"))
 		nameValue := bmStrict.Sanitize(r.FormValue("name"))
 		urlValue := bmStrict.Sanitize(r.FormValue("url"))
 		forgeValue := bmStrict.Sanitize(r.FormValue("forge"))
 		releaseValue := bmStrict.Sanitize(r.FormValue("release"))
 
-		if nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" {
-			project.Track(h.DbConn, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue)
+		// If releaseValue is not empty, we're updating an existing project
+		if idValue != "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue != "" {
+			project.Track(h.DbConn, h.Mu, h.ManualRefresh, nameValue, urlValue, forgeValue, releaseValue)
 			http.Redirect(w, r, "/", http.StatusSeeOther)
 			return
 		}
 
-		if nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" {
+		// If releaseValue is empty, we're creating a new project
+		if idValue == "" && nameValue != "" && urlValue != "" && forgeValue != "" && releaseValue == "" {
 			http.Redirect(w, r, "/new?action=yoink&name="+url.QueryEscape(nameValue)+"&url="+url.QueryEscape(urlValue)+"&forge="+url.QueryEscape(forgeValue), http.StatusSeeOther)
 			return
 		}
 
-		if nameValue == "" && urlValue == "" && forgeValue == "" && releaseValue == "" {
-			w.WriteHeader(http.StatusBadRequest)
-			_, err := w.Write([]byte("No data provided"))
-			if err != nil {
-				fmt.Println(err)
-			}
+		w.WriteHeader(http.StatusBadRequest)
+		_, err = w.Write([]byte("No data provided"))
+		if err != nil {
+			fmt.Println(err)
 		}
 	}
 }