From 30b5e36cd71d6ee243310611c1a9d4f4cb5dec07 Mon Sep 17 00:00:00 2001 From: luxick Date: Fri, 8 May 2026 21:25:41 +0200 Subject: [PATCH] Allow customizing companion commands --- Makefile | 7 +-- assets/companion.js | 15 +++--- assets/page/main.html | 2 +- cmd/companion/commands.go | 96 +++++++++++++++++++++++++++++++++++ cmd/companion/config.go | 8 +-- cmd/companion/config.html | 63 +++++++++++++++++++++++ cmd/companion/log.go | 65 ++++++++++++++++++++++++ cmd/companion/main.go | 22 ++++++-- cmd/companion/open_linux.go | 10 ---- cmd/companion/open_windows.go | 15 ------ cmd/companion/server.go | 73 ++++++++++++++++++++------ 11 files changed, 317 insertions(+), 59 deletions(-) create mode 100644 cmd/companion/commands.go create mode 100644 cmd/companion/log.go delete mode 100644 cmd/companion/open_linux.go delete mode 100644 cmd/companion/open_windows.go diff --git a/Makefile b/Makefile index f8019f5..4dc8d8a 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,7 @@ NAS := luxick@192.168.3.3 COMPANION_WIN := companion/datascape-companion-windows-amd64.exe COMPANION_LIN := companion/datascape-companion-linux-amd64 +COMPANION_SRCS := $(wildcard cmd/companion/*.go) $(wildcard cmd/companion/*.html) go.mod go.sum .PHONY: deploy companion companion-windows companion-linux companion-release @@ -9,10 +10,10 @@ COMPANION_LIN := companion/datascape-companion-linux-amd64 # before `go build .` so embed.FS picks them up. companion-release: $(COMPANION_WIN) $(COMPANION_LIN) -$(COMPANION_WIN): - GOOS=windows GOARCH=amd64 go build -o $@ ./cmd/companion +$(COMPANION_WIN): $(COMPANION_SRCS) + GOOS=windows GOARCH=amd64 go build -ldflags="-H windowsgui" -o $@ ./cmd/companion -$(COMPANION_LIN): +$(COMPANION_LIN): $(COMPANION_SRCS) GOOS=linux GOARCH=amd64 go build -o $@ ./cmd/companion companion-windows: $(COMPANION_WIN) diff --git a/assets/companion.js b/assets/companion.js index 1c6520e..75b86c4 100644 --- a/assets/companion.js +++ b/assets/companion.js @@ -136,14 +136,15 @@ } function wireRevealButton() { - var btn = document.querySelector('[data-companion-reveal]'); - if (!btn) return; if (!state.available) return; - btn.hidden = false; - btn.addEventListener('click', function () { - var rel = wikiPathFromHref(window.location.pathname); - if (rel === null) rel = ''; - companionGET('/open-folder', { path: rel }).catch(function () { }); + var btns = document.querySelectorAll('[data-companion-reveal]'); + btns.forEach(function (btn) { + btn.hidden = false; + btn.addEventListener('click', function () { + var rel = wikiPathFromHref(window.location.pathname); + if (rel === null) rel = ''; + companionGET('/open-folder', { path: rel }).catch(function () { }); + }); }); } diff --git a/assets/page/main.html b/assets/page/main.html index d65f57a..cbf99fc 100644 --- a/assets/page/main.html +++ b/assets/page/main.html @@ -8,7 +8,7 @@
{{.SpecialContent}}
{{end}} {{if .Entries}} -

Files

+

Files

{{range .Entries}}
diff --git a/cmd/companion/commands.go b/cmd/companion/commands.go new file mode 100644 index 0000000..6d2c647 --- /dev/null +++ b/cmd/companion/commands.go @@ -0,0 +1,96 @@ +package main + +import ( + "errors" + "fmt" + "os/exec" + "runtime" + "strings" +) + +// commandDefaults holds the per-OS open-command templates used when the user +// hasn't overridden them in the config. +type commandDefaults struct { + OpenFile string + OpenFolder string +} + +func defaultCommands() commandDefaults { + if runtime.GOOS == "windows" { + return commandDefaults{ + OpenFile: `cmd /c start "" "{path}"`, + OpenFolder: `explorer.exe "{path}"`, + } + } + return commandDefaults{ + OpenFile: `xdg-open "{path}"`, + OpenFolder: `xdg-open "{path}"`, + } +} + +// resolveOpenCommand returns the user-configured command if non-blank, else +// the platform default. +func resolveOpenCommand(configured, fallback string) string { + if strings.TrimSpace(configured) != "" { + return configured + } + return fallback +} + +// runOpenCommand tokenizes template, substitutes {path} with the resolved +// path (appending it if the placeholder is missing), and starts the command. +func runOpenCommand(template, path string) error { + tokens, err := tokenizeCommand(template) + if err != nil { + return fmt.Errorf("parse command: %w", err) + } + if len(tokens) == 0 { + return errors.New("command is empty") + } + sawPath := false + for i, t := range tokens { + if strings.Contains(t, "{path}") { + tokens[i] = strings.ReplaceAll(t, "{path}", path) + sawPath = true + } + } + if !sawPath { + tokens = append(tokens, path) + } + return exec.Command(tokens[0], tokens[1:]...).Start() +} + +// tokenizeCommand splits a command-line string into argv tokens, honouring +// double-quoted segments. An empty pair "" yields an empty argument — needed +// for Windows `cmd /c start "" file`, where the empty quotes are the title. +func tokenizeCommand(s string) ([]string, error) { + var tokens []string + var cur strings.Builder + inQuote := false + inToken := false + for i := 0; i < len(s); i++ { + c := s[i] + if c == '"' { + inQuote = !inQuote + inToken = true + continue + } + if !inQuote && (c == ' ' || c == '\t') { + if inToken { + tokens = append(tokens, cur.String()) + cur.Reset() + inToken = false + } + continue + } + cur.WriteByte(c) + inToken = true + } + if inQuote { + return nil, errors.New("unclosed quote") + } + if inToken { + tokens = append(tokens, cur.String()) + } + return tokens, nil +} diff --git a/cmd/companion/config.go b/cmd/companion/config.go index a2ba4e2..ea94e1a 100644 --- a/cmd/companion/config.go +++ b/cmd/companion/config.go @@ -12,9 +12,11 @@ import ( const defaultPort = 17680 type config struct { - WikiRoot string `json:"wikiRoot"` - AllowedOrigins []string `json:"allowedOrigins"` - Port int `json:"port,omitempty"` + WikiRoot string `json:"wikiRoot"` + AllowedOrigins []string `json:"allowedOrigins"` + Port int `json:"port,omitempty"` + OpenFileCommand string `json:"openFileCommand,omitempty"` + OpenFolderCommand string `json:"openFolderCommand,omitempty"` } // configPath returns the platform-conventional config path. diff --git a/cmd/companion/config.html b/cmd/companion/config.html index 7221630..df5b67e 100644 --- a/cmd/companion/config.html +++ b/cmd/companion/config.html @@ -49,6 +49,17 @@ margin-top: 1.25rem; } button:hover { background: var(--primary-hover); } + .btn-small { + padding: 0.4rem 0.75rem; + font-size: 0.8rem; + margin-top: 0; + } + .input-row { + display: flex; + gap: 0.5rem; + align-items: stretch; + } + .input-row input { flex: 1; } .notice { background: var(--bg-panel); border-left: 3px solid var(--secondary); @@ -58,6 +69,23 @@ .meta { margin-top: 2rem; font-size: 0.8rem; color: var(--text-muted); border-top: 1px dashed var(--secondary); padding-top: 0.75rem; } .meta div { margin: 0.2rem 0; } code { color: var(--link); word-break: break-all; } + .log { + margin-top: 2rem; + border-top: 1px dashed var(--secondary); + padding-top: 0.75rem; + } + .log h2 { font-size: 0.95rem; margin: 0 0 0.5rem; } + .log pre { + background: var(--bg-panel); + border: 1px solid var(--secondary); + margin: 0; + padding: 0.5rem 0.75rem; + max-height: 20rem; + overflow: auto; + font-size: 0.8rem; + white-space: pre-wrap; + word-break: break-all; + } @@ -78,13 +106,48 @@
One origin per line (scheme + host + optional port, no trailing slash). Only browser tabs from these origins can ask the companion to open files.
+ +
+ + +
+
Run when the wiki asks to open a file. Use {{`{path}`}} for the resolved file path. Leave blank to use the default. Default: {{.DefaultOpenFileCommand}}
+ + +
+ + +
+
Run when the wiki asks to reveal a folder. Default: {{.DefaultOpenFolderCommand}}
+ + +
Config file: {{.ConfigPath}}
+
Log file: {{.LogPath}}
Port is set in the config file only; restart after editing.
+ +
+

Log (last 50 lines)

+ {{if .LogError}} +
Could not read log: {{.LogError}}
+ {{else if .LogTail}} +
{{.LogTail}}
+ {{else}} +
Log is empty.
+ {{end}} +
diff --git a/cmd/companion/log.go b/cmd/companion/log.go new file mode 100644 index 0000000..940612e --- /dev/null +++ b/cmd/companion/log.go @@ -0,0 +1,65 @@ +package main + +import ( + "io" + "log" + "os" + "path/filepath" + "strings" +) + +// setupFileLogging tees the standard logger to a file alongside the config. +// Returns the log file path. Stderr is kept as a secondary sink so dev runs +// (linux, console) still print, while windowsgui builds rely on the file. +func setupFileLogging(cfgDir string) (string, error) { + if err := os.MkdirAll(cfgDir, 0755); err != nil { + return "", err + } + p := filepath.Join(cfgDir, "companion.log") + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return "", err + } + log.SetOutput(io.MultiWriter(f, os.Stderr)) + return p, nil +} + +// tailLog returns the last n lines of the log file. Reads only the trailing +// chunk of the file so the cost is bounded regardless of log size. +func tailLog(p string, n int) ([]string, error) { + const tailBytes = 32 * 1024 + f, err := os.Open(p) + if err != nil { + return nil, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + return nil, err + } + size := info.Size() + var off int64 + if size > tailBytes { + off = size - tailBytes + } + buf := make([]byte, size-off) + if _, err := f.ReadAt(buf, off); err != nil && err != io.EOF { + return nil, err + } + s := string(buf) + // Drop the (likely partial) first line when we didn't start at 0. + if off > 0 { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[i+1:] + } + } + s = strings.TrimRight(s, "\n") + if s == "" { + return nil, nil + } + lines := strings.Split(s, "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return lines, nil +} diff --git a/cmd/companion/main.go b/cmd/companion/main.go index da748d0..761a755 100644 --- a/cmd/companion/main.go +++ b/cmd/companion/main.go @@ -3,21 +3,33 @@ package main import ( "flag" "log" + "path/filepath" ) -const version = "0.1.0" +const version = "1" func main() { flag.Parse() - cfg, cfgPath, err := loadOrInitConfig() + cfgPath, err := configPath() + if err != nil { + log.Fatalf("config path: %v", err) + } + logPath, err := setupFileLogging(filepath.Dir(cfgPath)) + if err != nil { + log.Fatalf("log setup: %v", err) + } + + log.Printf("datascape-companion %s", version) + log.Printf("log file: %s", logPath) + log.Printf("config file: %s", cfgPath) + + cfg, _, err := loadOrInitConfig() if err != nil { log.Fatalf("config: %v", err) } - log.Printf("datascape-companion %s", version) - log.Printf("config file: %s", cfgPath) - srv := newServer(cfg, cfgPath) + srv := newServer(cfg, cfgPath, logPath) log.Printf("listening on http://127.0.0.1:%d", cfg.Port) log.Printf("settings page: http://127.0.0.1:%d/config", cfg.Port) if err := srv.run(); err != nil { diff --git a/cmd/companion/open_linux.go b/cmd/companion/open_linux.go deleted file mode 100644 index b60f9e0..0000000 --- a/cmd/companion/open_linux.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build linux - -package main - -import "os/exec" - -// openOSPath delegates to xdg-open for both files and folders. -func openOSPath(p string, isFolder bool) error { - return exec.Command("xdg-open", p).Start() -} diff --git a/cmd/companion/open_windows.go b/cmd/companion/open_windows.go deleted file mode 100644 index 607d62a..0000000 --- a/cmd/companion/open_windows.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build windows - -package main - -import "os/exec" - -// openOSPath asks Windows to open a file in the default app, or a folder in -// Explorer. `cmd /c start` handles default-handler dispatch for files; the -// empty "" argument is the window title required by start. -func openOSPath(p string, isFolder bool) error { - if isFolder { - return exec.Command("explorer.exe", p).Start() - } - return exec.Command("cmd", "/c", "start", "", p).Start() -} diff --git a/cmd/companion/server.go b/cmd/companion/server.go index 4e7c878..aaa6b74 100644 --- a/cmd/companion/server.go +++ b/cmd/companion/server.go @@ -21,11 +21,12 @@ type server struct { mu sync.Mutex cfg *config cfgPath string + logPath string port int } -func newServer(cfg *config, cfgPath string) *server { - return &server{cfg: cfg, cfgPath: cfgPath, port: cfg.Port} +func newServer(cfg *config, cfgPath, logPath string) *server { + return &server{cfg: cfg, cfgPath: cfgPath, logPath: logPath, port: cfg.Port} } func (s *server) snapshot() config { @@ -131,7 +132,14 @@ func (s *server) handleOpen(w http.ResponseWriter, r *http.Request, isFolder boo writeJSONError(w, http.StatusBadRequest, "path is a directory") return } - if err := openOSPath(resolved, isFolder); err != nil { + defs := defaultCommands() + var template string + if isFolder { + template = resolveOpenCommand(cfg.OpenFolderCommand, defs.OpenFolder) + } else { + template = resolveOpenCommand(cfg.OpenFileCommand, defs.OpenFile) + } + if err := runOpenCommand(template, resolved); err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return } @@ -151,23 +159,44 @@ func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) { } type configPageData struct { - WikiRoot string - AllowedOrigins string - Port int - ConfigPath string - Version string - Notice string + WikiRoot string + AllowedOrigins string + Port int + ConfigPath string + LogPath string + LogTail string + LogError string + Version string + Notice string + OpenFileCommand string + OpenFolderCommand string + DefaultOpenFileCommand string + DefaultOpenFolderCommand string } func (s *server) renderConfig(w http.ResponseWriter, notice string) { cfg := s.snapshot() + defs := defaultCommands() data := configPageData{ - WikiRoot: cfg.WikiRoot, - AllowedOrigins: strings.Join(cfg.AllowedOrigins, "\n"), - Port: cfg.Port, - ConfigPath: s.cfgPath, - Version: version, - Notice: notice, + WikiRoot: cfg.WikiRoot, + AllowedOrigins: strings.Join(cfg.AllowedOrigins, "\n"), + Port: cfg.Port, + ConfigPath: s.cfgPath, + LogPath: s.logPath, + Version: version, + Notice: notice, + OpenFileCommand: cfg.OpenFileCommand, + OpenFolderCommand: cfg.OpenFolderCommand, + DefaultOpenFileCommand: defs.OpenFile, + DefaultOpenFolderCommand: defs.OpenFolder, + } + if s.logPath != "" { + lines, err := tailLog(s.logPath, 50) + if err != nil { + data.LogError = err.Error() + } else { + data.LogTail = strings.Join(lines, "\n") + } } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := configTmpl.Execute(w, data); err != nil { @@ -200,10 +229,24 @@ func (s *server) saveConfig(w http.ResponseWriter, r *http.Request) { origins = []string{} } + openFileCmd := strings.TrimSpace(r.FormValue("openFileCommand")) + openFolderCmd := strings.TrimSpace(r.FormValue("openFolderCommand")) + defs := defaultCommands() + // Persist as blank when the user submits the default verbatim, so the + // config file stays clean and future default changes propagate. + if openFileCmd == defs.OpenFile { + openFileCmd = "" + } + if openFolderCmd == defs.OpenFolder { + openFolderCmd = "" + } + s.mu.Lock() newCfg := *s.cfg newCfg.WikiRoot = wikiRoot newCfg.AllowedOrigins = origins + newCfg.OpenFileCommand = openFileCmd + newCfg.OpenFolderCommand = openFolderCmd if err := writeConfigFile(s.cfgPath, &newCfg); err != nil { s.mu.Unlock() http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError)