mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: phase 1 MVP — sections, services, drag-drop, renderers
Backend: migration 0002 (sections/items/urls/settings), CRUD + fractional reorder, icon upload, settings, export/import, cached status pings. Handler tests cover reorder, URL scheme validation, settings round-trip. Frontend: shared board store over the generated client, dnd-kit layer (same-section, cross-section, section reorder persisted), URL chooser popover, add/edit service dialog, settings dialog, Bento + Cards renderers behind the shared seam, dark/light theme. Generated with [Devin](https://devin.ai) Authored By: TDvorak <[email protected]> Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
b60821350a
commit
6004105038
37 files changed
+4187
-66
No files matched your search
@@ -42,7 +42,8 @@ func main() {
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
router := api.NewRouter(logger, sqlDB)
|
||||
iconsDir := filepath.Join(dataDir, "icons")
|
||||
router := api.NewRouter(logger, sqlDB, iconsDir)
|
||||
if err := web.Register(router); err != nil {
|
||||
logger.Fatal("register embedded UI", zap.Error(err))
|
||||
}
|
||||
|
||||
@@ -2,31 +2,139 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Server holds handler dependencies: DB, icon dir, status cache.
|
||||
type Server struct {
|
||||
db *sql.DB
|
||||
iconsDir string
|
||||
status *statusCache
|
||||
}
|
||||
|
||||
// NewRouter builds the HTTP handler: zap access log, recovery, /api routes.
|
||||
func NewRouter(logger *zap.Logger, db *sql.DB) *gin.Engine {
|
||||
func NewRouter(logger *zap.Logger, db *sql.DB, iconsDir string) *gin.Engine {
|
||||
s := &Server{db: db, iconsDir: iconsDir, status: newStatusCache(60 * time.Second)}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(accessLog(logger), gin.Recovery())
|
||||
|
||||
v1 := r.Group("/api")
|
||||
v1.GET("/healthz", func(c *gin.Context) {
|
||||
if err := db.PingContext(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
v1.GET("/healthz", s.healthz)
|
||||
|
||||
v1.GET("/sections", s.listSections)
|
||||
v1.POST("/sections", s.createSection)
|
||||
v1.PATCH("/sections/:id", s.updateSection)
|
||||
v1.DELETE("/sections/:id", s.deleteSection)
|
||||
v1.POST("/sections/reorder", s.reorderSection)
|
||||
|
||||
v1.POST("/items", s.createItem)
|
||||
v1.PATCH("/items/:id", s.updateItem)
|
||||
v1.DELETE("/items/:id", s.deleteItem)
|
||||
v1.POST("/items/reorder", s.reorderItem)
|
||||
v1.POST("/items/:id/icon", s.uploadIcon)
|
||||
v1.GET("/icons/:file", s.getIcon)
|
||||
|
||||
v1.GET("/status", s.getStatus)
|
||||
v1.GET("/settings", s.getSettings)
|
||||
v1.PUT("/settings", s.putSettings)
|
||||
v1.GET("/export", s.exportBoard)
|
||||
v1.POST("/import", s.importBoard)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// ---------- shared types (mirror openapi.yaml) ----------
|
||||
|
||||
// URL is one launch target of an item (local, external, custom label).
|
||||
type URL struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Label string `json:"label"`
|
||||
Position float64 `json:"position"`
|
||||
}
|
||||
|
||||
// URLInput is a URL as accepted on create/update (id/position assigned).
|
||||
type URLInput struct {
|
||||
URL string `json:"url"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// Item is a board cell: a service tile today, a widget in Phase 2.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
SectionID string `json:"sectionId"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Position float64 `json:"position"`
|
||||
Config *json.RawMessage `json:"config"`
|
||||
URLs []URL `json:"urls"`
|
||||
}
|
||||
|
||||
// Section is a named, collapsible group of items.
|
||||
type Section struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func newID(prefix string) string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return prefix + "_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// midpoint returns a fractional position between two optional neighbors.
|
||||
func midpoint(before, after *float64) float64 {
|
||||
switch {
|
||||
case before == nil && after == nil:
|
||||
return 1024
|
||||
case before == nil:
|
||||
return *after - 1024
|
||||
case after == nil:
|
||||
return *before + 1024
|
||||
default:
|
||||
return (*before + *after) / 2
|
||||
}
|
||||
}
|
||||
|
||||
func fail(c *gin.Context, code int, msg string) {
|
||||
c.AbortWithStatusJSON(code, gin.H{"error": msg})
|
||||
}
|
||||
|
||||
// validURL enforces http(s) launch targets — also keeps /api/status safe
|
||||
// from file:// or internal-scheme abuse.
|
||||
func validURL(raw string) bool {
|
||||
u, err := url.Parse(raw)
|
||||
return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
||||
}
|
||||
|
||||
func notFound(err error) bool { return errors.Is(err, sql.ErrNoRows) }
|
||||
|
||||
// healthz reports liveness and DB reachability.
|
||||
func (s *Server) healthz(c *gin.Context) {
|
||||
if err := s.db.PingContext(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "db unreachable"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
// accessLog is gin.Logger backed by zap: one structured line per request.
|
||||
func accessLog(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tdvorak/dash/internal/db"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func testServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "dash.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
gin.SetMode(gin.TestMode)
|
||||
return httptest.NewServer(NewRouter(zap.NewNop(), d, t.TempDir()))
|
||||
}
|
||||
|
||||
func do(t *testing.T, method, url, body string) (*http.Response, map[string]any) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(method, url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, url, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(res.Body).Decode(&out)
|
||||
return res, out
|
||||
}
|
||||
|
||||
func TestSectionItemReorderFlow(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
|
||||
_, b := do(t, "POST", srv.URL+"/api/sections", `{"name":"B"}`)
|
||||
secA, secB := a["id"].(string), b["id"].(string)
|
||||
|
||||
// Move B before A: position should go below A's.
|
||||
res, mv := do(t, "POST", srv.URL+"/api/sections/reorder", `{"id":"`+secB+`","afterId":"`+secA+`"}`)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("reorder: %v", mv)
|
||||
}
|
||||
if mv["position"].(float64) >= a["position"].(float64) {
|
||||
t.Fatalf("expected B position < A position, got %v vs %v", mv["position"], a["position"])
|
||||
}
|
||||
|
||||
// Item with two urls into A.
|
||||
_, it := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+secA+`","name":"Svc","urls":[{"url":"http://x.local","label":"local"},{"url":"https://x.dev","label":"external"}]}`)
|
||||
if res := it["id"]; res == nil {
|
||||
t.Fatalf("create item: %v", it)
|
||||
}
|
||||
|
||||
// Move the item across sections.
|
||||
res, mv = do(t, "POST", srv.URL+"/api/items/reorder", `{"id":"`+it["id"].(string)+`","sectionId":"`+secB+`"}`)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("item reorder: %v", mv)
|
||||
}
|
||||
|
||||
// Board reflects the move.
|
||||
var secs []Section
|
||||
resp, err := http.Get(srv.URL + "/api/sections")
|
||||
if err != nil {
|
||||
t.Fatalf("get sections: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if err := json.NewDecoder(resp.Body).Decode(&secs); err != nil {
|
||||
t.Fatalf("decode board: %v", err)
|
||||
}
|
||||
if len(secs) != 2 || secs[0].Name != "B" || secs[1].Name != "A" {
|
||||
t.Fatalf("unexpected section order: %+v", secs)
|
||||
}
|
||||
if len(secs[0].Items) != 1 || secs[0].Items[0].Name != "Svc" {
|
||||
t.Fatalf("item did not move to section B: %+v", secs[0].Items)
|
||||
}
|
||||
if len(secs[0].Items[0].URLs) != 2 {
|
||||
t.Fatalf("expected 2 urls, got %+v", secs[0].Items[0].URLs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsNonHTTPURLs(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
_, a := do(t, "POST", srv.URL+"/api/sections", `{"name":"A"}`)
|
||||
res, body := do(t, "POST", srv.URL+"/api/items", `{"sectionId":"`+a["id"].(string)+`","name":"Bad","urls":[{"url":"file:///etc/passwd","label":"x"}]}`)
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for file:// url, got %d: %v", res.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
defer srv.Close()
|
||||
res, _ := do(t, "PUT", srv.URL+"/api/settings", `{"theme":"light","renderer":"cards"}`)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("put settings: %d", res.StatusCode)
|
||||
}
|
||||
resp, err := http.Get(srv.URL + "/api/settings")
|
||||
if err != nil {
|
||||
t.Fatalf("get settings: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var got map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode settings: %v", err)
|
||||
}
|
||||
if got["theme"] != "light" || got["renderer"] != "cards" {
|
||||
t.Fatalf("settings mismatch: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidpoint(t *testing.T) {
|
||||
f := func(v float64) *float64 { return &v }
|
||||
if got := midpoint(nil, nil); got != 1024 {
|
||||
t.Fatalf("empty midpoint: %v", got)
|
||||
}
|
||||
if got := midpoint(nil, f(1024)); got != 0 {
|
||||
t.Fatalf("before-only midpoint: %v", got)
|
||||
}
|
||||
if got := midpoint(f(1024), nil); got != 2048 {
|
||||
t.Fatalf("after-only midpoint: %v", got)
|
||||
}
|
||||
if got := midpoint(f(0), f(1024)); got != 512 {
|
||||
t.Fatalf("bounded midpoint: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidURL(t *testing.T) {
|
||||
for _, u := range []string{"http://a.b", "https://a.b:8443/x"} {
|
||||
if !validURL(u) {
|
||||
t.Fatalf("expected valid: %s", u)
|
||||
}
|
||||
}
|
||||
for _, u := range []string{"file:///etc/passwd", "javascript:alert(1)", "http://", "notaurl", ""} {
|
||||
if validURL(u) {
|
||||
t.Fatalf("expected invalid: %s", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// board loads the full tree: sections ordered, each with items and urls.
|
||||
// Three flat queries assembled in memory — the whole board is ~100 rows.
|
||||
func (s *Server) board() ([]Section, error) {
|
||||
secRows, err := s.db.Query(`SELECT id, name, position, collapsed FROM sections ORDER BY position`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer secRows.Close()
|
||||
|
||||
sections := []Section{}
|
||||
byID := map[string]*Section{}
|
||||
for secRows.Next() {
|
||||
var sec Section
|
||||
if err := secRows.Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec.Items = []Item{}
|
||||
sections = append(sections, sec)
|
||||
}
|
||||
for i := range sections {
|
||||
byID[sections[i].ID] = §ions[i]
|
||||
}
|
||||
if err := secRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
itemRows, err := s.db.Query(`SELECT id, section_id, kind, name, icon, position, config FROM items ORDER BY position`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer itemRows.Close()
|
||||
|
||||
items := []*Item{}
|
||||
itemByID := map[string]*Item{}
|
||||
for itemRows.Next() {
|
||||
var it Item
|
||||
var cfg sql.NullString
|
||||
if err := itemRows.Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Valid {
|
||||
raw := json.RawMessage(cfg.String)
|
||||
it.Config = &raw
|
||||
}
|
||||
it.URLs = []URL{}
|
||||
items = append(items, &it)
|
||||
itemByID[it.ID] = &it
|
||||
}
|
||||
if err := itemRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
urlRows, err := s.db.Query(`SELECT id, item_id, url, label, position FROM urls ORDER BY position`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer urlRows.Close()
|
||||
|
||||
for urlRows.Next() {
|
||||
var u URL
|
||||
var itemID string
|
||||
if err := urlRows.Scan(&u.ID, &itemID, &u.URL, &u.Label, &u.Position); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if it, ok := itemByID[itemID]; ok {
|
||||
it.URLs = append(it.URLs, u)
|
||||
}
|
||||
}
|
||||
if err := urlRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
if sec, ok := byID[it.SectionID]; ok {
|
||||
sec.Items = append(sec.Items, *it)
|
||||
}
|
||||
}
|
||||
return sections, nil
|
||||
}
|
||||
|
||||
func (s *Server) listSections(c *gin.Context) {
|
||||
sections, err := s.board()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sections)
|
||||
}
|
||||
|
||||
func (s *Server) createSection(c *gin.Context) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
var maxPos sql.NullFloat64
|
||||
if err := s.db.QueryRow(`SELECT MAX(position) FROM sections`).Scan(&maxPos); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sec := Section{ID: newID("s"), Name: in.Name, Position: midpoint(ptrOr(maxPos), nil), Items: []Item{}}
|
||||
if _, err := s.db.Exec(`INSERT INTO sections (id, name, position) VALUES (?, ?, ?)`, sec.ID, sec.Name, sec.Position); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, sec)
|
||||
}
|
||||
|
||||
func (s *Server) updateSection(c *gin.Context) {
|
||||
var in struct {
|
||||
Name *string `json:"name"`
|
||||
Collapsed *bool `json:"collapsed"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if in.Name != nil {
|
||||
if _, err := s.db.Exec(`UPDATE sections SET name = ? WHERE id = ?`, *in.Name, c.Param("id")); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Collapsed != nil {
|
||||
if _, err := s.db.Exec(`UPDATE sections SET collapsed = ? WHERE id = ?`, *in.Collapsed, c.Param("id")); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
var sec Section
|
||||
err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, c.Param("id")).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed)
|
||||
if notFound(err) {
|
||||
fail(c, http.StatusNotFound, "section not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sec.Items = []Item{}
|
||||
c.JSON(http.StatusOK, sec)
|
||||
}
|
||||
|
||||
func (s *Server) deleteSection(c *gin.Context) {
|
||||
res, err := s.db.Exec(`DELETE FROM sections WHERE id = ?`, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
fail(c, http.StatusNotFound, "section not found")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) reorderSection(c *gin.Context) {
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
BeforeID *string `json:"beforeId"`
|
||||
AfterID *string `json:"afterId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.ID == "" {
|
||||
fail(c, http.StatusBadRequest, "id required")
|
||||
return
|
||||
}
|
||||
pos, err := s.neighborPosition("sections", in.BeforeID, in.AfterID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := s.db.Exec(`UPDATE sections SET position = ? WHERE id = ?`, pos, in.ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
fail(c, http.StatusNotFound, "section not found")
|
||||
return
|
||||
}
|
||||
var sec Section
|
||||
if err := s.db.QueryRow(`SELECT id, name, position, collapsed FROM sections WHERE id = ?`, in.ID).
|
||||
Scan(&sec.ID, &sec.Name, &sec.Position, &sec.Collapsed); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sec.Items = []Item{}
|
||||
c.JSON(http.StatusOK, sec)
|
||||
}
|
||||
|
||||
// neighborPosition resolves the new fractional position between two neighbors.
|
||||
func (s *Server) neighborPosition(table string, beforeID, afterID *string) (float64, error) {
|
||||
var before, after *float64
|
||||
if beforeID != nil {
|
||||
p, err := s.positionOf(table, *beforeID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
before = &p
|
||||
}
|
||||
if afterID != nil {
|
||||
p, err := s.positionOf(table, *afterID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
after = &p
|
||||
}
|
||||
return midpoint(before, after), nil
|
||||
}
|
||||
|
||||
func (s *Server) positionOf(table, id string) (float64, error) {
|
||||
var p float64
|
||||
// table is an internal constant (sections|items) — never user input.
|
||||
err := s.db.QueryRow(`SELECT position FROM `+table+` WHERE id = ?`, id).Scan(&p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
func ptrOr(v sql.NullFloat64) *float64 {
|
||||
if v.Valid {
|
||||
return &v.Float64
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Allowed icon extensions — whitelist keeps the upload dir free of HTML/JS.
|
||||
var iconExts = map[string]struct{}{
|
||||
".png": {}, ".jpg": {}, ".jpeg": {}, ".webp": {},
|
||||
".gif": {}, ".svg": {}, ".ico": {}, ".avif": {},
|
||||
}
|
||||
|
||||
// uploadIcon stores a multipart file under iconsDir as <itemID><ext> and
|
||||
// points the item's icon field at /api/icons/<file>.
|
||||
func (s *Server) uploadIcon(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var exists int
|
||||
if err := s.db.QueryRow(`SELECT 1 FROM items WHERE id = ?`, id).Scan(&exists); err != nil {
|
||||
fail(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, "file field required")
|
||||
return
|
||||
}
|
||||
if fh.Size > 4<<20 {
|
||||
fail(c, http.StatusBadRequest, "icon too large (max 4 MB)")
|
||||
return
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(fh.Filename))
|
||||
if _, ok := iconExts[ext]; !ok {
|
||||
fail(c, http.StatusBadRequest, "unsupported icon type")
|
||||
return
|
||||
}
|
||||
|
||||
name := id + ext
|
||||
if err := c.SaveUploadedFile(fh, filepath.Join(s.iconsDir, name)); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := s.db.Exec(`UPDATE items SET icon = ? WHERE id = ?`, "/api/icons/"+name, id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
it, err := s.loadItem(id)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, it)
|
||||
}
|
||||
|
||||
// getIcon serves a stored icon. filepath.Base strips any traversal before
|
||||
// the path is pinned inside iconsDir.
|
||||
func (s *Server) getIcon(c *gin.Context) {
|
||||
name := filepath.Base(c.Param("file"))
|
||||
http.ServeFile(c.Writer, c.Request, filepath.Join(s.iconsDir, name))
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type itemInput struct {
|
||||
SectionID string `json:"sectionId"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
URLs []URLInput `json:"urls"`
|
||||
}
|
||||
|
||||
func (s *Server) createItem(c *gin.Context) {
|
||||
var in itemInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.SectionID == "" || in.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "sectionId and name required")
|
||||
return
|
||||
}
|
||||
for _, u := range in.URLs {
|
||||
if !validURL(u.URL) {
|
||||
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var maxPos sql.NullFloat64
|
||||
err := s.db.QueryRow(`SELECT MAX(position) FROM items WHERE section_id = ?`, in.SectionID).Scan(&maxPos)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
it := Item{ID: newID("i"), SectionID: in.SectionID, Kind: "service", Name: in.Name, Icon: in.Icon,
|
||||
Position: midpoint(ptrOr(maxPos), nil), URLs: []URL{}}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(`INSERT INTO items (id, section_id, name, icon, position) VALUES (?, ?, ?, ?, ?)`,
|
||||
it.ID, it.SectionID, it.Name, it.Icon, it.Position); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := insertURLs(tx, it.ID, in.URLs, &it.URLs); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, it)
|
||||
}
|
||||
|
||||
// insertURLs writes the url list (position = index * 1024) and fills out.
|
||||
func insertURLs(tx *sql.Tx, itemID string, in []URLInput, out *[]URL) error {
|
||||
for i, u := range in {
|
||||
row := URL{ID: newID("u"), URL: u.URL, Label: u.Label, Position: float64(i+1) * 1024}
|
||||
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
|
||||
row.ID, itemID, row.URL, row.Label, row.Position); err != nil {
|
||||
return err
|
||||
}
|
||||
*out = append(*out, row)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateItem(c *gin.Context) {
|
||||
var in struct {
|
||||
Name *string `json:"name"`
|
||||
Icon *string `json:"icon"`
|
||||
URLs *[]URLInput `json:"urls"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if in.URLs != nil {
|
||||
for _, u := range *in.URLs {
|
||||
if !validURL(u.URL) {
|
||||
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
id := c.Param("id")
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if in.Name != nil {
|
||||
if _, err := tx.Exec(`UPDATE items SET name = ? WHERE id = ?`, *in.Name, id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Icon != nil {
|
||||
if _, err := tx.Exec(`UPDATE items SET icon = ? WHERE id = ?`, *in.Icon, id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.URLs != nil {
|
||||
if _, err := tx.Exec(`DELETE FROM urls WHERE item_id = ?`, id); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var out []URL
|
||||
if err := insertURLs(tx, id, *in.URLs, &out); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
it, err := s.loadItem(id)
|
||||
if notFound(err) {
|
||||
fail(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, it)
|
||||
}
|
||||
|
||||
// loadItem reads one item with its urls.
|
||||
func (s *Server) loadItem(id string) (Item, error) {
|
||||
var it Item
|
||||
var cfg sql.NullString
|
||||
err := s.db.QueryRow(`SELECT id, section_id, kind, name, icon, position, config FROM items WHERE id = ?`, id).
|
||||
Scan(&it.ID, &it.SectionID, &it.Kind, &it.Name, &it.Icon, &it.Position, &cfg)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
if cfg.Valid {
|
||||
raw := json.RawMessage(cfg.String)
|
||||
it.Config = &raw
|
||||
}
|
||||
it.URLs = []URL{}
|
||||
rows, err := s.db.Query(`SELECT id, url, label, position FROM urls WHERE item_id = ? ORDER BY position`, id)
|
||||
if err != nil {
|
||||
return it, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var u URL
|
||||
if err := rows.Scan(&u.ID, &u.URL, &u.Label, &u.Position); err != nil {
|
||||
return it, err
|
||||
}
|
||||
it.URLs = append(it.URLs, u)
|
||||
}
|
||||
return it, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) deleteItem(c *gin.Context) {
|
||||
res, err := s.db.Exec(`DELETE FROM items WHERE id = ?`, c.Param("id"))
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
fail(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) reorderItem(c *gin.Context) {
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
SectionID *string `json:"sectionId"`
|
||||
BeforeID *string `json:"beforeId"`
|
||||
AfterID *string `json:"afterId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.ID == "" {
|
||||
fail(c, http.StatusBadRequest, "id required")
|
||||
return
|
||||
}
|
||||
pos, err := s.neighborPosition("items", in.BeforeID, in.AfterID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if in.SectionID == nil {
|
||||
if err := s.db.QueryRow(`SELECT section_id FROM items WHERE id = ?`, in.ID).Scan(&in.SectionID); notFound(err) {
|
||||
fail(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
} else if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := s.db.Exec(`UPDATE items SET position = ?, section_id = ? WHERE id = ?`, pos, *in.SectionID, in.ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
fail(c, http.StatusNotFound, "item not found")
|
||||
return
|
||||
}
|
||||
it, err := s.loadItem(in.ID)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, it)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// allSettings reads the whole settings table into a key/value map.
|
||||
func (s *Server) allSettings() (map[string]json.RawMessage, error) {
|
||||
rows, err := s.db.Query(`SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]json.RawMessage{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = json.RawMessage(v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Server) getSettings(c *gin.Context) {
|
||||
m, err := s.allSettings()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
// putSettings merges the posted keys into the settings table.
|
||||
func (s *Server) putSettings(c *gin.Context) {
|
||||
var in map[string]json.RawMessage
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
fail(c, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for k, v := range in {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
m, err := s.allSettings()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// statusEntry is one cached reachability probe result.
|
||||
type statusEntry struct {
|
||||
Status string `json:"status"` // "up" | "down"
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
CheckedAt time.Time `json:"checkedAt"`
|
||||
}
|
||||
|
||||
// statusCache is a TTL map of url -> probe result. Pings are HEAD requests;
|
||||
// any HTTP response < 500 means the host answered, so the service is up.
|
||||
// ponytail: pings the primary URL only — extend to all urls if demanded.
|
||||
type statusCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
entries map[string]statusEntry
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newStatusCache(ttl time.Duration) *statusCache {
|
||||
return &statusCache{
|
||||
ttl: ttl,
|
||||
entries: map[string]statusEntry{},
|
||||
client: &http.Client{Timeout: 4 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// check returns the cached probe if fresh, otherwise pings synchronously.
|
||||
func (sc *statusCache) check(url string) statusEntry {
|
||||
sc.mu.Lock()
|
||||
if e, ok := sc.entries[url]; ok && time.Since(e.CheckedAt) < sc.ttl {
|
||||
sc.mu.Unlock()
|
||||
return e
|
||||
}
|
||||
sc.mu.Unlock()
|
||||
|
||||
e := sc.ping(url)
|
||||
|
||||
sc.mu.Lock()
|
||||
sc.entries[url] = e
|
||||
sc.mu.Unlock()
|
||||
return e
|
||||
}
|
||||
|
||||
func (sc *statusCache) ping(url string) statusEntry {
|
||||
start := time.Now()
|
||||
e := statusEntry{Status: "down", CheckedAt: start}
|
||||
|
||||
req, err := http.NewRequest(http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return e
|
||||
}
|
||||
resp, err := sc.client.Do(req)
|
||||
e.LatencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return e
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 500 {
|
||||
e.Status = "up"
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// getStatus probes the primary URL of every service item, in parallel,
|
||||
// and returns a map keyed by item id.
|
||||
func (s *Server) getStatus(c *gin.Context) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT i.id, u.url FROM items i
|
||||
JOIN urls u ON u.item_id = i.id
|
||||
WHERE i.kind = 'service'
|
||||
AND u.position = (SELECT MIN(position) FROM urls WHERE item_id = i.id)`)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
type target struct{ id, url string }
|
||||
var targets []target
|
||||
for rows.Next() {
|
||||
var t target
|
||||
if err := rows.Scan(&t.id, &t.url); err != nil {
|
||||
rows.Close()
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
targets = append(targets, t)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
out := make(map[string]statusEntry, len(targets))
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for _, t := range targets {
|
||||
wg.Add(1)
|
||||
go func(t target) {
|
||||
defer wg.Done()
|
||||
e := s.status.check(t.url)
|
||||
mu.Lock()
|
||||
out[t.id] = e
|
||||
mu.Unlock()
|
||||
}(t)
|
||||
}
|
||||
wg.Wait()
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Export is the portable backup shape: version + settings + board tree.
|
||||
type Export struct {
|
||||
Version int `json:"version"`
|
||||
Settings map[string]json.RawMessage `json:"settings"`
|
||||
Sections []Section `json:"sections"`
|
||||
}
|
||||
|
||||
func (s *Server) exportBoard(c *gin.Context) {
|
||||
settings, err := s.allSettings()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
sections, err := s.board()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, Export{Version: 1, Settings: settings, Sections: sections})
|
||||
}
|
||||
|
||||
// importBoard replaces board state inside one transaction. Ids from the
|
||||
// payload are preserved so a round-trip restores identical state.
|
||||
func (s *Server) importBoard(c *gin.Context) {
|
||||
var in Export
|
||||
if err := c.ShouldBindJSON(&in); err != nil || in.Sections == nil {
|
||||
fail(c, http.StatusBadRequest, "invalid export payload")
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
for _, q := range []string{`DELETE FROM urls`, `DELETE FROM items`, `DELETE FROM sections`} {
|
||||
if _, err := tx.Exec(q); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, sec := range in.Sections {
|
||||
if sec.ID == "" || sec.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "section missing id or name")
|
||||
return
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO sections (id, name, position, collapsed) VALUES (?, ?, ?, ?)`,
|
||||
sec.ID, sec.Name, sec.Position, sec.Collapsed); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, it := range sec.Items {
|
||||
if it.ID == "" || it.Name == "" {
|
||||
fail(c, http.StatusBadRequest, "item missing id or name")
|
||||
return
|
||||
}
|
||||
var cfg any
|
||||
if it.Config != nil {
|
||||
cfg = string(*it.Config)
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO items (id, section_id, kind, name, icon, position, config) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
it.ID, sec.ID, orDefault(it.Kind, "service"), it.Name, it.Icon, it.Position, cfg); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, u := range it.URLs {
|
||||
if !validURL(u.URL) {
|
||||
fail(c, http.StatusBadRequest, "invalid url: "+u.URL)
|
||||
return
|
||||
}
|
||||
uid := u.ID
|
||||
if uid == "" {
|
||||
uid = newID("u")
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO urls (id, item_id, url, label, position) VALUES (?, ?, ?, ?, ?)`,
|
||||
uid, it.ID, u.URL, u.Label, u.Position); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range in.Settings {
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, k, string(v)); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sections, err := s.board()
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, sections)
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- Board tree: sections > items > urls. position REAL = fractional ordering;
|
||||
-- a drag writes one row instead of renumbering the set.
|
||||
CREATE TABLE sections (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position REAL NOT NULL,
|
||||
collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE items (
|
||||
id TEXT PRIMARY KEY,
|
||||
section_id TEXT NOT NULL REFERENCES sections(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL DEFAULT 'service' CHECK (kind IN ('service', 'widget')),
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '',
|
||||
position REAL NOT NULL,
|
||||
config JSON,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
CREATE INDEX items_section_pos ON items(section_id, position);
|
||||
|
||||
CREATE TABLE urls (
|
||||
id TEXT PRIMARY KEY,
|
||||
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
position REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX urls_item_pos ON urls(item_id, position);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE urls;
|
||||
DROP TABLE items;
|
||||
DROP TABLE sections;
|
||||
Reference in new issue
Block a user