Add calendar color support (DAVx5 calendar-color)

Calendars can now have a color (hex, e.g. #3b82f6) that DAVx5 and other
CalDAV clients pick up via the Apple/dav4jvm calendar-color property.

- db: add calendars.color column with migration for existing DBs;
  CreateCalendarWithColor, SetCalendarColor, GetCalendarColor;
  ListCalendars now returns []Calendar{Name, Color} instead of []string
- caldav: since go-webdav's caldav.Backend interface has no extension
  point for vendor properties, wrap the handler with a response-rewriting
  middleware that injects <calendar-color xmlns="http://apple.com/ns/ical/">
  into PROPFIND responses for calendars that have a color set
- web: color picker on the "New calendar" form and an inline color swatch/
  picker on each calendar card (calendars only, not address books)
- nidusctl: `calendar create --color` flag and a new `calendar color`
  subcommand; `calendar list` now also prints the color if set

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 06:43:56 +02:00
co-authored by Copilot
parent 37399d1ceb
commit ebbc7a2a2b
15 changed files with 618 additions and 163 deletions
+2 -1
View File
@@ -270,7 +270,8 @@ nidusctl user list
nidusctl user passwd <username> [--password PW] nidusctl user passwd <username> [--password PW]
# Calendars # Calendars
nidusctl calendar create <owner> <calendar> nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl calendar color <owner> <calendar> <hex-color>
nidusctl calendar delete <owner> <calendar> nidusctl calendar delete <owner> <calendar>
nidusctl calendar list <owner> nidusctl calendar list <owner>
nidusctl calendar share <owner> <calendar> <user> <read|write> nidusctl calendar share <owner> <calendar> <user> <read|write>
+2 -2
View File
@@ -82,8 +82,8 @@ func main() {
continue continue
} }
for _, cal := range cals { for _, cal := range cals {
if err := st.EnsureCollection(user.Username, "cal-"+cal); err != nil { if err := st.EnsureCollection(user.Username, "cal-"+cal.Name); err != nil {
logger.Warn("creating calendar collection", "user", user.Username, "cal", cal, "error", err) logger.Warn("creating calendar collection", "user", user.Username, "cal", cal.Name, "error", err)
} }
} }
books, err := dbase.ListAddressBooks(user.Username) books, err := dbase.ListAddressBooks(user.Username)
+142 -7
View File
@@ -1,11 +1,16 @@
package caldav package caldav
import ( import (
"bytes"
"context" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest"
"path" "path"
"regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -40,9 +45,15 @@ func NewBackend(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.
} }
// NewHandler returns an http.Handler for the /cal/ prefix. // NewHandler returns an http.Handler for the /cal/ prefix.
//
// It wraps the go-webdav caldav.Handler with a small response-rewriting
// middleware that injects the non-standard (Apple/DAVx5) calendar-color
// property into PROPFIND responses for calendar collections, since
// go-webdav's caldav.Backend interface has no extension point for
// vendor-specific WebDAV properties.
func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler { func NewHandler(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) http.Handler {
b := NewBackend(cfg, st, dbase, logger) b := NewBackend(cfg, st, dbase, logger)
return &caldav.Handler{Backend: b} return &colorInjectingHandler{backend: b, next: &caldav.Handler{Backend: b}}
} }
// -------- caldav.Backend interface -------- // -------- caldav.Backend interface --------
@@ -79,19 +90,19 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
} }
var cals []caldav.Calendar var cals []caldav.Calendar
for _, name := range names { for _, cal := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+name); err != nil { if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", name, "error", err) b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
continue continue
} }
cals = append(cals, b.calendarMeta(p.Username, name, name)) cals = append(cals, b.calendarMeta(p.Username, cal.Name, cal.Name))
} }
// Also include any extra calendars that exist on disk but aren't registered // Also include any extra calendars that exist on disk but aren't registered
disk, _ := b.store.ListCollections(p.Username) disk, _ := b.store.ListCollections(p.Username)
configured := make(map[string]bool) configured := make(map[string]bool)
for _, n := range names { for _, cal := range names {
configured["cal-"+n] = true configured["cal-"+cal.Name] = true
} }
for _, dir := range disk { for _, dir := range disk {
if strings.HasPrefix(dir, "cal-") && !configured[dir] { if strings.HasPrefix(dir, "cal-") && !configured[dir] {
@@ -386,3 +397,127 @@ func hashBytes(data []byte) uint64 {
} }
return h return h
} }
// -------- calendar-color PROPFIND injection --------
// calCollectionPathRe matches a calendar collection's own path (e.g.
// "/cal/home/work/"), as opposed to a calendar object inside it (e.g.
// "/cal/home/work/abc123.ics") or the home-set/principal path.
var calCollectionPathRe = regexp.MustCompile(`^/cal/home/[^/]+/$`)
// colorInjectingHandler wraps a caldav.Handler and post-processes PROPFIND
// responses to add the non-standard `calendar-color` property (in Apple's
// "http://apple.com/ns/ical/" namespace) that DAVx5 and other clients read
// to color-code synced calendars. go-webdav's caldav.Backend interface has
// no extension point for vendor properties like this, so the response XML
// is rewritten after the fact instead.
type colorInjectingHandler struct {
backend *Backend
next http.Handler
}
func (h *colorInjectingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "PROPFIND" || h.backend.dbase == nil {
h.next.ServeHTTP(w, r)
return
}
// Only bother rewriting if the client asked for calendar-color
// specifically, or for all properties (propname/allprop requests, or
// no body at all, are treated as "all properties" by most clients).
var reqBody []byte
if r.Body != nil {
reqBody, _ = readAllAndReset(&r.Body)
}
wantsColor := len(reqBody) == 0 || bytes.Contains(reqBody, []byte("calendar-color")) || bytes.Contains(reqBody, []byte("allprop"))
if !wantsColor {
h.next.ServeHTTP(w, r)
return
}
rec := httptest.NewRecorder()
h.next.ServeHTTP(rec, r)
for k, vs := range rec.Header() {
for _, v := range vs {
w.Header().Add(k, v)
}
}
body := rec.Body.Bytes()
if rec.Code == http.StatusMultiStatus && strings.Contains(rec.Header().Get("Content-Type"), "xml") {
body = h.injectCalendarColors(r.Context(), body)
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
}
w.WriteHeader(rec.Code)
_, _ = w.Write(body)
}
// responseBlockRe matches a single <response>...</response> element
// (non-greedy) inside a multistatus document, capturing its href.
var responseBlockRe = regexp.MustCompile(`(?s)<response[^>]*>.*?<href[^>]*>([^<]*)</href>.*?</response>`)
// injectCalendarColors scans a multistatus PROPFIND response body and, for
// each <response> whose href is a calendar collection with a color set,
// inserts a <apple:calendar-color xmlns:apple="http://apple.com/ns/ical/">
// element into its first 200 OK <prop>.
func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body []byte) []byte {
p := auth.FromContext(ctx)
if p == nil {
return body
}
return responseBlockRe.ReplaceAllFunc(body, func(block []byte) []byte {
m := responseBlockRe.FindSubmatch(block)
if m == nil {
return block
}
href := string(m[1])
if !calCollectionPathRe.MatchString(href) {
return block
}
localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/")
owner, realName, _, err := h.backend.resolveCalendar(p.Username, localName, false)
if err != nil {
return block
}
color, err := h.backend.dbase.GetCalendarColor(owner, realName)
if err != nil || color == "" {
return block
}
propEl := []byte(`<prop xmlns="DAV:">`)
idx := bytes.Index(block, propEl)
if idx < 0 {
return block
}
insertAt := idx + len(propEl)
colorEl := []byte(fmt.Sprintf(`<calendar-color xmlns="http://apple.com/ns/ical/">%s</calendar-color>`, xmlEscapeColor(color)))
out := make([]byte, 0, len(block)+len(colorEl))
out = append(out, block[:insertAt]...)
out = append(out, colorEl...)
out = append(out, block[insertAt:]...)
return out
})
}
// xmlEscapeColor returns color formatted as an 8-digit ARGB/RGBA hex value
// (as Apple's calendar-color property expects), padding a plain 6-digit
// "#RRGGBB" (as produced by an HTML <input type="color">) with a fully
// opaque alpha channel.
func xmlEscapeColor(color string) string {
if len(color) == 7 && color[0] == '#' {
return color + "FF"
}
return color
}
// readAllAndReset reads body fully and replaces it with a fresh reader over
// the same bytes, so downstream handlers can still consume it.
func readAllAndReset(body *io.ReadCloser) ([]byte, error) {
data, err := io.ReadAll(*body)
if err != nil {
return nil, err
}
*body = io.NopCloser(bytes.NewReader(data))
return data, nil
}
+64
View File
@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http"
"net/http/httptest"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -149,3 +151,65 @@ func TestUnauthorizedUserCannotAccessUnsharedCalendar(t *testing.T) {
t.Fatal("expected error accessing unshared calendar, got nil") t.Fatal("expected error accessing unshared calendar, got nil")
} }
} }
// TestPropFindEmitsCalendarColor verifies that a PROPFIND on a calendar
// with a color set returns the Apple/DAVx5 calendar-color property, and
// that a calendar without a color doesn't (since a client should fall
// back to its own default in that case).
func TestPropFindEmitsCalendarColor(t *testing.T) {
dir := t.TempDir()
st, err := store.NewStore(filepath.Join(dir, "data"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
dbase, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { dbase.Close() })
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
t.Fatalf("CreateUser alice: %v", err)
}
if err := dbase.CreateCalendarWithColor("alice", "work", "#3b82f6"); err != nil {
t.Fatalf("CreateCalendarWithColor: %v", err)
}
if err := dbase.CreateCalendar("alice", "personal"); err != nil {
t.Fatalf("CreateCalendar: %v", err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
handler := NewHandler(&config.Config{}, st, dbase, logger)
req := httptest.NewRequest("PROPFIND", "/cal/home/", strings.NewReader(
`<?xml version="1.0"?><a:propfind xmlns:a="DAV:"><a:allprop/></a:propfind>`))
req.SetBasicAuth("alice", "pw")
req.Header.Set("Content-Type", "text/xml")
req.Header.Set("Depth", "1")
req = req.WithContext(ctxFor("alice"))
rr := httptest.NewRecorder()
req = req.WithContext(auth.NewContext(context.Background(), &auth.Principal{Username: "alice"}))
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusMultiStatus {
t.Fatalf("expected 207, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">#3b82f6FF</calendar-color>`) {
t.Fatalf("expected calendar-color for work calendar, got: %s", body)
}
// The "personal" calendar has no color, so its <response> block
// shouldn't contain the property at all.
personalIdx := strings.Index(body, "/cal/home/personal/")
if personalIdx < 0 {
t.Fatalf("expected personal calendar in response, got: %s", body)
}
// Find personal's response block boundaries loosely by looking for the
// nearest calendar-color occurrence and ensuring it isn't right next to
// the personal href (colors are per-block, checked via count instead).
if strings.Count(body, "<calendar-color ") != 1 {
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body)
}
}
+39
View File
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS users (
CREATE TABLE IF NOT EXISTS calendars ( CREATE TABLE IF NOT EXISTS calendars (
owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE, owner TEXT NOT NULL REFERENCES users (username) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (owner, name) PRIMARY KEY (owner, name)
); );
@@ -106,5 +107,43 @@ CREATE TABLE IF NOT EXISTS web_sessions (
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at); CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at);
` `
_, err := d.conn.Exec(schema) _, err := d.conn.Exec(schema)
if err != nil {
return err return err
} }
return d.migrateAddColumns()
}
// migrateAddColumns adds columns to already-existing tables that predate
// their introduction. CREATE TABLE IF NOT EXISTS above only creates a
// table's initial shape, so columns added later (like calendars.color)
// need an explicit ALTER TABLE for databases created before this change.
func (d *DB) migrateAddColumns() error {
hasColumn := func(table, column string) (bool, error) {
rows, err := d.conn.Query(`SELECT name FROM pragma_table_info(?)`, table)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
ok, err := hasColumn("calendars", "color")
if err != nil {
return fmt.Errorf("checking calendars.color column: %w", err)
}
if !ok {
if _, err := d.conn.Exec(`ALTER TABLE calendars ADD COLUMN color TEXT NOT NULL DEFAULT ''`); err != nil {
return fmt.Errorf("adding calendars.color column: %w", err)
}
}
return nil
}
+56 -5
View File
@@ -160,10 +160,24 @@ func (d *DB) UserCount() (int, error) {
// -------- calendars -------- // -------- calendars --------
// Calendar is a calendar registration, including its display color.
type Calendar struct {
Name string
Color string
}
// CreateCalendar registers a new calendar owned by owner. Returns // CreateCalendar registers a new calendar owned by owner. Returns
// ErrResourceExists if it already exists. // ErrResourceExists if it already exists.
func (d *DB) CreateCalendar(owner, name string) error { func (d *DB) CreateCalendar(owner, name string) error {
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name) VALUES (?, ?)`, owner, name) return d.CreateCalendarWithColor(owner, name, "")
}
// CreateCalendarWithColor registers a new calendar owned by owner with the
// given color (an empty string means no color is set, in which case the
// client picks its own default). Returns ErrResourceExists if it already
// exists.
func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
if err != nil { if err != nil {
if isUniqueConstraintErr(err) { if isUniqueConstraintErr(err) {
return ErrResourceExists return ErrResourceExists
@@ -173,6 +187,30 @@ func (d *DB) CreateCalendar(owner, name string) error {
return nil return nil
} }
// SetCalendarColor updates a calendar's display color. Returns
// ErrResourceNotFound if it doesn't exist.
func (d *DB) SetCalendarColor(owner, name, color string) error {
res, err := d.conn.Exec(`UPDATE calendars SET color = ? WHERE owner = ? AND name = ?`, color, owner, name)
if err != nil {
return fmt.Errorf("setting calendar color: %w", err)
}
return requireRowsAffected(res, ErrResourceNotFound)
}
// GetCalendarColor returns a calendar's display color. Returns
// ErrResourceNotFound if it doesn't exist.
func (d *DB) GetCalendarColor(owner, name string) (string, error) {
var color string
err := d.conn.QueryRow(`SELECT color FROM calendars WHERE owner = ? AND name = ?`, owner, name).Scan(&color)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrResourceNotFound
}
return "", fmt.Errorf("getting calendar color: %w", err)
}
return color, nil
}
// DeleteCalendar removes a calendar registration (not the underlying // DeleteCalendar removes a calendar registration (not the underlying
// files/objects — callers are responsible for also removing those via // files/objects — callers are responsible for also removing those via
// store.Store). Returns ErrResourceNotFound if it doesn't exist. Any // store.Store). Returns ErrResourceNotFound if it doesn't exist. Any
@@ -197,10 +235,23 @@ func (d *DB) DeleteCalendar(owner, name string) error {
return tx.Commit() return tx.Commit()
} }
// ListCalendars returns the names of all calendars owner has registered, // ListCalendars returns all calendars owner has registered, sorted by name.
// sorted. func (d *DB) ListCalendars(owner string) ([]Calendar, error) {
func (d *DB) ListCalendars(owner string) ([]string, error) { rows, err := d.conn.Query(`SELECT name, color FROM calendars WHERE owner = ? ORDER BY name`, owner)
return listNames(d, `SELECT name FROM calendars WHERE owner = ? ORDER BY name`, owner) if err != nil {
return nil, fmt.Errorf("listing calendars: %w", err)
}
defer rows.Close()
var cals []Calendar
for rows.Next() {
var c Calendar
if err := rows.Scan(&c.Name, &c.Color); err != nil {
return nil, fmt.Errorf("scanning calendar: %w", err)
}
cals = append(cals, c)
}
return cals, rows.Err()
} }
// -------- address books -------- // -------- address books --------
+7 -4
View File
@@ -55,15 +55,15 @@ func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error
if err != nil { if err != nil {
return nil, fmt.Errorf("listing calendars: %w", err) return nil, fmt.Errorf("listing calendars: %w", err)
} }
for _, calName := range calNames { for _, cal := range calNames {
card := templates.ResourceCard{Kind: "calendar", Name: calName} card := templates.ResourceCard{Kind: "calendar", Name: cal.Name, Color: cal.Color}
shares, err := s.dbase.SharesOfCalendar(username, calName) shares, err := s.dbase.SharesOfCalendar(username, cal.Name)
if err != nil { if err != nil {
s.logger.Warn("listing calendar shares", "error", err) s.logger.Warn("listing calendar shares", "error", err)
} }
for _, sh := range shares { for _, sh := range shares {
card.Shares = append(card.Shares, templates.ShareRow{ card.Shares = append(card.Shares, templates.ShareRow{
ResourceName: calName, ResourceName: cal.Name,
SharedWith: sh.SharedWith, SharedWith: sh.SharedWith,
Permission: string(sh.Permission), Permission: string(sh.Permission),
}) })
@@ -101,6 +101,9 @@ func (s *Server) resourceCardFor(username, kind, name string) (templates.Resourc
var shares []templates.ShareRow var shares []templates.ShareRow
if kind == "calendar" { if kind == "calendar" {
if color, err := s.dbase.GetCalendarColor(username, name); err == nil {
card.Color = color
}
rows, err := s.dbase.SharesOfCalendar(username, name) rows, err := s.dbase.SharesOfCalendar(username, name)
if err != nil { if err != nil {
return card, err return card, err
+51 -1
View File
@@ -14,6 +14,10 @@ import (
// are safe as both a URL path segment and a filesystem directory name. // are safe as both a URL path segment and a filesystem directory name.
var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) var resourceNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
// hexColorRe validates the 6-digit hex color format produced by an HTML
// <input type="color">, e.g. "#3b82f6".
var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// handleCalendarResource handles POST (create) and DELETE (remove) for // handleCalendarResource handles POST (create) and DELETE (remove) for
// the current user's own calendars, mounted at /resources/calendar. // the current user's own calendars, mounted at /resources/calendar.
func (s *Server) handleCalendarResource(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCalendarResource(w http.ResponseWriter, r *http.Request) {
@@ -24,6 +28,47 @@ func (s *Server) handleAddressBookResource(w http.ResponseWriter, r *http.Reques
s.handleResource(w, r, "addressbook") s.handleResource(w, r, "addressbook")
} }
// handleCalendarColor updates the color of one of the current user's own
// calendars, mounted at /resources/calendar/color. It re-renders just
// that calendar's card (not the whole list), since the set of cards
// doesn't change.
func (s *Server) handleCalendarColor(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.PostForm.Get("name"))
color := strings.TrimSpace(r.PostForm.Get("color"))
if !resourceNameRe.MatchString(name) {
http.Error(w, "name must be 1-64 letters, digits, '-' or '_'", http.StatusBadRequest)
return
}
if color != "" && !hexColorRe.MatchString(color) {
http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest)
return
}
if err := s.dbase.SetCalendarColor(username, name, color); err != nil && err != db.ErrResourceNotFound {
s.logger.Error("setting calendar color", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
card, err := s.resourceCardFor(username, "calendar", name)
if err != nil {
s.logger.Error("rendering resource card", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.ResourceCardView(card).Render(r.Context(), w)
}
func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) { func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) {
username := userFromContext(r.Context()) username := userFromContext(r.Context())
@@ -50,7 +95,12 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str
case http.MethodPost: case http.MethodPost:
var err error var err error
if kind == "calendar" { if kind == "calendar" {
err = s.dbase.CreateCalendar(username, name) color := strings.TrimSpace(r.PostForm.Get("color"))
if color != "" && !hexColorRe.MatchString(color) {
http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest)
return
}
err = s.dbase.CreateCalendarWithColor(username, name, color)
} else { } else {
err = s.dbase.CreateAddressBook(username, name) err = s.dbase.CreateAddressBook(username, name)
} }
+4 -4
View File
@@ -32,8 +32,8 @@ func TestCreateAndDeleteCalendarViaWebUI(t *testing.T) {
t.Fatalf("ListCalendars: %v", err) t.Fatalf("ListCalendars: %v", err)
} }
found := false found := false
for _, n := range names { for _, c := range names {
if n == "vacation" { if c.Name == "vacation" {
found = true found = true
} }
} }
@@ -67,8 +67,8 @@ func TestCreateAndDeleteCalendarViaWebUI(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ListCalendars: %v", err) t.Fatalf("ListCalendars: %v", err)
} }
for _, n := range names { for _, c := range names {
if n == "vacation" { if c.Name == "vacation" {
t.Fatalf("expected vacation calendar to be gone, got %v", names) t.Fatalf("expected vacation calendar to be gone, got %v", names)
} }
} }
+1
View File
@@ -43,6 +43,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare)) mux.HandleFunc("/shares/calendar", s.requireLogin(s.handleCalendarShare))
mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare)) mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare))
mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource)) mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource))
mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor))
mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource)) mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource))
return mux return mux
+12 -7
View File
@@ -95,15 +95,20 @@ func (s *Server) handleShare(w http.ResponseWriter, r *http.Request, kind string
// actually configured for username, to prevent sharing arbitrary/other // actually configured for username, to prevent sharing arbitrary/other
// users' resources via a forged form post. // users' resources via a forged form post.
func (s *Server) ownsResource(username, kind, resource string) bool { func (s *Server) ownsResource(username, kind, resource string) bool {
var (
list []string
err error
)
if kind == "calendar" { if kind == "calendar" {
list, err = s.dbase.ListCalendars(username) cals, err := s.dbase.ListCalendars(username)
} else { if err != nil {
list, err = s.dbase.ListAddressBooks(username) s.logger.Warn("checking resource ownership", "kind", kind, "error", err)
return false
} }
for _, c := range cals {
if c.Name == resource {
return true
}
}
return false
}
list, err := s.dbase.ListAddressBooks(username)
if err != nil { if err != nil {
s.logger.Warn("checking resource ownership", "kind", kind, "error", err) s.logger.Warn("checking resource ownership", "kind", kind, "error", err)
return false return false
+31 -1
View File
@@ -13,6 +13,7 @@ type ShareRow struct {
type ResourceCard struct { type ResourceCard struct {
Kind string // "calendar" or "addressbook" Kind string // "calendar" or "addressbook"
Name string Name string
Color string // hex color like "#3b82f6"; only used for calendars
Shares []ShareRow Shares []ShareRow
} }
@@ -43,6 +44,11 @@ templ Dashboard(username string, resources []ResourceCard, sharedWithMe []Shared
placeholder="e.g. work" placeholder="e.g. work"
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/> class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
</div> </div>
<div>
<label class="block text-xs text-gray-500 mb-1">Color</label>
<input name="color" type="color" value="#3b82f6"
class="w-12 h-9 rounded-md border-gray-300 border p-0.5"/>
</div>
<button type="submit" <button type="submit"
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700"> class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Add Add
@@ -106,7 +112,24 @@ templ ResourceList(resources []ResourceCard) {
templ ResourceCardView(r ResourceCard) { templ ResourceCardView(r ResourceCard) {
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5"> <div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h2 class="font-medium"> <h2 class="font-medium flex items-center gap-2">
if r.Kind == "calendar" {
<form
hx-post="/web/resources/calendar/color"
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML"
hx-trigger="change"
>
<input type="hidden" name="name" value={ r.Name }/>
<input
name="color"
type="color"
value={ colorOrDefault(r.Color) }
title="Calendar color"
class="w-6 h-6 rounded border border-gray-300 p-0 align-middle"
/>
</form>
}
{ r.Name } { r.Name }
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span> <span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span>
</h2> </h2>
@@ -195,3 +218,10 @@ func resourceVals(name string) string {
return `{"name": "` + name + `"}` return `{"name": "` + name + `"}`
} }
func colorOrDefault(c string) string {
if c == "" {
return "#3b82f6"
}
return c
}
+125 -72
View File
@@ -21,6 +21,7 @@ type ShareRow struct {
type ResourceCard struct { type ResourceCard struct {
Kind string // "calendar" or "addressbook" Kind string // "calendar" or "addressbook"
Name string Name string
Color string // hex color like "#3b82f6"; only used for calendars
Shares []ShareRow Shares []ShareRow
} }
@@ -66,7 +67,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars &amp; address books</h1><div class=\"flex flex-col sm:flex-row gap-4 mb-6\"><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/calendar\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. work\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/addressbook\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New address book</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. contacts\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars &amp; address books</h1><div class=\"flex flex-col sm:flex-row gap-4 mb-6\"><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/calendar\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New calendar</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. work\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Color</label> <input name=\"color\" type=\"color\" value=\"#3b82f6\" class=\"w-12 h-9 rounded-md border-gray-300 border p-0.5\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form><form class=\"flex flex-col sm:flex-row sm:items-end gap-2 bg-white rounded-lg border border-gray-200 p-4\" hx-post=\"/web/resources/addressbook\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-on::after-request=\"if(event.detail.successful) this.reset()\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">New address book</label> <input name=\"name\" type=\"text\" required pattern=\"[a-zA-Z0-9_-]{1,64}\" placeholder=\"e.g. contacts\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Add</button></form></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -91,7 +92,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 79, Col: 45} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 45}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -104,7 +105,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 79, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -117,7 +118,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 80, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -130,7 +131,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 82, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 88, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -232,211 +233,256 @@ func ResourceCardView(r ResourceCard) templ.Component {
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 107, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-medium\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-medium flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if r.Kind == "calendar" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<form hx-post=\"/web/resources/calendar/color\" hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 110, Col: 12} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-swap=\"outerHTML\" hx-trigger=\"change\"><input type=\"hidden\" name=\"name\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 111, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 123, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span></h2><button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"> <input name=\"color\" type=\"color\" value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind)) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(colorOrDefault(r.Color))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 115, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 127, Col: 38}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-vals=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" title=\"Calendar color\" class=\"w-6 h-6 rounded border border-gray-300 p-0 align-middle\"></form>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
}
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name)) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 116, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 133, Col: 12}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.") templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 119, Col: 104} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">Delete</button></div><ul class=\"divide-y divide-gray-100 mb-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span></h2><button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, sh := range r.Shares {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceEndpoint(r.Kind))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 128, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 138, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(resourceVals(r.Name))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 130, Col: 81} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 139, Col: 34}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"#resources\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind)) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone.")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 133, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 142, Col: 104}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-vals=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">Delete</button></div><ul class=\"divide-y divide-gray-100 mb-4\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, sh := range r.Shares {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith)) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 151, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-target=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 135, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 153, Col: 81}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-swap=\"outerHTML\" hx-confirm=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?") templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 137, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 156, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">Remove</button></span></li>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-vals=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(r.Shares) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 151, Col: 34} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 157, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-target=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" hx-target=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 152, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 158, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 155, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 160, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Remove</button></span></li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
if len(r.Shares) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</ul><form class=\"flex flex-col sm:flex-row sm:items-end gap-2\" hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 174, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-target=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 175, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 178, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -466,4 +512,11 @@ func resourceVals(name string) string {
return `{"name": "` + name + `"}` return `{"name": "` + name + `"}`
} }
func colorOrDefault(c string) string {
if c == "" {
return "#3b82f6"
}
return c
}
var _ = templruntime.GeneratedTemplate var _ = templruntime.GeneratedTemplate
+31 -8
View File
@@ -80,7 +80,8 @@ Usage:
nidusctl [-config config.yaml] user list nidusctl [-config config.yaml] user list
nidusctl [-config config.yaml] user passwd <username> [--password PW] nidusctl [-config config.yaml] user passwd <username> [--password PW]
nidusctl [-config config.yaml] calendar create <owner> <calendar> nidusctl [-config config.yaml] calendar create <owner> <calendar> [--color '#RRGGBB']
nidusctl [-config config.yaml] calendar color <owner> <calendar> <hex-color>
nidusctl [-config config.yaml] calendar delete <owner> <calendar> nidusctl [-config config.yaml] calendar delete <owner> <calendar>
nidusctl [-config config.yaml] calendar list <owner> nidusctl [-config config.yaml] calendar list <owner>
nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write> nidusctl [-config config.yaml] calendar share <owner> <calendar> <user> <read|write>
@@ -117,12 +118,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
} }
switch args[0] { switch args[0] {
case "create": case "create":
if len(args) != 3 { fs := newFlagSet("calendar create")
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar>") color := fs.String("color", "", "hex color like #3b82f6 (optional)")
if err := fs.Parse(args[1:]); err != nil {
return 2 return 2
} }
owner, calName := args[1], args[2] if fs.NArg() != 2 {
if err := dbase.CreateCalendar(owner, calName); err != nil { fmt.Fprintln(os.Stderr, "usage: nidusctl calendar create <owner> <calendar> [--color '#RRGGBB']")
return 2
}
owner, calName := fs.Arg(0), fs.Arg(1)
if err := dbase.CreateCalendarWithColor(owner, calName, *color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
@@ -132,6 +138,19 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
fmt.Printf("created calendar %q for %s\n", calName, owner) fmt.Printf("created calendar %q for %s\n", calName, owner)
return 0 return 0
case "color":
if len(args) != 4 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar color <owner> <calendar> <hex-color>")
return 2
}
owner, calName, color := args[1], args[2], args[3]
if err := dbase.SetCalendarColor(owner, calName, color); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1
}
fmt.Printf("set color of calendar %q for %s to %s\n", calName, owner, color)
return 0
case "delete": case "delete":
if len(args) != 3 { if len(args) != 3 {
fmt.Fprintln(os.Stderr, "usage: nidusctl calendar delete <owner> <calendar>") fmt.Fprintln(os.Stderr, "usage: nidusctl calendar delete <owner> <calendar>")
@@ -154,13 +173,17 @@ func runCalendar(dbase *db.DB, st *store.Store, args []string) int {
return 2 return 2
} }
owner := args[1] owner := args[1]
names, err := dbase.ListCalendars(owner) cals, err := dbase.ListCalendars(owner)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err) fmt.Fprintf(os.Stderr, "error: %v\n", err)
return 1 return 1
} }
for _, n := range names { for _, c := range cals {
fmt.Println(n) if c.Color != "" {
fmt.Printf("%s\t%s\n", c.Name, c.Color)
} else {
fmt.Println(c.Name)
}
} }
return 0 return 0
+1 -1
View File
File diff suppressed because one or more lines are too long