From 70605fda7db53937fdc6f902e0a5d83aed03304a Mon Sep 17 00:00:00 2001 From: Panonim Date: Wed, 8 Jul 2026 22:48:17 +0200 Subject: [PATCH 01/53] Updated subrequests in dynawidgets --- docs/docs/contributing.md | 13 ++++++++++ internal/dynacat/widget-dynawidgets.go | 33 +++++++++++++++++++++----- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index ad67970f..a2f729ca 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -66,6 +66,19 @@ If you want to share a custom widget with the community, follow these steps: 5. Add a `required:` section at the bottom of `template.txt` to specify default configuration values. This is optional but recommended for any API that requires a URL or other configuration. The format is YAML, with supported fields: - `url`: The default API endpoint to fetch data from + - `subrequests`: Default subrequests keyed by name, each a request with its own `url` (and optional `headers`, `method`, etc). Required if your template calls `.Subrequest "key"`, so the key is defined even when the user does not set it. Example: + + ```text +
{{ (.Subrequest "heartbeats").JSON.Int "count" }}
+ + required: | + url: https://api.example.com/stats + subrequests: + heartbeats: + url: https://api.example.com/heartbeats + ``` + + Users can override each subrequest's URL the same way they override the primary `url`. When users add your widget to their config, they can override these defaults: diff --git a/internal/dynacat/widget-dynawidgets.go b/internal/dynacat/widget-dynawidgets.go index f5b8b948..f3a98a62 100644 --- a/internal/dynacat/widget-dynawidgets.go +++ b/internal/dynacat/widget-dynawidgets.go @@ -48,7 +48,8 @@ type dynawidgetsListEntry struct { } type dynawidgetsRequired struct { - URL string `yaml:"url"` + URL string `yaml:"url"` + Subrequests map[string]*CustomAPIRequest `yaml:"subrequests"` } func (widget *dynawidgetsWidget) initialize() error { @@ -80,12 +81,32 @@ func (widget *dynawidgetsWidget) initialize() error { } // Apply required defaults if user hasn't specified them - if required != nil && required.URL != "" { - if widget.CustomAPIRequest == nil { - widget.CustomAPIRequest = &CustomAPIRequest{} + if required != nil { + if required.URL != "" { + if widget.CustomAPIRequest == nil { + widget.CustomAPIRequest = &CustomAPIRequest{} + } + if widget.CustomAPIRequest.URL == "" { + widget.CustomAPIRequest.URL = required.URL + } } - if widget.CustomAPIRequest.URL == "" { - widget.CustomAPIRequest.URL = required.URL + + // Merge subrequests declared in the template's required section. + // User config takes precedence; a template default only fills a + // subrequest the user hasn't defined, or an empty URL on one they have. + for key, req := range required.Subrequests { + if req == nil { + continue + } + if widget.Subrequests == nil { + widget.Subrequests = make(map[string]*CustomAPIRequest) + } + existing, ok := widget.Subrequests[key] + if !ok || existing == nil { + widget.Subrequests[key] = req + } else if existing.URL == "" { + existing.URL = req.URL + } } } From 51fe1541523a042be21bc273dcd42d323663e99d Mon Sep 17 00:00:00 2001 From: Panonim Date: Tue, 14 Jul 2026 11:59:53 +0200 Subject: [PATCH 02/53] Updated dynawidgets to fix a bug with subrequests --- internal/dynacat/widget-dynawidgets.go | 34 +++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/internal/dynacat/widget-dynawidgets.go b/internal/dynacat/widget-dynawidgets.go index f3a98a62..8c678ee6 100644 --- a/internal/dynacat/widget-dynawidgets.go +++ b/internal/dynacat/widget-dynawidgets.go @@ -54,6 +54,7 @@ type dynawidgetsRequired struct { func (widget *dynawidgetsWidget) initialize() error { widget.withTitle("Dynawidgets").withCacheDuration(1 * time.Minute) + widget.widgetBase.WIP = true if widget.Widget == "" { return errors.New("widget (slug) is required") @@ -184,7 +185,7 @@ func dynawidgetsParseTemplate(raw string) (templateContent string, required *dyn } templateContent = strings.TrimRight(raw[:idx], "\n\r ") - requiredRaw := strings.TrimSpace(raw[idx+len(separator):]) + requiredRaw := dedentYAMLBlock(raw[idx+len(separator):]) if requiredRaw == "" { return templateContent, nil @@ -199,6 +200,37 @@ func dynawidgetsParseTemplate(raw string) (templateContent string, required *dyn return templateContent, required } +// dedentYAMLBlock strips the common leading indentation from the "required: |" +// block, keeping nested keys aligned. +func dedentYAMLBlock(raw string) string { + lines := strings.Split(raw, "\n") + + minIndent := -1 + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + indent := len(line) - len(strings.TrimLeft(line, " \t")) + if minIndent == -1 || indent < minIndent { + minIndent = indent + } + } + + if minIndent <= 0 { + return strings.TrimSpace(raw) + } + + for i, line := range lines { + if len(line) >= minIndent { + lines[i] = line[minIndent:] + } else { + lines[i] = strings.TrimLeft(line, " \t") + } + } + + return strings.TrimSpace(strings.Join(lines, "\n")) +} + // dynawidgetsResolveTemplate checks for a cached template on disk, or fetches // it from the dynawidgets repository. Returns the template content, the // widget title (empty if loaded from cache), and parsed required config. From e0ed8ce2d63a659b6b38f01f49e1d6d36da5f0b6 Mon Sep 17 00:00:00 2001 From: Panonim Date: Tue, 14 Jul 2026 12:32:11 +0200 Subject: [PATCH 03/53] Dynawidget - fixed protocol handling --- internal/dynacat/widget-custom-api.go | 4 ++++ internal/dynacat/widget-dynawidgets.go | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/internal/dynacat/widget-custom-api.go b/internal/dynacat/widget-custom-api.go index 11255f25..d8297b2a 100644 --- a/internal/dynacat/widget-custom-api.go +++ b/internal/dynacat/widget-custom-api.go @@ -198,6 +198,10 @@ func (req *CustomAPIRequest) initialize() error { req.Method = http.MethodGet } + if !strings.Contains(req.URL, "://") { + req.URL = "https://" + req.URL + } + httpReq, err := http.NewRequest(strings.ToUpper(req.Method), req.URL, req.bodyReader) if err != nil { return err diff --git a/internal/dynacat/widget-dynawidgets.go b/internal/dynacat/widget-dynawidgets.go index 8c678ee6..41e5721c 100644 --- a/internal/dynacat/widget-dynawidgets.go +++ b/internal/dynacat/widget-dynawidgets.go @@ -191,6 +191,13 @@ func dynawidgetsParseTemplate(raw string) (templateContent string, required *dyn return templateContent, nil } + expanded, err := parseConfigVariables([]byte(requiredRaw)) + if err != nil { + slog.Error("Failed to expand variables in dynawidget required section", "error", err) + return templateContent, nil + } + requiredRaw = string(expanded) + required = &dynawidgetsRequired{} if err := yaml.Unmarshal([]byte(requiredRaw), required); err != nil { slog.Error("Failed to parse dynawidget required section", "error", err) From 6f964f6241ae11da230a32b415478028dbd0e638 Mon Sep 17 00:00:00 2001 From: Panonim Date: Sun, 19 Jul 2026 22:16:52 +0200 Subject: [PATCH 04/53] Added visual configuration --- docs/docs/changelog.md | 5 + docs/docs/configuration.md | 4 +- internal/dynacat/config-editor.go | 634 +++++++++++++++ internal/dynacat/dynacat.go | 12 +- internal/dynacat/editor-http.go | 84 ++ internal/dynacat/main.go | 2 + internal/dynacat/static/css/editor.css | 629 +++++++++++++++ internal/dynacat/static/css/main.css | 1 + internal/dynacat/static/css/widget-search.css | 2 +- .../dynacat/static/js/docker-controller.js | 2 +- internal/dynacat/static/js/editor.js | 739 ++++++++++++++++++ internal/dynacat/templates/page.html | 17 + internal/dynacat/widget-playing.go | 4 +- internal/dynacat/widget-schema.go | 357 +++++++++ pkg/sysinfo/sysinfo.go | 2 +- 15 files changed, 2484 insertions(+), 10 deletions(-) create mode 100644 internal/dynacat/config-editor.go create mode 100644 internal/dynacat/editor-http.go create mode 100644 internal/dynacat/static/css/editor.css create mode 100644 internal/dynacat/static/js/editor.js create mode 100644 internal/dynacat/widget-schema.go diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index cd5683f6..fc14f00a 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -1,3 +1,8 @@ +# Changes for 3.0.0 +- Fixed an issue where subrequests were not detected properly in `dynawidgets` +- Fixed `dynawidgets` protocol handling +- Added an option to visually edit your configuration + # Changes for 2.4.0 - Added Brave Search as an autocompletion engine and normal one - Added support for icons in the page title diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index bfc95b0f..2b6fa909 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -582,10 +582,10 @@ A keybind is one or more keys separated by spaces. Each key is a single letter o | Config value | Behavior | | ------------ | -------- | -| `h` | Automatically expanded to `d h` — press `d` then `h` | +| `h` | Automatically expanded to `d h` - press `d` then `h` | | `d h` | Press `d`, release, then press `h` within 1 second | | `d a c` | Three-key sequence: `d` → `a` → `c` | -| `1` | Automatically expanded to `d 1` — press `d` then `1` | +| `1` | Automatically expanded to `d 1` - press `d` then `1` | > [!NOTE] > diff --git a/internal/dynacat/config-editor.go b/internal/dynacat/config-editor.go new file mode 100644 index 00000000..358d765c --- /dev/null +++ b/internal/dynacat/config-editor.go @@ -0,0 +1,634 @@ +package dynacat + +import ( + "bytes" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + + "gopkg.in/yaml.v3" +) + +// The editor reads and writes the YAML source files directly using yaml.Node so +// comments, ordering and $include directives are preserved. Each page is either +// inline in the main file or pulled in via `- $include: file.yml`; edits are routed +// to the file that actually owns the page. + +type editorConfigView struct { + Pages []editorPageView `json:"pages"` +} + +type editorPageView struct { + Title string `json:"title"` + Slug string `json:"slug"` + Width string `json:"width"` + File string `json:"file"` + Writable bool `json:"writable"` + Columns []editorColumnView `json:"columns"` +} + +type editorColumnView struct { + Size string `json:"size"` + Widgets []editorWidgetView `json:"widgets"` +} + +type editorWidgetView struct { + Type string `json:"type"` + Title string `json:"title"` + Values map[string]string `json:"values"` + Widgets []editorWidgetView `json:"widgets,omitempty"` // nested children for container widgets +} + +type editorMutation struct { + Op string `json:"op"` + Page int `json:"page"` + Column int `json:"column"` // addColumn/removeColumn + Index int `json:"index"` // addColumn insert position + Path []int `json:"path"` // widget location: [column, ...nested, index] + ToPath []int `json:"toPath"` // moveWidget destination + Size string `json:"size"` + WidgetType string `json:"widgetType"` + Fields map[string]any `json:"fields"` // scalar inputs + RawFields map[string]string `json:"rawFields"` // yaml-kind inputs, parsed as YAML + Title string `json:"title"` // addPage + Layout []string `json:"layout"` // addPage column sizes +} + +type editorPermissionError struct{ path string } + +func (e *editorPermissionError) Error() string { + return fmt.Sprintf("cannot write %s: the config directory is read only or lacks write permission", filepath.Base(e.path)) +} + +// isWriteBlockedError reports errors meaning the file cannot be written: permission +// denied or a read-only filesystem (e.g. a read-only Docker bind mount). +func isWriteBlockedError(err error) bool { + return os.IsPermission(err) || errors.Is(err, syscall.EROFS) +} + +type editorValidationError struct{ err error } + +func (e *editorValidationError) Error() string { return e.err.Error() } + +// buildEditorConfigView builds the logical page/column/widget tree from the source files. +func (a *application) buildEditorConfigView() (editorConfigView, error) { + mainPath := a.configPath + mainDoc, err := loadYAMLDocument(mainPath) + if err != nil { + return editorConfigView{}, err + } + + pagesNode := getMappingValue(documentRoot(mainDoc), "pages") + if pagesNode == nil || pagesNode.Kind != yaml.SequenceNode { + return editorConfigView{}, fmt.Errorf("config has no pages") + } + + view := editorConfigView{} + for i := range pagesNode.Content { + path, _, pageNode, err := resolvePageNode(mainDoc, mainPath, i) + if err != nil { + return editorConfigView{}, err + } + pv := pageNodeToView(pageNode, path) + // Slug is derived from the name at load time and absent from the source YAML, + // so take the runtime slug/title (page order matches the source order). + if i < len(a.Config.Pages) { + pv.Slug = a.Config.Pages[i].Slug + pv.Title = a.Config.Pages[i].Title + } + view.Pages = append(view.Pages, pv) + } + + return view, nil +} + +func pageNodeToView(pageNode *yaml.Node, path string) editorPageView { + pv := editorPageView{ + Title: scalarValue(getMappingValue(pageNode, "name")), + Slug: scalarValue(getMappingValue(pageNode, "slug")), + Width: scalarValue(getMappingValue(pageNode, "width")), + File: filepath.Base(path), + Writable: pathWritable(path), + } + + columns := getMappingValue(pageNode, "columns") + if columns == nil { + return pv + } + + for _, col := range columns.Content { + cv := editorColumnView{Size: scalarValue(getMappingValue(col, "size"))} + if widgets := getMappingValue(col, "widgets"); widgets != nil { + for _, w := range widgets.Content { + cv.Widgets = append(cv.Widgets, widgetNodeToView(w)) + } + } + pv.Columns = append(pv.Columns, cv) + } + + return pv +} + +func widgetNodeToView(w *yaml.Node) editorWidgetView { + wv := editorWidgetView{Values: map[string]string{}} + for i := 0; i+1 < len(w.Content); i += 2 { + key, val := w.Content[i].Value, w.Content[i+1] + switch key { + case "type": + wv.Type = val.Value + case "widgets": + // Container children are surfaced as nested views, not a raw value. + if val.Kind == yaml.SequenceNode { + for _, child := range val.Content { + wv.Widgets = append(wv.Widgets, widgetNodeToView(child)) + } + } + case "title": + wv.Title = val.Value + wv.Values[key] = nodeToText(val) + default: + wv.Values[key] = nodeToText(val) + } + } + return wv +} + +// applyEditorMutation mutates the owning source file's node tree, then writes and +// validates. On validation failure the original bytes are restored. +func (a *application) applyEditorMutation(m editorMutation) error { + mainPath := a.configPath + mainDoc, err := loadYAMLDocument(mainPath) + if err != nil { + return err + } + + if m.Op == "addPage" { + if err := mutateAddPage(documentRoot(mainDoc), m); err != nil { + return err + } + return a.writeConfigCandidate(mainPath, marshalDocument(mainDoc)) + } + + if m.Op == "removePage" { + pages := getMappingValue(documentRoot(mainDoc), "pages") + if pages == nil || !validIndex(pages.Content, m.Page) { + return fmt.Errorf("page %d out of range", m.Page) + } + // Drops the page (or its $include line) from the main file; included files stay on disk. + pages.Content = removeNode(pages.Content, m.Page) + return a.writeConfigCandidate(mainPath, marshalDocument(mainDoc)) + } + + path, doc, pageNode, err := resolvePageNode(mainDoc, mainPath, m.Page) + if err != nil { + return err + } + + columns := getMappingValue(pageNode, "columns") + if columns == nil || columns.Kind != yaml.SequenceNode { + return fmt.Errorf("page %d has no columns", m.Page) + } + + if err := mutateColumns(columns, m); err != nil { + return err + } + + return a.writeConfigCandidate(path, marshalDocument(doc)) +} + +func mutateColumns(columns *yaml.Node, m editorMutation) error { + switch m.Op { + case "addColumn": + col := newMappingNode() + addPair(col, "size", scalarNode(orDefault(m.Size, "full"))) + addPair(col, "widgets", sequenceNode()) + columns.Content = insertNode(columns.Content, m.Index, col) + return nil + case "removeColumn": + if !validIndex(columns.Content, m.Column) { + return fmt.Errorf("column %d out of range", m.Column) + } + columns.Content = removeNode(columns.Content, m.Column) + return nil + } + + widgets, index, err := resolveWidgets(columns, m.Path) + if err != nil { + return err + } + + switch m.Op { + case "addWidget": + node, err := buildWidgetNode(m.WidgetType, m.Fields, m.RawFields) + if err != nil { + return err + } + widgets.Content = insertNode(widgets.Content, index, node) + case "editWidget": + if !validIndex(widgets.Content, index) { + return fmt.Errorf("widget index out of range") + } + if err := editWidgetNode(widgets.Content[index], m.Fields, m.RawFields); err != nil { + return err + } + case "removeWidget": + if !validIndex(widgets.Content, index) { + return fmt.Errorf("widget index out of range") + } + widgets.Content = removeNode(widgets.Content, index) + case "moveWidget": + if isIntPrefix(m.Path, m.ToPath) { + return fmt.Errorf("cannot move a container into itself") + } + if !validIndex(widgets.Content, index) { + return fmt.Errorf("widget index out of range") + } + node := widgets.Content[index] + dstWidgets, dstIndex, err := resolveWidgets(columns, m.ToPath) + if err != nil { + return err + } + widgets.Content = removeNode(widgets.Content, index) + // Same parent: the target index was computed before removal. + if dstWidgets == widgets && dstIndex > index { + dstIndex-- + } + dstWidgets.Content = insertNode(dstWidgets.Content, dstIndex, node) + default: + return fmt.Errorf("unknown op: %s", m.Op) + } + return nil +} + +// resolveWidgets walks a widget path ([column, ...nested container indices, target +// index]) to the widgets sequence that owns the target and its index within it. +func resolveWidgets(columns *yaml.Node, path []int) (*yaml.Node, int, error) { + if len(path) < 2 { + return nil, 0, fmt.Errorf("invalid widget path") + } + + widgets := columnWidgets(columns, path[0]) + if widgets == nil { + return nil, 0, fmt.Errorf("column %d out of range", path[0]) + } + + for _, wi := range path[1 : len(path)-1] { + if !validIndex(widgets.Content, wi) { + return nil, 0, fmt.Errorf("widget %d out of range", wi) + } + container := widgets.Content[wi] + inner := getMappingValue(container, "widgets") + if inner == nil { + inner = sequenceNode() + addPair(container, "widgets", inner) + } + widgets = inner + } + + return widgets, path[len(path)-1], nil +} + +// isIntPrefix reports whether prefix is a leading slice of full (used to block +// moving a container into its own subtree). +func isIntPrefix(prefix, full []int) bool { + if len(prefix) > len(full) { + return false + } + for i := range prefix { + if prefix[i] != full[i] { + return false + } + } + return true +} + +func mutateAddPage(root *yaml.Node, m editorMutation) error { + pages := getMappingValue(root, "pages") + if pages == nil { + pages = sequenceNode() + addPair(root, "pages", pages) + } + + page := newMappingNode() + addPair(page, "name", scalarNode(orDefault(m.Title, "New Page"))) + + columns := sequenceNode() + layout := m.Layout + if len(layout) == 0 { + layout = []string{"full"} + } + for _, size := range layout { + col := newMappingNode() + addPair(col, "size", scalarNode(size)) + addPair(col, "widgets", sequenceNode()) + columns.Content = append(columns.Content, col) + } + addPair(page, "columns", columns) + + pages.Content = append(pages.Content, page) + return nil +} + +func (a *application) writeConfigCandidate(path string, candidate []byte) error { + perm := os.FileMode(0o644) + if info, err := os.Stat(path); err == nil { + perm = info.Mode() + } + + original, _ := os.ReadFile(path) + + if err := os.WriteFile(path, candidate, perm); err != nil { + if isWriteBlockedError(err) { + return &editorPermissionError{path} + } + return err + } + + merged, _, err := parseYAMLIncludes(a.configPath) + if err == nil { + _, err = newConfigFromYAML(merged) + } + if err != nil { + os.WriteFile(path, original, perm) // roll back invalid edit + return &editorValidationError{err} + } + + return nil +} + +// resolvePageNode returns the file, document and page mapping node owning a page. +func resolvePageNode(mainDoc *yaml.Node, mainPath string, idx int) (string, *yaml.Node, *yaml.Node, error) { + pages := getMappingValue(documentRoot(mainDoc), "pages") + if pages == nil || pages.Kind != yaml.SequenceNode || !validIndex(pages.Content, idx) { + return "", nil, nil, fmt.Errorf("page %d out of range", idx) + } + + item := pages.Content[idx] + if inc := includeTarget(item); inc != "" { + path := inc + if !filepath.IsAbs(path) { + path = filepath.Join(filepath.Dir(mainPath), path) + } + doc, err := loadYAMLDocument(path) + if err != nil { + return "", nil, nil, err + } + return path, doc, firstMapping(documentRoot(doc)), nil + } + + return mainPath, mainDoc, item, nil +} + +// +// yaml.Node helpers +// + +func loadYAMLDocument(path string) (*yaml.Node, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc yaml.Node + if err := yaml.Unmarshal(b, &doc); err != nil { + return nil, err + } + return &doc, nil +} + +func documentRoot(doc *yaml.Node) *yaml.Node { + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + return doc.Content[0] + } + return doc +} + +// firstMapping returns the page mapping, whether the file root is that mapping or a +// single-item sequence (the `- name: ...` form used by included page files). +func firstMapping(root *yaml.Node) *yaml.Node { + if root.Kind == yaml.SequenceNode && len(root.Content) > 0 { + return root.Content[0] + } + return root +} + +func includeTarget(item *yaml.Node) string { + if item.Kind != yaml.MappingNode { + return "" + } + for i := 0; i+1 < len(item.Content); i += 2 { + if k := item.Content[i].Value; k == "$include" || k == "!include" { + return strings.TrimSpace(item.Content[i+1].Value) + } + } + return "" +} + +func getMappingValue(m *yaml.Node, key string) *yaml.Node { + if m == nil || m.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} + +func setMappingKey(m *yaml.Node, key string, value *yaml.Node) { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + m.Content[i+1] = value + return + } + } + addPair(m, key, value) +} + +func removeMappingKey(m *yaml.Node, key string) { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + m.Content = append(m.Content[:i], m.Content[i+2:]...) + return + } + } +} + +func columnWidgets(columns *yaml.Node, index int) *yaml.Node { + if !validIndex(columns.Content, index) { + return nil + } + widgets := getMappingValue(columns.Content[index], "widgets") + if widgets == nil { + widgets = sequenceNode() + addPair(columns.Content[index], "widgets", widgets) + } + return widgets +} + +func buildWidgetNode(widgetType string, fields map[string]any, rawFields map[string]string) (*yaml.Node, error) { + if widgetType == "" { + return nil, fmt.Errorf("widget type is required") + } + node := newMappingNode() + addPair(node, "type", scalarNode(widgetType)) + return node, applyFieldsToNode(node, fields, rawFields) +} + +func editWidgetNode(node *yaml.Node, fields map[string]any, rawFields map[string]string) error { + return applyFieldsToNode(node, fields, rawFields) +} + +// applyFieldsToNode upserts scalar fields and parsed yaml fields; empty values drop the key. +func applyFieldsToNode(node *yaml.Node, fields map[string]any, rawFields map[string]string) error { + for _, k := range sortedKeys(fields) { + if isEmptyValue(fields[k]) { + removeMappingKey(node, k) + continue + } + setMappingKey(node, k, valueNode(fields[k])) + } + + for _, k := range sortedStringKeys(rawFields) { + if strings.TrimSpace(rawFields[k]) == "" { + removeMappingKey(node, k) + continue + } + parsed, err := parseYAMLValue(rawFields[k]) + if err != nil { + return fmt.Errorf("field %s: %w", k, err) + } + setMappingKey(node, k, parsed) + } + + return nil +} + +func parseYAMLValue(text string) (*yaml.Node, error) { + var doc yaml.Node + if err := yaml.Unmarshal([]byte(text), &doc); err != nil { + return nil, err + } + if len(doc.Content) == 0 { + return scalarNode(""), nil + } + return doc.Content[0], nil +} + +func marshalDocument(doc *yaml.Node) []byte { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + enc.Encode(doc) + enc.Close() + return buf.Bytes() +} + +func nodeToText(n *yaml.Node) string { + if n.Kind == yaml.ScalarNode { + return n.Value + } + return strings.TrimRight(string(marshalDocument(n)), "\n") +} + +func scalarValue(n *yaml.Node) string { + if n == nil { + return "" + } + return n.Value +} + +func newMappingNode() *yaml.Node { return &yaml.Node{Kind: yaml.MappingNode} } + +func sequenceNode() *yaml.Node { return &yaml.Node{Kind: yaml.SequenceNode} } + +func scalarNode(v string) *yaml.Node { return &yaml.Node{Kind: yaml.ScalarNode, Value: v} } + +func addPair(m *yaml.Node, key string, value *yaml.Node) { + m.Content = append(m.Content, scalarNode(key), value) +} + +func valueNode(v any) *yaml.Node { + switch x := v.(type) { + case bool: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(x)} + case float64: + if x == math.Trunc(x) { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: strconv.FormatInt(int64(x), 10)} + } + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!float", Value: strconv.FormatFloat(x, 'f', -1, 64)} + case string: + return scalarNode(x) + default: + return scalarNode(fmt.Sprintf("%v", v)) + } +} + +func isEmptyValue(v any) bool { + switch x := v.(type) { + case nil: + return true + case string: + return strings.TrimSpace(x) == "" + default: + return false + } +} + +func insertNode(nodes []*yaml.Node, index int, node *yaml.Node) []*yaml.Node { + if index < 0 || index > len(nodes) { + return append(nodes, node) + } + nodes = append(nodes, nil) + copy(nodes[index+1:], nodes[index:]) + nodes[index] = node + return nodes +} + +func removeNode(nodes []*yaml.Node, index int) []*yaml.Node { + return append(nodes[:index], nodes[index+1:]...) +} + +func validIndex(nodes []*yaml.Node, index int) bool { + return index >= 0 && index < len(nodes) +} + +func pathWritable(path string) bool { + f, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + return false + } + f.Close() + return true +} + +func orDefault(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortedStringKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/dynacat/dynacat.go b/internal/dynacat/dynacat.go index 369f744e..82eea4d2 100644 --- a/internal/dynacat/dynacat.go +++ b/internal/dynacat/dynacat.go @@ -40,9 +40,10 @@ type imageProxyInfo struct { } type application struct { - Version string - CreatedAt time.Time - Config config + Version string + CreatedAt time.Time + Config config + configPath string // main config file path, used by the editor to write back parsedManifest []byte @@ -932,6 +933,11 @@ func (a *application) server() (func() error, func() error) { mux.HandleFunc("PUT /api/todo/{listID}", a.handleTodoSave) } + mux.HandleFunc("GET /api/editor/schema", a.handleEditorSchema) + mux.HandleFunc("GET /api/editor/status", a.handleEditorStatus) + mux.HandleFunc("GET /api/editor/config", a.handleEditorConfigLoad) + mux.HandleFunc("POST /api/editor/config", a.handleEditorConfigSave) + mux.Handle( fmt.Sprintf("GET /static/%s/{path...}", getStaticFSHash()), http.StripPrefix( diff --git a/internal/dynacat/editor-http.go b/internal/dynacat/editor-http.go new file mode 100644 index 00000000..69a1c573 --- /dev/null +++ b/internal/dynacat/editor-http.go @@ -0,0 +1,84 @@ +package dynacat + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" +) + +const editorMaxBodyBytes = 1 << 20 // 1 MiB + +func (a *application) handleEditorSchema(w http.ResponseWriter, r *http.Request) { + if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { + return + } + writeJSON(w, http.StatusOK, allWidgetSchemas()) +} + +// handleEditorStatus returns a generation that changes whenever the config hot-reloads, +// letting the editor wait for the server to pick up a page add/remove before navigating. +func (a *application) handleEditorStatus(w http.ResponseWriter, r *http.Request) { + if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { + return + } + writeJSON(w, http.StatusOK, map[string]int64{"generation": a.CreatedAt.UnixNano()}) +} + +func (a *application) handleEditorConfigLoad(w http.ResponseWriter, r *http.Request) { + if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { + return + } + + view, err := a.buildEditorConfigView() + if err != nil { + slog.Error("Editor config load failed", "error", err) + writeJSONError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, view) +} + +func (a *application) handleEditorConfigSave(w http.ResponseWriter, r *http.Request) { + if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { + return + } + + r.Body = http.MaxBytesReader(w, r.Body, editorMaxBodyBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid body") + return + } + + var mutation editorMutation + if err := json.Unmarshal(body, &mutation); err != nil { + writeJSONError(w, http.StatusBadRequest, "invalid JSON") + return + } + + if err := a.applyEditorMutation(mutation); err != nil { + switch err.(type) { + case *editorPermissionError: + writeJSONError(w, http.StatusForbidden, err.Error()) + case *editorValidationError: + writeJSONError(w, http.StatusUnprocessableEntity, err.Error()) + default: + slog.Error("Editor mutation failed", "op", mutation.Op, "error", err) + writeJSONError(w, http.StatusBadRequest, err.Error()) + } + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(body) +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/dynacat/main.go b/internal/dynacat/main.go index f4c676f0..9f64c638 100644 --- a/internal/dynacat/main.go +++ b/internal/dynacat/main.go @@ -158,6 +158,7 @@ func serveApp(configPath string) error { return } + app.configPath = configPath if !hadValidConfigOnStartup { hadValidConfigOnStartup = true @@ -203,6 +204,7 @@ func serveApp(configPath string) error { if err != nil { return fmt.Errorf("creating application: %w", err) } + app.configPath = configPath startServer, _ := app.server() if err := startServer(); err != nil { diff --git a/internal/dynacat/static/css/editor.css b/internal/dynacat/static/css/editor.css new file mode 100644 index 00000000..f5a3f974 --- /dev/null +++ b/internal/dynacat/static/css/editor.css @@ -0,0 +1,629 @@ +/* Interactive page editor UI. Only active while document.body has .editing. + Kept intentionally subtle: muted borders and text, accents only on hover. */ + +/* The visual editor is desktop only */ +@media (max-width: 768px) { + #editor-toggle { + display: none; + } +} + +.editor-toggle-icon { + width: 1.8rem; + height: 1.8rem; + stroke: var(--color-text-subdue); + transition: stroke 0.2s; +} + +#editor-toggle:hover .editor-toggle-icon { + stroke: var(--color-text-highlight); +} + +.editor-toggle-active .editor-toggle-icon { + stroke: var(--color-primary); +} + +/* Palette dock */ +.editor-palette { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 40; + display: flex; + gap: 6px; + overflow-x: auto; + padding: 10px 16px; + background: var(--color-popover-background); + border-top: 1px solid var(--color-separator); + scrollbar-width: thin; +} + +.editor-palette::-webkit-scrollbar { + height: 6px; +} + +.editor-palette::-webkit-scrollbar-thumb { + background: var(--color-separator); + border-radius: 3px; +} + +.editor-palette-item { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + width: 76px; + padding: 8px 6px; + border-radius: var(--border-radius); + cursor: grab; + user-select: none; + opacity: 0.75; + transition: opacity 0.15s, background 0.15s; +} + +.editor-palette-item:hover { + opacity: 1; + background: var(--color-widget-background); +} + +.editor-palette-icon { + width: 22px; + height: 22px; + background: var(--color-text-subdue); + mask: var(--icon-url) center / contain no-repeat; + -webkit-mask: var(--icon-url) center / contain no-repeat; +} + +.editor-palette-name { + width: 100%; + font-size: 1.05rem; + line-height: 1.2; + text-align: center; + color: var(--color-text-subdue); + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Leave room for the dock so it never hides page content */ +body.editing { + padding-bottom: 92px; +} + +/* Cards are spaced by the flex gap in edit mode, not the app's inter-widget margin */ +body.editing .widget + .widget { + margin-top: 0; +} + +/* Columns become filled container areas holding cards while editing */ +body.editing .page-column { + display: flex; + flex-direction: column; + gap: 8px; + min-height: 120px; + align-self: flex-start; + position: relative; + padding: 8px; + border-radius: var(--border-radius); + background: var(--color-widget-background); +} + +body.editing .page-column:not(:has(> .widget)) { + align-items: center; + justify-content: center; +} + +body.editing .page-column:not(:has(> .widget))::after { + content: "Drop widgets here"; + color: var(--color-text-subdue); + font-size: 1.2rem; + opacity: 0.5; + pointer-events: none; +} + +/* Widget cards: a compact icon + title box, no live content */ +.editor-widget { + display: flex; + flex-direction: column; + padding: 12px 14px; + border-radius: var(--border-radius); + background: var(--color-widget-background-highlight); + border: 1px solid var(--color-separator); + cursor: grab; + transition: border-color 0.15s; +} + +.editor-widget:hover { + border-color: var(--color-text-subdue); +} + +.editor-widget-header { + display: flex; + align-items: center; + gap: 10px; +} + +/* Container widgets (group / split-column): nested drop zone for child cards */ +.editor-nested { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 10px; + padding: 8px; + min-height: 46px; + border-radius: var(--border-radius); + background: var(--color-widget-background); +} + +.editor-nested:not(:has(> .widget)) { + align-items: center; + justify-content: center; +} + +.editor-nested:not(:has(> .widget))::after { + content: "Drop widgets here"; + color: var(--color-text-subdue); + font-size: 1.1rem; + opacity: 0.5; + pointer-events: none; +} + +/* split-column: lay children out side by side so the split is visible */ +.editor-nested-split:has(> .widget) { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + align-content: flex-start; +} + +.editor-nested-split > .editor-widget { + flex: 1 1 100px; + min-width: 100px; + align-self: flex-start; +} + +.editor-widget.editor-dragging { + opacity: 0.4; +} + +.editor-widget-card { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.editor-widget-icon { + flex-shrink: 0; + width: 22px; + height: 22px; + background: var(--color-text-subdue); + mask: var(--icon-url) center / contain no-repeat; + -webkit-mask: var(--icon-url) center / contain no-repeat; +} + +.editor-widget-title { + font-size: 1.3rem; + color: var(--color-text-base); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-widget-tools { + margin-left: auto; + display: flex; + gap: 2px; + opacity: 0; + transition: opacity 0.15s; +} + +.editor-widget:hover .editor-widget-tools { + opacity: 1; +} + +.editor-tool { + display: flex; + padding: 3px; + border: none; + border-radius: var(--border-radius); + background: transparent; + color: var(--color-text-subdue); + cursor: pointer; +} + +.editor-tool:hover { + color: var(--color-text-highlight); +} + +.editor-tool svg { + width: 15px; + height: 15px; +} + +/* Column remove control: sits in the column's background area, below the cards */ +.editor-column-tools { + display: flex; + justify-content: center; + padding-top: 8px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.editor-column-tools:hover { + opacity: 1; +} + +.editor-column-remove { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: none; + border-radius: var(--border-radius); + background: transparent; + color: var(--color-text-subdue); + font: inherit; + font-size: 1.1rem; + cursor: pointer; +} + +.editor-column-remove:hover { + color: var(--color-negative); +} + +.editor-icon-inline { + display: flex; +} + +.editor-icon-inline svg { + width: 14px; + height: 14px; +} + +/* Drop marker: absolute overlay so it does not reflow the widgets */ +.editor-drop-marker { + position: absolute; + left: 8px; + right: 8px; + height: 2px; + border-radius: 2px; + background: var(--color-primary); + opacity: 0.7; + pointer-events: none; +} + +/* Vertical variant for horizontal (split-column) drop zones */ +.editor-drop-marker-v { + left: auto; + right: auto; + top: 8px; + bottom: 8px; + width: 2px; + height: auto; +} + +/* Inter-column add line: hidden until hover */ +.editor-add-column { + position: relative; + flex: 0 0 14px; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.editor-add-column::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--color-primary); + opacity: 0; + transition: opacity 0.15s ease; +} + +.editor-add-column-plus { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-background); + transform: scale(0); + transition: transform 0.15s ease; +} + +.editor-add-column-plus svg { + width: 13px; + height: 13px; +} + +.editor-add-column:hover::before { + opacity: 0.5; +} + +.editor-add-column:hover .editor-add-column-plus { + transform: scale(1); +} + +/* Add / remove page buttons */ +.editor-add-page, +.editor-remove-page { + cursor: pointer; + color: var(--color-text-subdue); + opacity: 0.6; + font-size: 0.9em; + transition: opacity 0.2s, color 0.2s; +} + +.editor-add-page:hover, +.editor-remove-page:hover { + opacity: 1; +} + +.editor-add-page:hover { + color: var(--color-text-highlight); +} + +.editor-remove-page:hover { + color: var(--color-negative); +} + +/* Modal */ +.editor-modal-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(0, 0, 0, 0.5); +} + +.editor-modal { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 520px; + max-height: 85vh; + overflow-y: auto; + padding: 20px; + border-radius: var(--border-radius); + background: var(--color-widget-background); + border: 1px solid var(--color-separator); +} + +.editor-modal-title { + font-size: 1.6rem; + color: var(--color-text-highlight); +} + +.editor-modal-body { + display: flex; + flex-direction: column; + gap: 14px; +} + +.editor-fields { + display: flex; + flex-direction: column; + gap: 12px; +} + +.editor-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.editor-field-label { + font-size: 1.2rem; + color: var(--color-text-subdue); +} + +.editor-required { + margin-left: 3px; + color: var(--color-primary); + font-weight: bold; +} + +/* Inputs: a clearly visible raised surface, lighter than the modal. + Scoped to .editor-modal so it outweighs the app's input[type="text"] reset. */ +.editor-modal .editor-input { + width: 100%; + padding: 8px 10px; + border-radius: var(--border-radius); + border: 1px solid var(--color-separator); + background: var(--color-widget-background-highlight); + color: var(--color-text-base); + font: inherit; + color-scheme: dark; +} + +.editor-modal .editor-input:focus { + outline: none; + border-color: var(--color-primary); +} + +/* Checkbox field: input and label on one row */ +.editor-check { + flex-direction: row; + align-items: center; + gap: 10px; + cursor: pointer; +} + +.editor-check-label { + font-size: 1.3rem; + color: var(--color-text-base); +} + +.editor-modal .editor-check input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + position: relative; + flex-shrink: 0; + width: 18px; + height: 18px; + margin: 0; + border: 1.5px solid var(--color-text-subdue); + border-radius: 5px; + background: transparent; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.editor-modal .editor-check input[type="checkbox"]:hover { + border-color: var(--color-primary); +} + +.editor-modal .editor-check input[type="checkbox"]:checked { + background: var(--color-primary); + border-color: var(--color-primary); +} + +.editor-modal .editor-check input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + left: 6px; + top: 2px; + width: 4px; + height: 9px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +select.editor-input { + appearance: none; + cursor: pointer; +} + +.editor-textarea { + min-height: 90px; + resize: vertical; + font-family: monospace; +} + +.editor-icon-preview { + width: 26px; + height: 26px; + margin-top: 4px; +} + +.editor-collapsible summary { + cursor: pointer; + padding: 6px 0; + color: var(--color-text-subdue); + font-size: 1.2rem; +} + +.editor-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.editor-btn { + padding: 8px 16px; + border: 1px solid var(--color-separator); + border-radius: var(--border-radius); + background: transparent; + color: var(--color-text-base); + cursor: pointer; + font: inherit; +} + +.editor-btn:hover { + border-color: var(--color-text-subdue); +} + +.editor-btn-primary { + background: var(--color-primary); + color: var(--color-background); + border-color: var(--color-primary); +} + +.editor-btn-danger { + display: flex; + align-items: center; + gap: 6px; + color: var(--color-negative); + border-color: var(--color-negative); +} + +.editor-btn-danger:hover { + background: var(--color-negative); + color: var(--color-background); + border-color: var(--color-negative); +} + +.editor-confirm-message { + font-size: 1.35rem; + color: var(--color-text-base); +} + +/* Layout picker */ +.editor-layout-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.editor-layout-option { + display: flex; + gap: 3px; + height: 54px; + padding: 6px; + border-radius: var(--border-radius); + border: 2px solid var(--color-separator); + cursor: pointer; +} + +.editor-layout-option.selected { + border-color: var(--color-primary); +} + +.editor-layout-cell { + background: var(--color-text-subdue); + border-radius: 2px; +} + +.editor-layout-cell-small { + flex: 0 0 20%; +} + +.editor-layout-cell-full { + flex: 1; +} + +/* Toasts */ +.editor-toasts { + position: fixed; + top: 16px; + right: 16px; + z-index: 70; + display: flex; + flex-direction: column; + gap: 8px; +} + +.editor-toast { + padding: 9px 15px; + font-size: 1.2rem; + border-radius: var(--border-radius); + background: var(--color-popover-background); + border: 1px solid var(--color-separator); +} diff --git a/internal/dynacat/static/css/main.css b/internal/dynacat/static/css/main.css index 4f79d07a..5f12880c 100644 --- a/internal/dynacat/static/css/main.css +++ b/internal/dynacat/static/css/main.css @@ -63,4 +63,5 @@ @import "widgets.css"; @import "popover.css"; @import "utils.css"; +@import "editor.css"; @import "mobile.css"; diff --git a/internal/dynacat/static/css/widget-search.css b/internal/dynacat/static/css/widget-search.css index a065e43e..afa4b54d 100644 --- a/internal/dynacat/static/css/widget-search.css +++ b/internal/dynacat/static/css/widget-search.css @@ -110,7 +110,7 @@ border-top-width: 0; } -/* Dropdown base — positioned + sized by JS */ +/* Dropdown base - positioned + sized by JS */ .search-autocomplete { position: fixed; z-index: 200; diff --git a/internal/dynacat/static/js/docker-controller.js b/internal/dynacat/static/js/docker-controller.js index 366bbab9..94374c80 100644 --- a/internal/dynacat/static/js/docker-controller.js +++ b/internal/dynacat/static/js/docker-controller.js @@ -263,7 +263,7 @@ const key = widgetId + ':' + type + ':' + id; if (confirmingItems.has(key)) { - // Second click — perform the removal + // Second click - perform the removal const state = confirmingItems.get(key); clearTimeout(state.timeoutId); confirmingItems.delete(key); diff --git a/internal/dynacat/static/js/editor.js b/internal/dynacat/static/js/editor.js new file mode 100644 index 00000000..793dacbf --- /dev/null +++ b/internal/dynacat/static/js/editor.js @@ -0,0 +1,739 @@ +// Interactive page editor: palette, drag and drop, option modals and layout tools. +// Loaded lazily on demand so it adds no overhead to normal page rendering. +// +// In edit mode the page columns are re-rendered from the config as simple icon+title +// cards (not live widgets), so editing never fights expanded widget content. Each save +// re-fetches the config and re-renders in place; the page only reloads when editing ends. + +const PD = typeof pageData !== "undefined" ? pageData : window.pageData; +const API = `${PD.baseURL}/api/editor`; + +const state = { + active: false, + schemas: [], + schemaByType: {}, + config: null, + pageIndex: -1, + drag: null, // {type} for a palette item, {fromPath} for a placed card +}; + +export async function toggleEditor() { + if (state.active) return exitEditor(); + await enterEditor(); +} + +async function enterEditor() { + try { + const [schemas, config] = await Promise.all([apiGet("/schema"), apiGet("/config")]); + state.schemas = schemas; + state.schemaByType = Object.fromEntries(schemas.map((s) => [s.type, s])); + state.config = config; + state.pageIndex = config.pages.findIndex((p) => p.slug === PD.slug); + } catch (err) { + toast(err.status === 401 ? "Log in to edit this page" : "Could not load editor", "negative"); + return; + } + + if (state.pageIndex < 0) { + toast("This page is not editable here", "negative"); + return; + } + + state.active = true; + sessionStorage.setItem("dynacat-editing", "1"); + document.body.classList.add("editing"); + document.getElementById("editor-toggle")?.classList.add("editor-toggle-active"); + + buildPalette(); + buildAddPageButton(); + + // page.js injects content only once the cache finishes building (marking #page + // content-ready), and may rebuild it. Wait for ready, then guard against rebuilds. + const ready = await waitForPageReady(); + if (!ready) { + toast("Page content is still loading, try again in a moment", "negative"); + return; + } + renderCanvas(); + guardCanvas(); +} + +// waitForPageReady resolves once page.js marks the page content-ready, or null on timeout. +function waitForPageReady(timeout = 30000) { + const page = document.getElementById("page"); + if (page && page.classList.contains("content-ready")) return Promise.resolve(page); + + return new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (page && page.classList.contains("content-ready")) { + observer.disconnect(); + resolve(page); + } + }); + observer.observe(page || document.body, { attributes: true, attributeFilter: ["class"], childList: !page, subtree: !page }); + setTimeout(() => { + observer.disconnect(); + resolve(page && page.classList.contains("content-ready") ? page : null); + }, timeout); + }); +} + +// guardCanvas re-renders our cards if page.js later replaces the whole content. +function guardCanvas() { + const content = document.getElementById("page-content"); + if (!content) return; + new MutationObserver(() => { + const columns = document.querySelector(".page-columns"); + if (state.active && columns && !columns.dataset.editorCanvas) renderCanvas(); + }).observe(content, { childList: true, subtree: true }); +} + +function exitEditor() { + // Reload to restore the live widgets we replaced with editor cards. + sessionStorage.removeItem("dynacat-editing"); + location.reload(); +} + +// +// API +// + +async function apiGet(path) { + const res = await fetch(API + path); + if (!res.ok) throw { status: res.status }; + return res.json(); +} + +// commit posts a mutation; returns true on success, shows an error toast otherwise. +async function commit(mutation) { + const res = await fetch(`${API}/config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(mutation), + }); + if (res.status === 204) return true; + const body = await res.json().catch(() => ({})); + toast(body.error || "Save failed", "negative"); + return false; +} + +// save commits then re-fetches the config and re-renders the canvas in place. +async function save(mutation) { + if (!(await commit(mutation))) return; + state.config = await apiGet("/config"); + renderCanvas(); +} + +// commitAndNavigate commits a page-level change, waits for the server to hot-reload +// (so the navigation reflects it), then runs navigate(). +async function commitAndNavigate(mutation, navigate) { + const before = await serverGeneration(); + if (!(await commit(mutation))) return; + await waitForServerReload(before); + navigate(); +} + +async function serverGeneration() { + try { + return (await apiGet("/status")).generation; + } catch { + return 0; + } +} + +function waitForServerReload(before, timeout = 6000) { + const started = Date.now(); + return new Promise((resolve) => { + const tick = async () => { + if ((await serverGeneration()) !== before || Date.now() - started > timeout) return resolve(); + setTimeout(tick, 300); + }; + setTimeout(tick, 300); + }); +} + +// +// Palette dock +// + +function buildPalette() { + const dock = div("editor-ui editor-palette"); + // Let the mouse wheel scroll the horizontal list instead of needing the scrollbar. + dock.addEventListener("wheel", (e) => { + if (!e.deltaY) return; + e.preventDefault(); + dock.scrollLeft += e.deltaY; + }, { passive: false }); + for (const schema of state.schemas) { + if (schema.hidden) continue; + const item = div("editor-palette-item"); + item.draggable = true; + item.title = schema.label; + if (schema.icon) item.append(maskIcon("editor-palette-icon", schema.icon)); + item.append(div("editor-palette-name", schema.label)); + item.addEventListener("dragstart", () => (state.drag = { type: schema.type })); + dock.append(item); + } + document.body.append(dock); +} + +// +// Canvas: columns and widget cards rendered from the config +// + +// slim pages allow 2 columns, standard ones 3. +function maxColumnsFor(page) { + return page.width === "slim" ? 2 : 3; +} + +function renderCanvas() { + const container = document.querySelector(".page-columns"); + if (!container) return; + container.innerHTML = ""; + + const page = state.config.pages[state.pageIndex]; + const canAddColumn = page.columns.length < maxColumnsFor(page); + + page.columns.forEach((col, colIndex) => { + if (canAddColumn) container.append(addColumnLine(colIndex)); + const colEl = div(`page-column page-column-${col.size}`); + setupDropTarget(colEl, [colIndex]); + (col.widgets || []).forEach((w, i) => colEl.append(buildWidgetCard(w, [colIndex, i]))); + colEl.append(buildColumnTools(colIndex)); + container.append(colEl); + }); + if (canAddColumn) container.append(addColumnLine(page.columns.length)); + container.dataset.editorCanvas = "1"; // marks this as our render so the guard skips it +} + +function buildColumnTools(colIndex) { + const tools = div("editor-ui editor-column-tools"); + const btn = button("Remove column", "editor-column-remove"); + btn.prepend(iconSpan(iconTrash)); + btn.addEventListener("click", () => removeColumn(colIndex)); + tools.append(btn); + return tools; +} + +function iconSpan(svg) { + const span = document.createElement("span"); + span.className = "editor-icon-inline"; + span.innerHTML = svg; + return span; +} + +function removeColumn(colIndex) { + confirmAction("Remove this column and its widgets?", () => + save({ op: "removeColumn", page: state.pageIndex, column: colIndex }) + ); +} + +const CONTAINER_TYPES = ["group", "split-column"]; + +function buildWidgetCard(w, path) { + const schema = state.schemaByType[w.type]; + const card = div("widget editor-widget"); + card.draggable = true; + card.addEventListener("dragstart", (e) => { + e.stopPropagation(); + state.drag = { fromPath: path }; + card.classList.add("editor-dragging"); + }); + card.addEventListener("dragend", () => card.classList.remove("editor-dragging")); + + const header = div("editor-widget-header"); + const body = div("editor-widget-card"); + if (schema?.icon) body.append(maskIcon("editor-widget-icon", schema.icon)); + body.append(div("editor-widget-title", w.title || schema?.label || w.type)); + header.append(body); + + const tools = div("editor-ui editor-widget-tools"); + tools.append(toolButton("Edit", iconPencil, () => openWidgetModal(path))); + tools.append(toolButton("Delete", iconTrash, () => removeWidget(path))); + header.append(tools); + card.append(header); + + // Container widgets get a nested drop zone showing their child cards. + if (CONTAINER_TYPES.includes(w.type)) { + card.classList.add("editor-container"); + const horizontal = w.type === "split-column"; // split lays children out in columns + const nested = div(`editor-nested${horizontal ? " editor-nested-split" : ""}`); + setupDropTarget(nested, path, horizontal); + (w.widgets || []).forEach((child, j) => nested.append(buildWidgetCard(child, [...path, j]))); + card.append(nested); + } + + return card; +} + +function directWidgets(column) { + return Array.from(column.children).filter((el) => el.classList.contains("widget")); +} + +function setupDropTarget(element, basePath, horizontal = false) { + const marker = div(`editor-ui editor-drop-marker${horizontal ? " editor-drop-marker-v" : ""}`); + const pointer = (e) => (horizontal ? e.clientX : e.clientY); + + element.addEventListener("dragover", (e) => { + e.preventDefault(); + e.stopPropagation(); // nested targets take precedence over the parent column + positionMarker(element, marker, dropIndex(element, pointer(e), horizontal), horizontal); + }); + element.addEventListener("dragleave", (e) => { + if (!element.contains(e.relatedTarget)) marker.remove(); + }); + element.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + const index = dropIndex(element, pointer(e), horizontal); + marker.remove(); + handleDrop([...basePath, index]); + }); +} + +// positionMarker places the insertion line as an absolute overlay so it never +// reflows the widgets (which would make the drop position jitter). +function positionMarker(element, marker, index, horizontal) { + // Only one insertion line at a time (a parent's marker isn't cleared when the + // pointer moves into a nested drop zone, since dragleave doesn't fire). + document.querySelectorAll(".editor-drop-marker").forEach((m) => m !== marker && m.remove()); + + const widgets = directWidgets(element); + const edge = (el) => (horizontal ? el.offsetLeft : el.offsetTop); + const size = (el) => (horizontal ? el.offsetWidth : el.offsetHeight); + + let pos; + if (widgets.length === 0) { + pos = 8; + } else if (index < widgets.length) { + pos = edge(widgets[index]) - 4; + } else { + const last = widgets[widgets.length - 1]; + pos = edge(last) + size(last) + 3; + } + + if (horizontal) { + marker.style.left = `${pos}px`; + } else { + marker.style.top = `${pos}px`; + } + if (!marker.isConnected) element.appendChild(marker); +} + +// dropIndex returns the insertion position based on the pointer against card midpoints. +function dropIndex(element, pointer, horizontal) { + const widgets = directWidgets(element); + for (let i = 0; i < widgets.length; i++) { + const box = widgets[i].getBoundingClientRect(); + const mid = horizontal ? box.left + box.width / 2 : box.top + box.height / 2; + if (pointer < mid) return i; + } + return widgets.length; +} + +function handleDrop(path) { + const drag = state.drag; + state.drag = null; + if (!drag) return; + + if (drag.type) { + openNewWidgetModal(drag.type, path); + } else { + save({ op: "moveWidget", page: state.pageIndex, path: drag.fromPath, toPath: path }); + } +} + +function removeWidget(path) { + save({ op: "removeWidget", page: state.pageIndex, path }); +} + +// widgetAtPath walks the config tree (through nested containers) to a widget. +function widgetAtPath(path) { + const col = state.config.pages[state.pageIndex].columns[path[0]]; + let widgets = (col && col.widgets) || []; + let w = null; + for (let i = 1; i < path.length; i++) { + w = widgets[path[i]]; + if (!w) return null; + widgets = w.widgets || []; + } + return w; +} + +// +// Inter-column add lines +// + +function addColumnLine(insertIndex) { + const line = div("editor-ui editor-add-column"); + line.title = "Add column here"; + const plus = div("editor-add-column-plus"); + plus.innerHTML = iconPlus; + line.append(plus); + line.addEventListener("click", () => addColumn(insertIndex)); + return line; +} + +function addColumn(insertIndex) { + const page = state.config.pages[state.pageIndex]; + const max = maxColumnsFor(page); + if (page.columns.length >= max) { + toast(`This layout is full (max ${max} columns)`, "negative"); + return; + } + // Keep the layout valid: at most 2 full columns, so fall back to small. + const fullCount = page.columns.filter((c) => c.size === "full").length; + const size = fullCount < 2 ? "full" : "small"; + save({ op: "addColumn", page: state.pageIndex, index: insertIndex, size }); +} + +// +// Add page +// + +const PAGE_LAYOUTS = [ + ["full"], + ["small", "full"], + ["full", "small"], + ["full", "full"], + ["small", "full", "small"], + ["full", "small", "full"], +]; + +function buildAddPageButton() { + const nav = document.querySelector(".header .nav"); + if (!nav) return; + const add = div("editor-ui editor-add-page nav-item", "Add +"); + add.addEventListener("click", openLayoutModal); + nav.append(add); + + const remove = div("editor-ui editor-remove-page nav-item", "Remove page"); + remove.addEventListener("click", removeCurrentPage); + nav.append(remove); +} + +function removeCurrentPage() { + const page = state.config.pages[state.pageIndex]; + confirmAction(`Remove the page "${page.title}"? This cannot be undone.`, () => { + // Keep editing (flag stays set) and land on home once the server reloads. + commitAndNavigate({ op: "removePage", page: state.pageIndex }, () => (location.href = `${PD.baseURL}/`)); + }); +} + +function openLayoutModal() { + const body = div("editor-layout-grid"); + let chosen = PAGE_LAYOUTS[0]; + + for (const layout of PAGE_LAYOUTS) { + const option = div("editor-layout-option"); + for (const size of layout) option.append(div(`editor-layout-cell editor-layout-cell-${size}`)); + option.addEventListener("click", () => { + body.querySelectorAll(".editor-layout-option").forEach((o) => o.classList.remove("selected")); + option.classList.add("selected"); + chosen = layout; + }); + body.append(option); + } + body.firstChild.classList.add("selected"); + + const title = document.createElement("input"); + title.className = "editor-input"; + title.placeholder = "Page name"; + + openModal("New page", [labeled("Name", title), body], () => { + // Navigate onto the new page in edit mode once the server has picked it up. + const name = title.value.trim() || "New Page"; + commitAndNavigate({ op: "addPage", title: name, layout: chosen }, async () => { + const cfg = await apiGet("/config").catch(() => null); + const created = cfg?.pages.find((p) => p.title === name); + location.href = created ? `${PD.baseURL}/${created.slug}` : `${PD.baseURL}/`; + }); + }); +} + +// +// Widget option modal +// + +function openNewWidgetModal(type, path) { + // Widgets dropped inside a container default to frameless (user can uncheck it). + const initial = path.length >= 3 ? { frameless: "true" } : {}; + buildWidgetModal(type, initial, (fields, rawFields) => + save({ op: "addWidget", page: state.pageIndex, path, widgetType: type, fields, rawFields }) + ); +} + +function openWidgetModal(path) { + const widget = widgetAtPath(path); + if (!widget) return; + buildWidgetModal(widget.type, widget.values || {}, (fields, rawFields) => + save({ op: "editWidget", page: state.pageIndex, path, fields, rawFields }) + ); +} + +function buildWidgetModal(type, values, onSave) { + const schema = state.schemaByType[type]; + if (!schema) return toast(`Unknown widget: ${type}`, "negative"); + + const basic = div("editor-fields"); + const advanced = div("editor-fields"); + const controls = []; + + for (const field of schema.fields) { + if (field.name === "widgets") continue; // container children are edited via nesting + const control = renderField(field, values[field.name]); + controls.push(control); + (field.advanced ? advanced : basic).append(control.wrapper); + } + + const sections = [basic]; + if (advanced.children.length) sections.push(collapsible("Advanced", advanced)); + + openModal(schema.label, sections, () => { + const fields = {}; + const rawFields = {}; + for (const c of controls) { + const value = c.read(); + if (value === undefined) continue; + if (c.field.kind === "yaml") rawFields[c.field.name] = value; + else fields[c.field.name] = value; + } + onSave(fields, rawFields); + }); +} + +// renderField builds a labeled input for a schema field and a reader for its value. +function renderField(field, value) { + if (field.kind === "checkbox") { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = value === "true" || value === true; + const row = document.createElement("label"); + row.className = "editor-field editor-check"; + const text = document.createElement("span"); + text.className = "editor-check-label"; + text.append(field.label); + if (field.required) text.append(requiredStar()); + row.append(input, text); + return { field, wrapper: row, read: () => input.checked }; + } + + let input; + let read; + + if (field.kind === "select") { + input = document.createElement("select"); + input.className = "editor-input"; + input.append(new Option("-", "")); // allow leaving the field unset + for (const opt of field.options || []) input.append(new Option(opt, opt)); + input.value = value || ""; + read = () => input.value || undefined; + } else if (field.kind === "yaml") { + input = document.createElement("textarea"); + input.className = "editor-input editor-textarea"; + input.value = value || ""; + read = () => input.value.trim() || undefined; + } else if (field.kind === "number") { + input = inputEl("number"); + if (value) input.value = value; + read = () => (input.value === "" ? undefined : Number(input.value)); + } else { + input = inputEl("text"); + if (value) input.value = value; + if (field.kind === "duration") input.placeholder = "e.g. 30s, 5m, 1h"; + read = () => input.value.trim() || undefined; + } + + const wrapper = labeled(field.label, input, field.required); + if (field.kind === "icon") wrapper.append(iconPreview(input)); + + return { field, wrapper, read }; +} + +function iconPreview(input) { + const preview = img("editor-icon-preview", ""); + const update = () => { + const url = resolveIconURL(input.value.trim()); + preview.src = url; + preview.style.display = url ? "" : "none"; + }; + input.addEventListener("input", update); + update(); + return preview; +} + +// resolveIconURL mirrors the server's icon shorthand (si/di/mdi/sh) for a live preview. +function resolveIconURL(value) { + if (!value) return ""; + value = value.replace(/^auto-invert /, ""); + const [prefix, name] = value.split(":"); + if (!name) return value; + const base = name.split(".")[0]; + switch (prefix) { + case "si": return `https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/${base}.svg`; + case "di": return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${base}.svg`; + case "mdi": return `https://cdn.jsdelivr.net/npm/@mdi/svg@latest/svg/${base}.svg`; + case "sh": return `https://cdn.jsdelivr.net/gh/selfhst/icons/svg/${base}.svg`; + default: return value; + } +} + +// +// Generic modal +// + +function openModal(title, sections, onSave) { + const overlay = div("editor-ui editor-modal-overlay"); + const modal = div("editor-modal"); + modal.append(div("editor-modal-title", title)); + + const bodyEl = div("editor-modal-body"); + for (const s of sections) bodyEl.append(s); + modal.append(bodyEl); + + const actions = div("editor-modal-actions"); + const cancel = button("Cancel", "editor-btn"); + const saveBtn = button("Save", "editor-btn editor-btn-primary"); + cancel.addEventListener("click", () => overlay.remove()); + saveBtn.addEventListener("click", () => { + overlay.remove(); + onSave(); + }); + actions.append(cancel, saveBtn); + modal.append(actions); + + overlay.append(modal); + // Only close on a real backdrop click, not when a text selection drag ends here. + let pressedOnBackdrop = false; + overlay.addEventListener("mousedown", (e) => (pressedOnBackdrop = e.target === overlay)); + overlay.addEventListener("click", (e) => { + if (e.target === overlay && pressedOnBackdrop) overlay.remove(); + }); + document.body.append(overlay); +} + +// confirmAction shows a custom confirmation, falling back to the native dialog on mobile. +function confirmAction(message, onConfirm) { + if (window.matchMedia("(max-width: 768px)").matches) { + if (confirm(message)) onConfirm(); + return; + } + + const overlay = div("editor-ui editor-modal-overlay"); + const modal = div("editor-modal editor-confirm"); + modal.append(div("editor-confirm-message", message)); + + const actions = div("editor-modal-actions"); + const cancel = button("Cancel", "editor-btn"); + const confirmBtn = button("Remove", "editor-btn editor-btn-danger"); + confirmBtn.prepend(iconSpan(iconTrash)); + cancel.addEventListener("click", () => overlay.remove()); + confirmBtn.addEventListener("click", () => { + overlay.remove(); + onConfirm(); + }); + actions.append(cancel, confirmBtn); + modal.append(actions); + + overlay.append(modal); + overlay.addEventListener("click", (e) => { + if (e.target === overlay) overlay.remove(); + }); + document.body.append(overlay); +} + +function collapsible(title, content) { + const details = document.createElement("details"); + details.className = "editor-collapsible"; + const summary = document.createElement("summary"); + summary.textContent = title; + details.append(summary, content); + return details; +} + +// +// Toast +// + +function toast(message, type) { + let container = document.querySelector(".editor-toasts"); + if (!container) { + container = div("editor-ui editor-toasts"); + document.body.append(container); + } + const el = div(`editor-toast color-${type}`, message); + container.append(el); + setTimeout(() => el.remove(), 4000); +} + +// +// Small DOM helpers +// + +function div(className, text) { + const el = document.createElement("div"); + el.className = className; + if (text) el.textContent = text; + return el; +} + +function img(className, src) { + const el = document.createElement("img"); + el.className = className; + if (src) el.src = src; + return el; +} + +// maskIcon renders a monochrome icon as a CSS mask so it tints to the theme color. +function maskIcon(className, url) { + const el = div(className); + el.style.setProperty("--icon-url", `url("${url}")`); + return el; +} + +function button(text, className) { + const el = document.createElement("button"); + el.type = "button"; + el.className = className; + el.textContent = text; + return el; +} + +function inputEl(type) { + const el = document.createElement("input"); + el.type = type; + el.className = "editor-input"; + return el; +} + +function labeled(label, control, required) { + const wrapper = div("editor-field"); + const l = document.createElement("label"); + l.className = "editor-field-label"; + l.append(label); + if (required) l.append(requiredStar()); + wrapper.append(l, control); + return wrapper; +} + +function requiredStar() { + const star = document.createElement("span"); + star.className = "editor-required"; + star.textContent = "*"; + return star; +} + +function toolButton(title, svg, onClick) { + const b = button("", "editor-tool"); + b.title = title; + b.innerHTML = svg; + b.addEventListener("click", (e) => { + e.stopPropagation(); + onClick(); + }); + return b; +} + +const iconPlus = ``; +const iconPencil = ``; +const iconTrash = ``; diff --git a/internal/dynacat/templates/page.html b/internal/dynacat/templates/page.html index e272f35c..6ea0d066 100644 --- a/internal/dynacat/templates/page.html +++ b/internal/dynacat/templates/page.html @@ -5,6 +5,16 @@ {{ define "document-head-after" }} + {{ end }} {{ define "navigation-links" }} @@ -62,6 +72,13 @@ {{ end }} + {{ if or (not .App.AnyAuthEnabled) .AuthUser }} + + {{ end }} {{- if .AuthUser }}
{{ .AuthUser.Username }} diff --git a/internal/dynacat/widget-playing.go b/internal/dynacat/widget-playing.go index 0776e190..4194c7cd 100644 --- a/internal/dynacat/widget-playing.go +++ b/internal/dynacat/widget-playing.go @@ -87,7 +87,7 @@ type playingWidget struct { Frameless bool `yaml:"frameless"` Hosts []PlayingHostConfig `yaml:"hosts"` SmallColumn bool `yaml:"small-column"` - // `compact` option removed — layouts use the default (non-compact) sizing + // `compact` option removed - layouts use the default (non-compact) sizing PlayState string `yaml:"play-state"` ShowThumbnail *bool `yaml:"show-thumbnail"` ShowPaused bool `yaml:"show-paused"` @@ -163,7 +163,7 @@ func (widget *playingWidget) initialize() error { widget.EpisodeTitleFormat = "series" } - // Boolean defaults — only applied when not explicitly set by the user + // Boolean defaults - only applied when not explicitly set by the user t := true if widget.ShowThumbnail == nil { widget.ShowThumbnail = &t diff --git a/internal/dynacat/widget-schema.go b/internal/dynacat/widget-schema.go new file mode 100644 index 00000000..7d1a3088 --- /dev/null +++ b/internal/dynacat/widget-schema.go @@ -0,0 +1,357 @@ +package dynacat + +import ( + "reflect" + "strings" +) + +// widgetFieldSchema describes one editable option of a widget for the editor UI. +type widgetFieldSchema struct { + Name string `json:"name"` + Label string `json:"label"` + Kind string `json:"kind"` // text|number|checkbox|select|duration|icon|yaml + Required bool `json:"required,omitempty"` + Advanced bool `json:"advanced,omitempty"` + Options []string `json:"options,omitempty"` +} + +type widgetTypeSchema struct { + Type string `json:"type"` + Label string `json:"label"` + Icon string `json:"icon"` + Hidden bool `json:"hidden,omitempty"` // kept editable but omitted from the palette + Fields []widgetFieldSchema `json:"fields"` +} + +// hiddenWidgetTypes are excluded from the palette (still editable if already used). +var hiddenWidgetTypes = map[string]bool{ + "html": true, +} + +type widgetTypeMeta struct { + Type string + Label string + Icon string +} + +// widgetTypeCatalog lists every user-selectable widget for the palette. +// Keep in sync with the newWidget switch in widget.go (the one manual sync point). +var widgetTypeCatalog = []widgetTypeMeta{ + {"calendar", "Calendar", "mdi:calendar"}, + {"clock", "Clock", "mdi:clock-outline"}, + {"weather", "Weather", "mdi:weather-partly-cloudy"}, + {"bookmarks", "Bookmarks", "mdi:bookmark-outline"}, + {"iframe", "IFrame", "mdi:application-brackets-outline"}, + {"html", "HTML", "mdi:language-html5"}, + {"hacker-news", "Hacker News", "mdi:newspaper-variant-outline"}, + {"releases", "Releases", "mdi:tag-outline"}, + {"videos", "Videos", "mdi:youtube"}, + {"markets", "Markets", "mdi:chart-line"}, + {"reddit", "Reddit", "mdi:reddit"}, + {"rss", "RSS", "mdi:rss"}, + {"monitor", "Monitor", "mdi:heart-pulse"}, + {"twitch-top-games", "Twitch Top Games", "mdi:twitch"}, + {"twitch-channels", "Twitch Channels", "mdi:twitch"}, + {"lobsters", "Lobsters", "mdi:message-text-outline"}, + {"change-detection", "Change Detection", "mdi:eye-outline"}, + {"repository", "Repository", "mdi:source-repository"}, + {"search", "Search", "mdi:magnify"}, + {"stopwatch", "Stopwatch", "mdi:timer-outline"}, + {"extension", "Extension", "mdi:puzzle-outline"}, + {"group", "Group", "mdi:tab"}, + {"dns-stats", "DNS Stats", "mdi:dns-outline"}, + {"split-column", "Split Column", "mdi:view-column-outline"}, + {"custom-api", "Custom API", "mdi:api"}, + {"dynawidgets", "Dynawidgets", "mdi:widgets-outline"}, + {"docker-containers", "Docker Containers", "mdi:docker"}, + {"docker-controller", "Docker Controller", "mdi:docker"}, + {"server-stats", "Server Stats", "mdi:server"}, + {"speedtest", "Speedtest", "mdi:speedometer"}, + {"to-do", "To-do", "mdi:checkbox-marked-outline"}, + {"playing", "Now Playing", "mdi:play-circle-outline"}, + {"latest-media", "Latest Media", "mdi:multimedia"}, + {"torrenting", "Torrenting", "mdi:download-network-outline"}, +} + +type fieldAnnotation struct { + Advanced bool + Kind string + Options []string + Required bool +} + +// alwaysAdvancedFields are the shared plumbing options hidden under Advanced for +// every widget. Everything else defaults to basic (shown by default). +var alwaysAdvancedFields = map[string]bool{ + "title": true, "title-icon": true, "title-url": true, "hide-header": true, + "css-class": true, "cache": true, "update-interval": true, "lazy-load": true, + "frameless": true, +} + +// fieldAnnotations overlays per-widget specifics reflection cannot know: which +// widget-specific fields are advanced (auth/network/low-level), select options and +// required flags. Fields not listed here (and not in alwaysAdvancedFields) are basic. +var fieldAnnotations = map[string]map[string]fieldAnnotation{ + "calendar": { + "first-day-of-week": {Options: []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}}, + }, + "clock": { + "hour-format": {Options: []string{"24h", "12h"}}, + }, + "weather": { + "location": {Required: true}, + "hour-format": {Options: []string{"12h", "24h"}}, + "units": {Options: []string{"metric", "imperial"}}, + }, + "iframe": { + "source": {Required: true}, + }, + "html": { + "source": {Required: true}, + }, + "hacker-news": { + "sort-by": {Options: []string{"top", "new", "best"}}, + "extra-sort-by": {Options: []string{"engagement"}}, + "comments-url-template": {Advanced: true}, + }, + "releases": { + "repositories": {Required: true}, + "token": {Advanced: true}, + "gitlab-token": {Advanced: true}, + }, + "videos": { + "style": {Options: []string{"grid-cards", "vertical-list"}}, + "video-url-template": {Advanced: true}, + }, + "markets": { + "sort-by": {Options: []string{"absolute-change", "change"}}, + "chart-link-template": {Advanced: true}, + "symbol-link-template": {Advanced: true}, + "proxy": {Advanced: true}, + }, + "reddit": { + "subreddit": {Required: true}, + "sort-by": {Options: []string{"hot", "new", "top", "rising"}}, + "top-period": {Options: []string{"hour", "day", "week", "month", "year", "all"}}, + "style": {Options: []string{"horizontal-cards", "vertical-cards"}}, + "extra-sort-by": {Options: []string{"engagement"}}, + "proxy": {Advanced: true}, + "comments-url-template": {Advanced: true}, + "request-url-template": {Advanced: true}, + "app-auth": {Advanced: true}, + }, + "rss": { + "feeds": {Required: true}, + "style": {Options: []string{"detailed-list", "horizontal-cards", "horizontal-cards-2"}}, + }, + "monitor": { + "sites": {Required: true}, + "style": {Options: []string{"compact"}}, + }, + "twitch-channels": { + "sort-by": {Options: []string{"viewers", "live"}}, + }, + "lobsters": { + "sort-by": {Options: []string{"hot", "new"}}, + }, + "change-detection": { + "allow-insecure": {Advanced: true}, + "token": {Advanced: true}, + }, + "repository": { + "repository": {Required: true}, + "token": {Advanced: true}, + }, + "search": { + "search-engine": {Options: []string{"duckduckgo", "google", "bing", "perplexity", "kagi", "startpage", "qwant", "brave"}}, + "autocomplete-provider": {Options: []string{"duckduckgo", "brave"}}, + }, + "extension": { + "url": {Required: true}, + "fallback-content-type": {Options: []string{"html"}}, + "headers": {Advanced: true}, + "allow-potentially-dangerous-html": {Advanced: true}, + }, + "dns-stats": { + "service": {Required: true, Options: []string{"adguard", "pihole", "pihole-v6", "technitium", "blocky"}}, + "hour-format": {Options: []string{"12h", "24h"}}, + "token": {Advanced: true}, + "username": {Advanced: true}, + "password": {Advanced: true}, + "allow-insecure": {Advanced: true}, + }, + "custom-api": { + "template": {Required: true, Kind: "yaml"}, + "subrequests": {Advanced: true}, + "method": {Advanced: true}, + "body": {Advanced: true}, + "body-type": {Advanced: true, Options: []string{"json", "string"}}, + "headers": {Advanced: true}, + "allow-insecure": {Advanced: true}, + }, + "dynawidgets": { + "widget": {Required: true}, + "repo": {Advanced: true}, + "subrequests": {Advanced: true}, + "method": {Advanced: true}, + "body": {Advanced: true}, + "body-type": {Advanced: true, Options: []string{"json", "string"}}, + "headers": {Advanced: true}, + "allow-insecure": {Advanced: true}, + }, + "docker-containers": { + "sock-path": {Advanced: true}, + }, + "docker-controller": { + "show": {Options: []string{"both", "containers", "images"}}, + "sock-path": {Advanced: true}, + }, + "to-do": { + "storage": {Options: []string{"local", "server"}}, + "id": {Advanced: true}, + }, + "playing": { + "hosts": {Required: true}, + "play-state": {Options: []string{"indicator", "text"}}, + "episode-title-format": {Options: []string{"series", "episode"}}, + }, + "latest-media": { + "hosts": {Required: true}, + }, + "torrenting": { + "hosts": {Required: true}, + }, + "speedtest": { + "server": {Required: true}, + }, +} + +// widgetSchema reflects a widget struct into an editor schema, overlaying annotations. +func widgetSchema(meta widgetTypeMeta) (widgetTypeSchema, error) { + w, err := newWidget(meta.Type) + if err != nil { + return widgetTypeSchema{}, err + } + + fields := reflectWidgetFields(reflect.TypeOf(w).Elem()) + ann := fieldAnnotations[meta.Type] + + for i := range fields { + f := &fields[i] + f.Advanced = alwaysAdvancedFields[f.Name] + f.Label = humanizeFieldName(f.Name) + + a, ok := ann[f.Name] + if !ok { + continue + } + if a.Advanced { + f.Advanced = true + } + if a.Kind != "" { + f.Kind = a.Kind + } + if len(a.Options) > 0 { + f.Kind = "select" + f.Options = a.Options + } + f.Required = a.Required + } + + return widgetTypeSchema{ + Type: meta.Type, + Label: meta.Label, + Icon: string(newCustomIconField(meta.Icon).URL), + Hidden: hiddenWidgetTypes[meta.Type], + Fields: fields, + }, nil +} + +func allWidgetSchemas() []widgetTypeSchema { + schemas := make([]widgetTypeSchema, 0, len(widgetTypeCatalog)) + for _, meta := range widgetTypeCatalog { + if s, err := widgetSchema(meta); err == nil { + schemas = append(schemas, s) + } + } + return schemas +} + +// reflectWidgetFields walks yaml-tagged fields, recursing into inlined structs. +func reflectWidgetFields(t reflect.Type) []widgetFieldSchema { + var fields []widgetFieldSchema + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + name, inline := parseYAMLTag(f.Tag.Get("yaml")) + + ft := f.Type + for ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + + if inline && ft.Kind() == reflect.Struct { + fields = append(fields, reflectWidgetFields(ft)...) + continue + } + if name == "" || name == "-" || name == "type" || !f.IsExported() { + continue + } + + fields = append(fields, widgetFieldSchema{Name: name, Kind: fieldKind(f.Type)}) + } + + return fields +} + +func parseYAMLTag(tag string) (name string, inline bool) { + parts := strings.Split(tag, ",") + name = parts[0] + for _, p := range parts[1:] { + if p == "inline" { + inline = true + } + } + return name, inline +} + +var ( + iconFieldType = reflect.TypeOf(customIconField{}) + durationFieldType = reflect.TypeOf(durationField(0)) + intervalFieldType = reflect.TypeOf(updateIntervalField(0)) +) + +func fieldKind(t reflect.Type) string { + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t { + case iconFieldType: + return "icon" + case durationFieldType, intervalFieldType: + return "duration" + } + + switch t.Kind() { + case reflect.Bool: + return "checkbox" + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return "number" + case reflect.String: + return "text" + default: + return "yaml" // slices, maps, nested structs get a raw yaml editor + } +} + +func humanizeFieldName(name string) string { + words := strings.Split(name, "-") + for i, w := range words { + if w != "" { + words[i] = strings.ToUpper(w[:1]) + w[1:] + } + } + return strings.Join(words, " ") +} diff --git a/pkg/sysinfo/sysinfo.go b/pkg/sysinfo/sysinfo.go index 17f03a2a..5eb9f104 100644 --- a/pkg/sysinfo/sysinfo.go +++ b/pkg/sysinfo/sysinfo.go @@ -288,7 +288,7 @@ func Collect(req *SystemInfoRequest) (*SystemInfo, []error) { } // Inside containers (e.g. Docker), disk.Partitions(false) only returns - // devices starting with /dev/ — the overlay root filesystem is skipped. + // devices starting with /dev/ - the overlay root filesystem is skipped. // Fall back to "/" so disk usage is still reported without requiring a // host bind-mount. if len(addedMountpoints) == 0 { From 2fdb9a92625cb134fc5a93906e3d64319c955e7b Mon Sep 17 00:00:00 2001 From: Panonim Date: Sun, 19 Jul 2026 22:26:50 +0200 Subject: [PATCH 05/53] Go bump --- Dockerfile | 2 +- go.mod | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ed3199b3..5e595e38 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.0-alpine3.21 AS builder +FROM golang:1.25.5-alpine3.21 AS builder WORKDIR /app diff --git a/go.mod b/go.mod index c0a1045b..95a8546c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Panonim/dynacat -go 1.25.0 +go 1.25.5 require ( github.com/coreos/go-oidc/v3 v3.18.0 From 6f48d795e7410a4904d8ecbe2f03fccfe0dba53d Mon Sep 17 00:00:00 2001 From: Panonim Date: Sun, 19 Jul 2026 22:29:40 +0200 Subject: [PATCH 06/53] Go bump 2 --- go.mod | 39 ++++++------- go.sum | 175 ++++++++++++++++----------------------------------------- 2 files changed, 65 insertions(+), 149 deletions(-) diff --git a/go.mod b/go.mod index 95a8546c..47226706 100644 --- a/go.mod +++ b/go.mod @@ -3,46 +3,41 @@ module github.com/Panonim/dynacat go 1.25.5 require ( - github.com/coreos/go-oidc/v3 v3.18.0 + github.com/coreos/go-oidc/v3 v3.20.0 github.com/fsnotify/fsnotify v1.10.1 - github.com/mmcdole/gofeed v1.3.0 + github.com/mmcdole/gofeed v1.4.0 github.com/refraction-networking/utls v1.8.2 - github.com/shirou/gopsutil/v4 v4.26.5 + github.com/shirou/gopsutil/v4 v4.26.6 github.com/tidwall/gjson v1.19.0 - golang.org/x/crypto v0.52.0 - golang.org/x/net v0.55.0 + golang.org/x/crypto v0.54.0 + golang.org/x/net v0.57.0 golang.org/x/oauth2 v0.36.0 - golang.org/x/text v0.37.0 + golang.org/x/text v0.40.0 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.51.0 + modernc.org/sqlite v1.54.0 ) require ( - github.com/PuerkitoBio/goquery v1.11.0 // indirect - github.com/andybalholm/brotli v1.0.6 // indirect - github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/andybalholm/brotli v1.2.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/ebitengine/purego v0.10.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.4 // indirect - github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mmcdole/goxpp v1.1.1 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect + github.com/mattn/go-isatty v0.0.23 // indirect + github.com/mmcdole/goxpp/v2 v2.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/sys v0.45.0 // indirect - modernc.org/libc v1.72.3 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index edbf99af..870fe831 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,13 @@ -github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= -github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= -github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= -github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= -github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= -github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= -github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -20,33 +15,24 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9 github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= -github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= -github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= -github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= -github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c= +github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ= +github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mmcdole/gofeed v1.4.0 h1:+efDmI/yJXJgTfa8we5zg9GAKsU+2d7tnpt9QZwvjLQ= +github.com/mmcdole/gofeed v1.4.0/go.mod h1:ngV5MTB7UJko6fH3/fG5AkB/ABUGK1ZTePF9iRhzu/c= +github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ= +github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -57,10 +43,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= -github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= +github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= @@ -69,114 +53,51 @@ github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= -github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= -github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= -modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= -modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= -modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= -modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws= +modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -185,8 +106,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= -modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= From 68faddb747d9ab78092dec825dd64e12f395962b Mon Sep 17 00:00:00 2001 From: Panonim Date: Sun, 19 Jul 2026 22:58:56 +0200 Subject: [PATCH 07/53] Visual changes --- internal/dynacat/static/css/editor.css | 6 ++-- .../static/css/widget-docker-controller.css | 4 +-- internal/dynacat/static/js/editor.js | 28 ++++++++++++++++++- internal/dynacat/widget-schema.go | 2 +- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/internal/dynacat/static/css/editor.css b/internal/dynacat/static/css/editor.css index f5a3f974..110646e4 100644 --- a/internal/dynacat/static/css/editor.css +++ b/internal/dynacat/static/css/editor.css @@ -13,6 +13,7 @@ height: 1.8rem; stroke: var(--color-text-subdue); transition: stroke 0.2s; + transform: translateY(2px); } #editor-toggle:hover .editor-toggle-icon { @@ -429,8 +430,8 @@ body.editing .page-column:not(:has(> .widget))::after { } .editor-field-label { - font-size: 1.2rem; - color: var(--color-text-subdue); + font-size: 1.3rem; + color: var(--color-text-base); } .editor-required { @@ -467,6 +468,7 @@ body.editing .page-column:not(:has(> .widget))::after { .editor-check-label { font-size: 1.3rem; + line-height: 1; color: var(--color-text-base); } diff --git a/internal/dynacat/static/css/widget-docker-controller.css b/internal/dynacat/static/css/widget-docker-controller.css index 5410db87..4c0bb04b 100644 --- a/internal/dynacat/static/css/widget-docker-controller.css +++ b/internal/dynacat/static/css/widget-docker-controller.css @@ -1,6 +1,6 @@ .widget-type-docker-controller input { background: none; - border: 1px solid var(--color-text-subdue); + border: 1px solid var(--color-popover-border); border-radius: var(--border-radius); color: var(--color-text-highlight); font: inherit; @@ -39,7 +39,7 @@ .docker-ctrl-pull-btn { background: none; - border: 1px solid var(--color-text-subdue); + border: 1px solid var(--color-popover-border); border-radius: var(--border-radius); color: var(--color-text-base); cursor: pointer; diff --git a/internal/dynacat/static/js/editor.js b/internal/dynacat/static/js/editor.js index 793dacbf..3f0854bc 100644 --- a/internal/dynacat/static/js/editor.js +++ b/internal/dynacat/static/js/editor.js @@ -521,7 +521,9 @@ function renderField(field, value) { let input; let read; - if (field.kind === "select") { + if (field.kind === "select" && field.name === "search-engine") { + return renderSearchEngineField(field, value); + } else if (field.kind === "select") { input = document.createElement("select"); input.className = "editor-input"; input.append(new Option("-", "")); // allow leaving the field unset @@ -550,6 +552,30 @@ function renderField(field, value) { return { field, wrapper, read }; } +// renderSearchEngineField adds a custom-URL input that shows only when "custom" is picked. +function renderSearchEngineField(field, value) { + const known = (field.options || []).filter((o) => o !== "custom"); + const isCustom = value && !known.includes(value); + + const select = document.createElement("select"); + select.className = "editor-input"; + select.append(new Option("-", "")); + for (const opt of field.options || []) select.append(new Option(opt, opt)); + select.value = isCustom ? "custom" : value || ""; + + const customInput = inputEl("text"); + customInput.placeholder = "https://example.com/search?q={QUERY}"; + customInput.style.display = select.value === "custom" ? "" : "none"; + if (isCustom) customInput.value = value; + select.addEventListener("change", () => { + customInput.style.display = select.value === "custom" ? "" : "none"; + }); + + const wrapper = labeled(field.label, select, field.required); + wrapper.append(customInput); + return { field, wrapper, read: () => (select.value === "custom" ? customInput.value.trim() || undefined : select.value || undefined) }; +} + function iconPreview(input) { const preview = img("editor-icon-preview", ""); const update = () => { diff --git a/internal/dynacat/widget-schema.go b/internal/dynacat/widget-schema.go index 7d1a3088..ee22d40d 100644 --- a/internal/dynacat/widget-schema.go +++ b/internal/dynacat/widget-schema.go @@ -163,7 +163,7 @@ var fieldAnnotations = map[string]map[string]fieldAnnotation{ "token": {Advanced: true}, }, "search": { - "search-engine": {Options: []string{"duckduckgo", "google", "bing", "perplexity", "kagi", "startpage", "qwant", "brave"}}, + "search-engine": {Options: []string{"duckduckgo", "google", "bing", "perplexity", "kagi", "startpage", "qwant", "brave", "custom"}}, "autocomplete-provider": {Options: []string{"duckduckgo", "brave"}}, }, "extension": { From 13c268f53d749fe7b5978ffbc4e8ea831d163938 Mon Sep 17 00:00:00 2001 From: Panonim Date: Wed, 22 Jul 2026 16:26:19 +0200 Subject: [PATCH 08/53] Added an option to search using bookmarks --- docs/docs/configuration.md | 37 +++++- internal/dynacat/dynacat.go | 35 ++++-- internal/dynacat/sse.go | 79 ++++++++---- internal/dynacat/static/css/widget-search.css | 20 ++- internal/dynacat/static/js/editor.js | 41 +++++- internal/dynacat/static/js/page.js | 119 +++++++++++++----- internal/dynacat/templates/search.html | 10 +- internal/dynacat/widget-schema.go | 9 +- internal/dynacat/widget-search.go | 109 +++++++++++++--- 9 files changed, 374 insertions(+), 85 deletions(-) diff --git a/docs/docs/configuration.md b/docs/docs/configuration.md index 2b6fa909..fed21c99 100644 --- a/docs/docs/configuration.md +++ b/docs/docs/configuration.md @@ -2534,6 +2534,8 @@ Preview: | placeholder | string | no | Type here to search… | | autocomplete | boolean | no | true | | autocomplete-provider | string | no | duckduckgo | +| include-bookmarks | boolean | no | false | +| cross-page-bookmarks | boolean | no | false | | bangs | array | no | | ##### `search-engine` @@ -2565,12 +2567,45 @@ When set, modifies the text displayed in the input field before typing. When set to `true` (default), displays search suggestions as you type. Navigate suggestions with ↑ and ↓ arrow keys, select with Enter, or dismiss with Escape. Set to `false` to disable autocompletion. ##### `autocomplete-provider` -The provider used for search suggestions. Possible values are `duckduckgo` (default) and `brave`. +Either `duckduckgo` (default), `brave`, or a URL to a custom suggestion endpoint — works the same way as [`search-engine`](#search-engine): pick a known provider by name, or supply your own URL directly. | Value | Provider | | ----- | -------- | | duckduckgo | DuckDuckGo autocomplete | | brave | Brave Search autocomplete | +| a URL containing `{QUERY}` | Your own suggestion endpoint | + +A custom URL must return the [OpenSearch suggestions format](https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md#the-json-format) (`["query", ["suggestion 1", "suggestion 2", ...]]`), which is what most self-hosted and public search engines (SearXNG, Wikipedia, etc.) expose. Use `{QUERY}` to indicate where the typed query gets placed. The request is made server-side, so the URL is never sent to the browser. Example: + +```yaml +- type: search + search-engine: https://en.wikipedia.org/wiki/Special:Search?search={QUERY} + autocomplete-provider: https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search={QUERY} +``` + +> [!NOTE] +> +> Older configs written with `autocomplete-provider: custom` alongside a separate `autocomplete-url: ` still work, but should be migrated to the single-field form above — the `autocomplete-url` property is deprecated and no longer shown in the interactive editor. + +##### `include-bookmarks` +When set to `true`, matches what you type against the titles of your [bookmarks](#bookmarks) (case-insensitive, matching from the start of the title) and shows matching bookmarks above the regular search suggestions. Clicking a bookmark match navigates straight to its URL instead of performing a search. Up to 3 matches are shown. + +By default only bookmarks on the same page as the search widget are matched. Use [`cross-page-bookmarks`](#cross-page-bookmarks) to match bookmarks from every page. + +Each match shows the bookmark's own [icon](#icons) if it has one, or a small arrow otherwise, exactly like in the bookmarks widget itself. + +```yaml +- type: search + include-bookmarks: true +``` + +##### `cross-page-bookmarks` +When set to `true`, implies [`include-bookmarks`](#include-bookmarks) and widens the search to bookmarks on every page, not just the page the search widget is on. + +```yaml +- type: search + cross-page-bookmarks: true +``` ##### `bangs` What now? [Bangs](https://duckduckgo.com/bangs). They're shortcuts that allow you to use the same search box for many different sites. Assuming you have it configured, if for example you start your search input with `!yt` you'd be able to perform a search on YouTube: diff --git a/internal/dynacat/dynacat.go b/internal/dynacat/dynacat.go index 82eea4d2..dcefa83d 100644 --- a/internal/dynacat/dynacat.go +++ b/internal/dynacat/dynacat.go @@ -74,20 +74,23 @@ type application struct { imageProxyMu sync.RWMutex imageProxyURLs map[string]imageProxyInfo + searchAutocompleteURLs map[uint64]string + imageCache *imageCache } func newApplication(c *config) (*application, error) { app := &application{ - Version: buildVersion, - CreatedAt: time.Now(), - Config: *c, - slugToPage: make(map[string]*page), - widgetByID: make(map[uint64]widget), - widgetToPage: make(map[uint64]*page), - sseClients: make(map[*sseClient]struct{}), - imageProxyURLs: make(map[string]imageProxyInfo), - todoListIDToPage: make(map[string]*page), + Version: buildVersion, + CreatedAt: time.Now(), + Config: *c, + slugToPage: make(map[string]*page), + widgetByID: make(map[uint64]widget), + widgetToPage: make(map[uint64]*page), + sseClients: make(map[*sseClient]struct{}), + imageProxyURLs: make(map[string]imageProxyInfo), + todoListIDToPage: make(map[string]*page), + searchAutocompleteURLs: make(map[uint64]string), } config := &app.Config @@ -336,6 +339,20 @@ func newApplication(c *config) (*application, error) { } } + for id, w := range app.widgetByID { + sw, ok := w.(*searchWidget) + if !ok || !sw.IncludeBookmarks { + continue + } + + var pageFilter *page + if !sw.CrossPageBookmarks { + pageFilter = app.widgetToPage[id] + } + + sw.collectBookmarks(app, pageFilter) + } + config.Theme.CustomCSSFile = app.resolveUserDefinedAssetPath(config.Theme.CustomCSSFile) config.Branding.LogoURL = app.resolveUserDefinedAssetPath(config.Branding.LogoURL) diff --git a/internal/dynacat/sse.go b/internal/dynacat/sse.go index 7df7f54c..305a2457 100644 --- a/internal/dynacat/sse.go +++ b/internal/dynacat/sse.go @@ -8,6 +8,8 @@ import ( "net" "net/http" "net/url" + "strconv" + "strings" "sync" "time" ) @@ -127,6 +129,48 @@ func (a *application) handleImageProxyRequest(w http.ResponseWriter, r *http.Req } } +// respondWithOpenSearchSuggestions fetches requestURL and writes its suggestions +// to w. Expects the OpenSearch format ["query", ["suggestion1", ...]], hence raw[1]. +func (a *application) respondWithOpenSearchSuggestions(w http.ResponseWriter, r *http.Request, requestURL string) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, requestURL, nil) + if err != nil { + http.Error(w, "Failed to create request", http.StatusInternalServerError) + return + } + setBrowserUserAgentHeader(req) + + resp, err := defaultHTTPClient.Do(req) + if err != nil { + http.Error(w, "Failed to fetch suggestions", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + var raw []json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || len(raw) < 2 { + w.WriteHeader(http.StatusOK) + w.Write([]byte("[]")) + return + } + + var suggestions []string + if err := json.Unmarshal(raw[1], &suggestions); err != nil { + w.WriteHeader(http.StatusOK) + w.Write([]byte("[]")) + return + } + + type phrase struct { + Phrase string `json:"phrase"` + } + result := make([]phrase, len(suggestions)) + for i, s := range suggestions { + result[i] = phrase{Phrase: s} + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(result) +} + func (a *application) handleSearchAutocompleteRequest(w http.ResponseWriter, r *http.Request) { if a.handleUnauthorizedResponse(w, r, showUnauthorizedJSON) { return @@ -145,44 +189,33 @@ func (a *application) handleSearchAutocompleteRequest(w http.ResponseWriter, r * if provider == "brave" { braveURL := "https://search.brave.com/api/suggest?" + url.Values{"q": {query}, "rich": {"false"}}.Encode() - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, braveURL, nil) - if err != nil { - http.Error(w, "Failed to create request", http.StatusInternalServerError) - return - } - setBrowserUserAgentHeader(req) + a.respondWithOpenSearchSuggestions(w, r, braveURL) + return + } - resp, err := defaultHTTPClient.Do(req) + if provider == "custom" { + widgetID, err := strconv.ParseUint(r.URL.Query().Get("widgetId"), 10, 64) if err != nil { - http.Error(w, "Failed to fetch suggestions", http.StatusBadGateway) + w.WriteHeader(http.StatusOK) + w.Write([]byte("[]")) return } - defer resp.Body.Close() - // Brave returns OpenSearch format: ["query", ["s1", "s2", ...]] - var raw []json.RawMessage - if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || len(raw) < 2 { + urlTemplate, ok := a.searchAutocompleteURLs[widgetID] + if !ok { w.WriteHeader(http.StatusOK) w.Write([]byte("[]")) return } - var suggestions []string - if err := json.Unmarshal(raw[1], &suggestions); err != nil { + customURL := strings.ReplaceAll(urlTemplate, "{QUERY}", url.QueryEscape(query)) + if err := validateImageProxyURL(customURL); err != nil { w.WriteHeader(http.StatusOK) w.Write([]byte("[]")) return } - type phrase struct { - Phrase string `json:"phrase"` - } - result := make([]phrase, len(suggestions)) - for i, s := range suggestions { - result[i] = phrase{Phrase: s} - } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(result) + a.respondWithOpenSearchSuggestions(w, r, customURL) return } diff --git a/internal/dynacat/static/css/widget-search.css b/internal/dynacat/static/css/widget-search.css index afa4b54d..449dcd00 100644 --- a/internal/dynacat/static/css/widget-search.css +++ b/internal/dynacat/static/css/widget-search.css @@ -76,6 +76,7 @@ } .search-bangs { display: none; } +.search-bookmarks { display: none; } .search-bang { border-radius: calc(var(--border-radius) * 2); @@ -130,7 +131,6 @@ transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s; } -/* Dropdown shown above input */ .search-autocomplete.search-autocomplete-above { border-bottom-width: 0; border-top-width: 1px; @@ -168,3 +168,21 @@ background: var(--color-widget-background-highlight); color: var(--color-text-highlight); } + +.search-autocomplete-item-bookmark { + display: flex; + align-items: center; + gap: 0.7em; +} + +.search-autocomplete-item-bookmark-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + opacity: 0.8; +} + +.search-autocomplete-item-bookmark-arrow { + flex-shrink: 0; + color: var(--color-primary); +} diff --git a/internal/dynacat/static/js/editor.js b/internal/dynacat/static/js/editor.js index 3f0854bc..516b3f9d 100644 --- a/internal/dynacat/static/js/editor.js +++ b/internal/dynacat/static/js/editor.js @@ -481,7 +481,12 @@ function buildWidgetModal(type, values, onSave) { for (const field of schema.fields) { if (field.name === "widgets") continue; // container children are edited via nesting - const control = renderField(field, values[field.name]); + if (field.name === "autocomplete") continue; // folded into autocomplete-provider below + + const control = field.name === "autocomplete-provider" + ? renderAutocompleteProviderField(field, values["autocomplete-provider"], values["autocomplete"]) + : renderField(field, values[field.name]); + controls.push(control); (field.advanced ? advanced : basic).append(control.wrapper); } @@ -494,6 +499,7 @@ function buildWidgetModal(type, values, onSave) { const rawFields = {}; for (const c of controls) { const value = c.read(); + if (c.extra) Object.assign(fields, c.extra()); if (value === undefined) continue; if (c.field.kind === "yaml") rawFields[c.field.name] = value; else fields[c.field.name] = value; @@ -576,6 +582,39 @@ function renderSearchEngineField(field, value) { return { field, wrapper, read: () => (select.value === "custom" ? customInput.value.trim() || undefined : select.value || undefined) }; } +// renderAutocompleteProviderField is renderSearchEngineField plus the "autocomplete" +// bool: the "Off" option disables autocomplete rather than just clearing the provider. +function renderAutocompleteProviderField(field, value, autocompleteValue) { + const known = (field.options || []).filter((o) => o !== "custom"); + const isOff = autocompleteValue === "false" || autocompleteValue === false; + const isCustom = value && !known.includes(value); + + const select = document.createElement("select"); + select.className = "editor-input"; + select.append(new Option("Off", "")); + for (const opt of known) select.append(new Option(opt, opt)); + select.append(new Option("custom", "custom")); + select.value = isOff ? "" : (isCustom ? "custom" : value || known[0] || ""); + + const customInput = inputEl("text"); + customInput.placeholder = "https://example.com/suggest?q={QUERY}"; + customInput.style.display = select.value === "custom" ? "" : "none"; + if (isCustom) customInput.value = value; + select.addEventListener("change", () => { + customInput.style.display = select.value === "custom" ? "" : "none"; + }); + + const wrapper = labeled(field.label, select, field.required); + wrapper.append(customInput); + + return { + field, + wrapper, + read: () => (select.value === "" ? undefined : (select.value === "custom" ? customInput.value.trim() || undefined : select.value)), + extra: () => ({ autocomplete: select.value !== "" }), + }; +} + function iconPreview(input) { const preview = img("editor-icon-preview", ""); const update = () => { diff --git a/internal/dynacat/static/js/page.js b/internal/dynacat/static/js/page.js index 2f7a2c60..a8162c6b 100644 --- a/internal/dynacat/static/js/page.js +++ b/internal/dynacat/static/js/page.js @@ -337,14 +337,23 @@ function setupSearchBoxes() { }); } - // Search Autocomplete - if (widget.dataset.autocomplete === "true") { + // Search Autocomplete + bookmark matching + if (widget.dataset.autocomplete === "true" || widget.dataset.bookmarksEnabled === "true") { + const autocompleteEnabled = widget.dataset.autocomplete === "true"; + const bookmarksEnabled = widget.dataset.bookmarksEnabled === "true"; const autocompleteEl = widget.querySelector(".search-autocomplete"); + const bookmarks = Array.from(widget.querySelectorAll(".search-bookmarks > input")).map((el) => ({ + type: "bookmark", + title: el.dataset.title, + url: el.dataset.url, + target: el.dataset.target || "", + icon: el.dataset.icon || "", + iconAutoInvert: el.dataset.iconAutoInvert === "true", + })); let acItems = []; let acIndex = -1; let acDebounce = null; let acVisible = false; - let acAbove = false; let acRepositioner = null; let acRepositionFrame = null; @@ -355,7 +364,6 @@ function setupSearchBoxes() { const spaceAbove = rect.top; const maxH = 280; - // Switch to above if not enough space below and more space above const goAbove = spaceBelow < 120 && spaceAbove > spaceBelow; autocompleteEl.style.left = rect.left + "px"; @@ -371,12 +379,7 @@ function setupSearchBoxes() { autocompleteEl.style.maxHeight = Math.min(maxH, spaceBelow - 4) + "px"; } - if (goAbove !== acAbove) { - acAbove = goAbove; - } - - // Keep direction classes in sync on every reposition so the initial - // below state also receives its border adjustments. + // Sync direction classes every reposition so the initial below state gets its borders too. autocompleteEl.classList.toggle("search-autocomplete-above", goAbove); widget.classList.toggle("search-suggestions-above", goAbove); widget.classList.toggle("search-suggestions-below", !goAbove); @@ -414,7 +417,6 @@ function setupSearchBoxes() { autocompleteEl.classList.remove("active", "search-autocomplete-above"); widget.classList.remove("search-suggestions-above", "search-suggestions-below"); acVisible = false; - acAbove = false; acIndex = -1; }; @@ -424,20 +426,65 @@ function setupSearchBoxes() { acIndex = i; }; - const renderAC = (suggestions) => { - acItems = suggestions; + const BOOKMARK_MATCH_LIMIT = 3; + + const matchBookmarks = (query) => { + if (!bookmarksEnabled || !query) return []; + const lowerQuery = query.toLowerCase(); + return bookmarks + .filter((b) => b.title && b.title.toLowerCase().startsWith(lowerQuery)) + .slice(0, BOOKMARK_MATCH_LIMIT); + }; + + const selectItem = (item) => { + if (item.type === "bookmark") { + hideAC(); + if (item.target === "_blank") { + window.open(item.url, item.target).focus(); + } else { + window.location.href = item.url; + } + return; + } + + inputElement.value = item.phrase; + hideAC(); + submitSearch(newTab); + }; + + const renderAC = (items) => { + acItems = items; acIndex = -1; autocompleteEl.innerHTML = ""; - if (suggestions.length === 0) { hideAC(); return; } - suggestions.forEach((phrase, idx) => { + if (items.length === 0) { hideAC(); return; } + items.forEach((acItem, idx) => { const item = document.createElement("div"); - item.className = "search-autocomplete-item"; - item.textContent = phrase; + + if (acItem.type === "bookmark") { + item.className = "search-autocomplete-item search-autocomplete-item-bookmark"; + if (acItem.icon) { + const icon = document.createElement("img"); + icon.className = "search-autocomplete-item-bookmark-icon" + (acItem.iconAutoInvert ? " flat-icon" : ""); + icon.src = acItem.icon; + icon.alt = ""; + item.appendChild(icon); + } else { + const arrow = document.createElement("span"); + arrow.className = "search-autocomplete-item-bookmark-arrow"; + arrow.textContent = "↗"; + item.appendChild(arrow); + } + const label = document.createElement("span"); + label.textContent = acItem.title; + item.appendChild(label); + } else { + item.className = "search-autocomplete-item"; + item.textContent = acItem.phrase; + } + item.addEventListener("mousedown", (e) => { e.preventDefault(); - inputElement.value = phrase; - hideAC(); - submitSearch(newTab); + selectItem(acItem); }); item.addEventListener("mousemove", () => { setACIndex(idx); @@ -447,19 +494,33 @@ function setupSearchBoxes() { showAC(); }; + const updateSuggestions = (query, phraseItems) => { + if (inputElement.value.trim() !== query) return; + renderAC([...matchBookmarks(query), ...phraseItems]); + }; + const fetchSuggestions = (query) => { - if (!query || query.length < 2 || currentBang != null) { + if (!query || currentBang != null) { hideAC(); return; } + + if (!autocompleteEnabled || query.length < 2) { + updateSuggestions(query, []); + return; + } + const provider = widget.dataset.autocompleteProvider || "duckduckgo"; - fetch("/api/search/autocomplete?q=" + encodeURIComponent(query) + "&provider=" + encodeURIComponent(provider)) + let suggestionsUrl = "/api/search/autocomplete?q=" + encodeURIComponent(query) + "&provider=" + encodeURIComponent(provider); + if (provider === "custom" && widget.dataset.autocompleteWidgetId) { + suggestionsUrl += "&widgetId=" + encodeURIComponent(widget.dataset.autocompleteWidgetId); + } + fetch(suggestionsUrl) .then((r) => r.json()) .then((data) => { - if (inputElement.value.trim() !== query) return; - renderAC(data.map((d) => d.phrase)); + updateSuggestions(query, data.map((d) => ({ type: "phrase", phrase: d.phrase }))); }) - .catch(() => hideAC()); + .catch(() => updateSuggestions(query, [])); }; inputElement.addEventListener("input", () => { @@ -479,16 +540,14 @@ function setupSearchBoxes() { setACIndex(Math.max(acIndex - 1, -1)); } else if (event.key === "Enter" && acIndex >= 0) { event.preventDefault(); - inputElement.value = acItems[acIndex]; - hideAC(); - submitSearch(newTab); + selectItem(acItems[acIndex]); } else if (event.key === "Escape") { hideAC(); } }); - // Delay hide to guard against spurious blur (e.g. Chrome scroll-on-focus - // near viewport bottom). Only close if focus genuinely left the input. + // Delay hide to survive spurious blur (Chrome scroll-on-focus); close only + // if focus really left the input. inputElement.addEventListener("blur", () => { setTimeout(() => { if (document.activeElement !== inputElement) { diff --git a/internal/dynacat/templates/search.html b/internal/dynacat/templates/search.html index c2a65aa7..8918c8aa 100644 --- a/internal/dynacat/templates/search.html +++ b/internal/dynacat/templates/search.html @@ -3,13 +3,19 @@ {{ define "widget-content-classes" }}{{ if .Frameless }}widget-content-frameless{{ end }}{{ end }} {{ define "widget-content" }} -