Implement new UI, fix DB use

- Implement dual-column UI
- Swap project table index from URL to ID
- Enable WAL for concurrent reads
- Use a Mutex to protect writes
This commit is contained in:
Amolith 2024-02-23 18:06:16 -05:00
parent 89e894401f
commit d5c7bf70ce
Signed by: Amolith
SSH Key Fingerprint: SHA256:JBKEeoO/72Fz03rtlzeO49PATFT2maMancH3opcT0h0
16 changed files with 398 additions and 196 deletions

View File

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

View File

@ -7,6 +7,8 @@ package db
import ( import (
"database/sql" "database/sql"
_ "embed" _ "embed"
"errors"
"sync"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@ -14,46 +16,25 @@ import (
//go:embed sql/schema.sql //go:embed sql/schema.sql
var schema string var schema string
var mutex = &sync.Mutex{}
// Open opens a connection to the SQLite database // Open opens a connection to the SQLite database
func Open(dbPath string) (*sql.DB, error) { 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 // VerifySchema checks whether the schema has been initalised and initialises it
// if not // if not
func InitialiseDatabase(dbConn *sql.DB) error { func InitialiseDatabase(dbConn *sql.DB) error {
var name string var name string
err := dbConn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'").Scan(&name) err := dbConn.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='users'").Scan(&name)
if err == nil { 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
} }
return err
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
}
}
}
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
} }

View File

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

View File

@ -55,3 +55,35 @@ func generateAndInsertProjectIDs(tx *sql.Tx) error {
return nil 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
}

View File

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

View File

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

View File

@ -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;

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -37,12 +37,11 @@
} }
html { html {
max-width: 500px;
margin: auto auto; margin: auto auto;
color: #2f2f2f; color: #2f2f2f;
background: white; background: white;
font-family: 'Atkinson Hyperlegible', sans-serif; font-family: 'Atkinson Hyperlegible', sans-serif;
padding-bottom: 20px; scroll-behavior: smooth;
} }
a { a {
@ -53,8 +52,28 @@ a:visited {
color: #0640e0; color: #0640e0;
} }
.project { .two_column {
max-width: 500px; 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; border: 2px solid #2f2f2f;
background: #f8f8f8; background: #f8f8f8;
border-radius: 5px; border-radius: 5px;
@ -63,36 +82,37 @@ a:visited {
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19); 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; margin-top: 0;
} }
.project > p:first-of-type { .card > p:first-of-type {
margin-bottom: 16px; margin-bottom: 16px;
} }
.project > h2 > span { .card > p:last-of-type {
margin-bottom: 16px;
}
.close, .delete { float: right; }
.delete { font-size: 12px; }
.close > a {
text-decoration: none;
color: #2f2f2f;
}
.card > pre, .card > div > pre { overflow: scroll; }
.wrapper {
max-width: 500px;
margin: auto auto;
}
header > h1 > span {
font-size: 12px; font-size: 12px;
float: right; float: right;
} }
body > h1 > span {
font-size: 12px;
float: right;
}
.project > details > pre {
overflow: scroll;
}
summary {
cursor: pointer;
}
details summary > * {
display: inline;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
background: #171717; background: #171717;
@ -107,8 +127,36 @@ details summary > * {
color: #5582ff; color: #5582ff;
} }
.project { .card {
border: 2px solid #ccc; border: 2px solid #ccc;
background: #1c1c1c; 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;
}
}

View File

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