Allow customizing companion commands

This commit is contained in:
2026-05-08 21:25:41 +02:00
parent 7209aebc62
commit 30b5e36cd7
11 changed files with 317 additions and 59 deletions
+4 -3
View File
@@ -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)
+8 -7
View File
@@ -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 () { });
});
});
}
+1 -1
View File
@@ -8,7 +8,7 @@
<div class="content">{{.SpecialContent}}</div>
{{end}}
{{if .Entries}}
<h2 id="files">Files</h2>
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button></h2>
<div class="listing">
{{range .Entries}}
<div class="listing-item" data-path="{{.URL}}">
+96
View File
@@ -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
}
+5 -3
View File
@@ -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.
+63
View File
@@ -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;
}
</style>
</head>
<body>
@@ -78,13 +106,48 @@
<textarea id="allowedOrigins" name="allowedOrigins" placeholder="https://wiki.example.lan&#10;http://192.168.1.10:8080">{{.AllowedOrigins}}</textarea>
<div class="help">One origin per line (scheme + host + optional port, no trailing slash). Only browser tabs from these origins can ask the companion to open files.</div>
<label for="openFileCommand">Open-file command</label>
<div class="input-row">
<input id="openFileCommand" name="openFileCommand" type="text" value="{{.OpenFileCommand}}" placeholder="{{.DefaultOpenFileCommand}}">
<button type="button" class="btn-small" data-reset="openFileCommand" data-default="{{.DefaultOpenFileCommand}}">RESET</button>
</div>
<div class="help">Run when the wiki asks to open a file. Use <code>{{`{path}`}}</code> for the resolved file path. Leave blank to use the default. Default: <code>{{.DefaultOpenFileCommand}}</code></div>
<label for="openFolderCommand">Open-folder command</label>
<div class="input-row">
<input id="openFolderCommand" name="openFolderCommand" type="text" value="{{.OpenFolderCommand}}" placeholder="{{.DefaultOpenFolderCommand}}">
<button type="button" class="btn-small" data-reset="openFolderCommand" data-default="{{.DefaultOpenFolderCommand}}">RESET</button>
</div>
<div class="help">Run when the wiki asks to reveal a folder. Default: <code>{{.DefaultOpenFolderCommand}}</code></div>
<button type="submit">SAVE</button>
</form>
<script>
document.querySelectorAll('button[data-reset]').forEach(function (btn) {
btn.addEventListener('click', function () {
var input = document.getElementById(btn.dataset.reset);
if (input) input.value = btn.dataset.default;
});
});
</script>
<div class="meta">
<div>Config file: <code>{{.ConfigPath}}</code></div>
<div>Log file: <code>{{.LogPath}}</code></div>
<div>Port is set in the config file only; restart after editing.</div>
</div>
<div class="log">
<h2>Log (last 50 lines)</h2>
{{if .LogError}}
<div class="muted">Could not read log: {{.LogError}}</div>
{{else if .LogTail}}
<pre>{{.LogTail}}</pre>
{{else}}
<div class="muted">Log is empty.</div>
{{end}}
</div>
</main>
</body>
</html>
+65
View File
@@ -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
}
+17 -5
View File
@@ -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 {
-10
View File
@@ -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()
}
-15
View File
@@ -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()
}
+58 -15
View File
@@ -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)