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:
parent
89e894401f
commit
d5c7bf70ce
|
@ -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"
|
||||||
|
@ -23,7 +24,6 @@ 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
|
||||||
|
@ -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()
|
||||||
|
|
41
db/db.go
41
db/db.go
|
@ -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) {
|
||||||
return nil
|
mutex.Lock()
|
||||||
}
|
defer mutex.Unlock()
|
||||||
|
|
||||||
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 {
|
if _, err := dbConn.Exec(schema); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,19 +37,19 @@ 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,
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
@ -30,6 +32,7 @@ type Project struct {
|
||||||
|
|
||||||
type Release struct {
|
type Release struct {
|
||||||
ID string
|
ID string
|
||||||
|
ProjectID string
|
||||||
URL string
|
URL string
|
||||||
Tag string
|
Tag string
|
||||||
Content string
|
Content string
|
||||||
|
@ -37,18 +40,19 @@ type Release struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
}
|
||||||
|
|
|
@ -18,28 +18,26 @@
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header class="wrapper">
|
||||||
<h1>Willow <span><a href="/logout">Log out</a></span></h1>
|
<h1>Willow <span><a href="/logout">Log out</a></span></h1>
|
||||||
<p><a href="/new">Track a new project</a></p>
|
<p><a href="/new">Track a new project</a></p>
|
||||||
|
</header>
|
||||||
|
<div class="two_column">
|
||||||
<div class="projects">
|
<div class="projects">
|
||||||
<!-- Range through projects that aren't yet up-to-date -->
|
<!-- Range through projects that aren't yet up-to-date -->
|
||||||
{{- range . -}}
|
{{- range . -}}
|
||||||
{{- if ne .Running (index .Releases 0).Tag -}}
|
{{- if ne .Running (index .Releases 0).Tag -}}
|
||||||
<div class="project">
|
<h2>Outdated projects</h2>
|
||||||
<h2><a href="{{ .URL }}">{{ .Name }}</a> <span><a href="/new?action=delete&url={{ .URL }}">Delete?</a></span></h2>
|
{{- break -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- range . -}}
|
||||||
|
{{- if ne .Running (index .Releases 0).Tag -}}
|
||||||
|
<div class="project card">
|
||||||
|
<h3><a href="{{ .URL }}">{{ .Name }}</a> <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>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>Latest: <a href="{{ (index .Releases 0).URL }}">{{ (index .Releases 0).Tag }}</a></p>
|
||||||
<p>
|
<p><a href="#{{ (index .Releases 0).ID }}">View release notes</a></p>
|
||||||
<details>
|
|
||||||
<summary>Expand release notes</summary>
|
|
||||||
{{- if eq .Forge "github" "gitea" "forgejo" -}}
|
|
||||||
{{- (index .Releases 0).Content -}}
|
|
||||||
{{- else -}}
|
|
||||||
<pre>
|
|
||||||
{{- (index .Releases 0).Content -}}
|
|
||||||
</pre>
|
|
||||||
{{- end -}}
|
|
||||||
</details>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
|
@ -47,11 +45,35 @@
|
||||||
<!-- Range through projects that _are_ up-to-date -->
|
<!-- Range through projects that _are_ up-to-date -->
|
||||||
{{- range . -}}
|
{{- range . -}}
|
||||||
{{- if eq .Running (index .Releases 0).Tag -}}
|
{{- if eq .Running (index .Releases 0).Tag -}}
|
||||||
<div class="project">
|
<h2>Up-to-date projects</h2>
|
||||||
<h2><a href="{{ .URL }}">{{ .Name }}</a> <span style="font-size: 12px;"><a href="/new?action=delete&url={{ .URL }}">Delete?</a></span></h2>
|
{{- break -}}
|
||||||
<p>You've selected {{ .Running }}. <a href="/new?action=update&url={{ .URL }}&forge={{ .Forge }}&name={{ .Name }}">Modify?</a></p>
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- range . -}}
|
||||||
|
{{- if eq .Running (index .Releases 0).Tag -}}
|
||||||
|
<div class="project card">
|
||||||
|
<h3><a href="{{ .URL }}">{{ .Name }}</a> <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>
|
</div>
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
{{- 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="#">✖</a></span></h3>
|
||||||
|
{{- if eq .Forge "github" "gitea" "forgejo" -}}
|
||||||
|
{{- (index .Releases 0).Content -}}
|
||||||
|
{{- else -}}
|
||||||
|
<pre>
|
||||||
|
{{- (index .Releases 0).Content -}}
|
||||||
|
</pre>
|
||||||
|
{{- end -}}
|
||||||
|
<p><a class="return_to_project" href="#{{ .ID }}">Back to project</a></p>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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")` -->
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
39
ws/ws.go
39
ws/ws.go
|
@ -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,30 +157,31 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
Loading…
Reference in New Issue