Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5122e9cd87 | |||
| ed9291a7ad | |||
| 7ab00fc518 | |||
| 051abae390 | |||
| dd5142a48a | |||
| 2c3db6ea25 | |||
| 063bdccf1b | |||
| 67cbcd85bd | |||
| 3d5f52729f | |||
| ed3a547e54 |
61
cmd/mcq/client.go
Normal file
61
cmd/mcq/client.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wntrmute.dev/mc/mcq/internal/client"
|
||||
)
|
||||
|
||||
var clientFlags struct {
|
||||
server string
|
||||
token string
|
||||
}
|
||||
|
||||
func addClientFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().StringVar(&clientFlags.server, "server", "", "MCQ server URL (env: MCQ_SERVER)")
|
||||
cmd.Flags().StringVar(&clientFlags.token, "token", "", "auth token (env: MCQ_TOKEN)")
|
||||
}
|
||||
|
||||
func newClient() (*client.Client, error) {
|
||||
server := clientFlags.server
|
||||
if server == "" {
|
||||
server = os.Getenv("MCQ_SERVER")
|
||||
}
|
||||
if server == "" {
|
||||
return nil, fmt.Errorf("server URL required: use --server or MCQ_SERVER")
|
||||
}
|
||||
|
||||
token := clientFlags.token
|
||||
if token == "" {
|
||||
token = os.Getenv("MCQ_TOKEN")
|
||||
}
|
||||
if token == "" {
|
||||
token = readCachedToken()
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("auth token required: use --token, MCQ_TOKEN, or run 'mcq login'")
|
||||
}
|
||||
|
||||
return client.New(server, token), nil
|
||||
}
|
||||
|
||||
func tokenPath() string {
|
||||
if dir, err := os.UserConfigDir(); err == nil {
|
||||
return filepath.Join(dir, "mcq", "token")
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, ".config", "mcq", "token")
|
||||
}
|
||||
|
||||
func readCachedToken() string {
|
||||
data, err := os.ReadFile(tokenPath())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
32
cmd/mcq/delete.go
Normal file
32
cmd/mcq/delete.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func deleteCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <slug>",
|
||||
Short: "Delete a document from the queue",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.DeleteDocument(context.Background(), args[0]); err != nil {
|
||||
return fmt.Errorf("delete document: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("deleted %s\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
addClientFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
34
cmd/mcq/get.go
Normal file
34
cmd/mcq/get.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func getCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <slug>",
|
||||
Short: "Get a document's markdown body",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
doc, err := c.GetDocument(context.Background(), args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("get document: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprint(os.Stdout, doc.Body)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
addClientFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
44
cmd/mcq/list.go
Normal file
44
cmd/mcq/list.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func listCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List documents in the queue",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
docs, err := c.ListDocuments(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("list documents: %w", err)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(w, "SLUG\tTITLE\tPUSHED BY\tPUSHED AT\tREAD")
|
||||
for _, d := range docs {
|
||||
read := "no"
|
||||
if d.Read {
|
||||
read = "yes"
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.Slug, d.Title, d.PushedBy, d.PushedAt, read)
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
addClientFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
71
cmd/mcq/login.go
Normal file
71
cmd/mcq/login.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"git.wntrmute.dev/mc/mcq/internal/client"
|
||||
)
|
||||
|
||||
func loginCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "login",
|
||||
Short: "Authenticate with MCIAS and cache a token",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
server := clientFlags.server
|
||||
if server == "" {
|
||||
server = os.Getenv("MCQ_SERVER")
|
||||
}
|
||||
if server == "" {
|
||||
return fmt.Errorf("server URL required: use --server or MCQ_SERVER")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Print("Username: ")
|
||||
username, _ := reader.ReadString('\n')
|
||||
username = strings.TrimSpace(username)
|
||||
|
||||
fmt.Print("Password: ")
|
||||
passBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
password := string(passBytes)
|
||||
|
||||
fmt.Print("TOTP code (blank if none): ")
|
||||
totpCode, _ := reader.ReadString('\n')
|
||||
totpCode = strings.TrimSpace(totpCode)
|
||||
|
||||
c := client.New(server, "")
|
||||
token, err := c.Login(context.Background(), username, password, totpCode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
path := tokenPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(token+"\n"), 0600); err != nil {
|
||||
return fmt.Errorf("cache token: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("token cached to %s\n", path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&clientFlags.server, "server", "", "MCQ server URL (env: MCQ_SERVER)")
|
||||
return cmd
|
||||
}
|
||||
@@ -16,6 +16,12 @@ func main() {
|
||||
}
|
||||
|
||||
root.AddCommand(serverCmd())
|
||||
root.AddCommand(pushCmd())
|
||||
root.AddCommand(listCmd())
|
||||
root.AddCommand(getCmd())
|
||||
root.AddCommand(deleteCmd())
|
||||
root.AddCommand(loginCmd())
|
||||
root.AddCommand(mcpCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
|
||||
40
cmd/mcq/mcp.go
Normal file
40
cmd/mcq/mcp.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wntrmute.dev/mc/mcq/internal/client"
|
||||
"git.wntrmute.dev/mc/mcq/internal/mcpserver"
|
||||
)
|
||||
|
||||
func mcpCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "Start MCP stdio server for Claude integration",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
return runMCP()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runMCP() error {
|
||||
serverURL := os.Getenv("MCQ_SERVER")
|
||||
if serverURL == "" {
|
||||
return fmt.Errorf("MCQ_SERVER environment variable required")
|
||||
}
|
||||
token := os.Getenv("MCQ_TOKEN")
|
||||
if token == "" {
|
||||
return fmt.Errorf("MCQ_TOKEN environment variable required")
|
||||
}
|
||||
|
||||
c := client.New(serverURL, token)
|
||||
mcpSrv := mcpserver.New(c, version)
|
||||
stdioSrv := server.NewStdioServer(mcpSrv)
|
||||
return stdioSrv.Listen(context.Background(), os.Stdin, os.Stdout)
|
||||
}
|
||||
58
cmd/mcq/push.go
Normal file
58
cmd/mcq/push.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.wntrmute.dev/mc/mcq/internal/client"
|
||||
)
|
||||
|
||||
func pushCmd() *cobra.Command {
|
||||
var title string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "push <slug> <file|->",
|
||||
Short: "Push a document to the queue",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(_ *cobra.Command, args []string) error {
|
||||
slug := args[0]
|
||||
source := args[1]
|
||||
|
||||
var body []byte
|
||||
var err error
|
||||
if source == "-" {
|
||||
body, err = io.ReadAll(os.Stdin)
|
||||
} else {
|
||||
body, err = os.ReadFile(source)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read input: %w", err)
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = client.ExtractTitle(string(body), slug)
|
||||
}
|
||||
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
doc, err := c.PutDocument(context.Background(), slug, title, string(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("push document: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("pushed %s (%s)\n", doc.Slug, doc.Title)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&title, "title", "", "document title (default: first H1 heading, or slug)")
|
||||
addClientFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
@@ -77,6 +77,9 @@ func runServer(configPath string) error {
|
||||
wsCfg := webserver.Config{
|
||||
ServiceName: cfg.MCIAS.ServiceName,
|
||||
Tags: cfg.MCIAS.Tags,
|
||||
MciasURL: cfg.MCIAS.ServerURL,
|
||||
CACert: cfg.MCIAS.CACert,
|
||||
RedirectURI: cfg.SSO.RedirectURI,
|
||||
}
|
||||
webSrv, err := webserver.New(wsCfg, database, authClient, logger)
|
||||
if err != nil {
|
||||
|
||||
7
go.mod
7
go.mod
@@ -3,13 +3,15 @@ module git.wntrmute.dev/mc/mcq
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0
|
||||
git.wntrmute.dev/mc/mcdsl v1.7.0
|
||||
github.com/alecthomas/chroma/v2 v2.18.0
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/mark3labs/mcp-go v0.46.0
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/yuin/goldmark v1.7.12
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
golang.org/x/term v0.41.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
@@ -17,12 +19,15 @@ require (
|
||||
require (
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
|
||||
28
go.sum
28
go.sum
@@ -1,5 +1,5 @@
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
|
||||
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
|
||||
git.wntrmute.dev/mc/mcdsl v1.7.0 h1:dAh2SGdzjhz0H66i3KAMDm1eRYYgMaxqQ0Pj5NzF7fc=
|
||||
git.wntrmute.dev/mc/mcdsl v1.7.0/go.mod h1:MhYahIu7Sg53lE2zpQ20nlrsoNRjQzOJBAlCmom2wJc=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
@@ -12,6 +12,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
@@ -19,6 +20,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
@@ -29,6 +32,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
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=
|
||||
@@ -39,22 +44,37 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mark3labs/mcp-go v0.46.0 h1:8KRibF4wcKejbLsHxCA/QBVUr5fQ9nwz/n8lGqmaALo=
|
||||
github.com/mark3labs/mcp-go v0.46.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
|
||||
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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
|
||||
github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
@@ -82,6 +102,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
@@ -96,6 +118,8 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
|
||||
223
internal/client/client.go
Normal file
223
internal/client/client.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Package client provides an HTTP client for the MCQ REST API.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
)
|
||||
|
||||
// Document represents a document returned by the MCQ API.
|
||||
type Document struct {
|
||||
ID int64 `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
PushedBy string `json:"pushed_by"`
|
||||
PushedAt string `json:"pushed_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
// Client talks to a remote MCQ server's REST API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Option configures a Client.
|
||||
type Option func(*Client)
|
||||
|
||||
// WithHTTPClient sets a custom HTTP client (useful for testing).
|
||||
func WithHTTPClient(hc *http.Client) Option {
|
||||
return func(c *Client) {
|
||||
c.httpClient = hc
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a Client. baseURL is the MCQ server URL (e.g. "https://mcq.example.com:8443").
|
||||
// token is a Bearer token from MCIAS login.
|
||||
func New(baseURL, token string, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
httpClient: http.DefaultClient,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, method, path string, body any) (*http.Response, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusUnauthorized:
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrUnauthorized
|
||||
case http.StatusForbidden:
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrForbidden
|
||||
case http.StatusNotFound:
|
||||
_ = resp.Body.Close()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func decodeJSON[T any](resp *http.Response) (T, error) {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var v T
|
||||
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
|
||||
return v, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Login authenticates with MCIAS and returns a bearer token.
|
||||
func (c *Client) Login(ctx context.Context, username, password, totpCode string) (string, error) {
|
||||
resp, err := c.do(ctx, http.MethodPost, "/v1/auth/login", map[string]string{
|
||||
"username": username,
|
||||
"password": password,
|
||||
"totp_code": totpCode,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result, err := decodeJSON[struct {
|
||||
Token string `json:"token"`
|
||||
}](resp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// ListDocuments returns all documents in the queue.
|
||||
func (c *Client) ListDocuments(ctx context.Context) ([]Document, error) {
|
||||
resp, err := c.do(ctx, http.MethodGet, "/v1/documents", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := decodeJSON[struct {
|
||||
Documents []Document `json:"documents"`
|
||||
}](resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Documents, nil
|
||||
}
|
||||
|
||||
// GetDocument fetches a single document by slug.
|
||||
func (c *Client) GetDocument(ctx context.Context, slug string) (*Document, error) {
|
||||
resp, err := c.do(ctx, http.MethodGet, "/v1/documents/"+slug, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := decodeJSON[Document](resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// PutDocument creates or replaces a document.
|
||||
func (c *Client) PutDocument(ctx context.Context, slug, title, body string) (*Document, error) {
|
||||
resp, err := c.do(ctx, http.MethodPut, "/v1/documents/"+slug, map[string]string{
|
||||
"title": title,
|
||||
"body": body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := decodeJSON[Document](resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// DeleteDocument removes a document by slug.
|
||||
func (c *Client) DeleteDocument(ctx context.Context, slug string) error {
|
||||
resp, err := c.do(ctx, http.MethodDelete, "/v1/documents/"+slug, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRead marks a document as read.
|
||||
func (c *Client) MarkRead(ctx context.Context, slug string) (*Document, error) {
|
||||
resp, err := c.do(ctx, http.MethodPost, "/v1/documents/"+slug+"/read", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := decodeJSON[Document](resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// MarkUnread marks a document as unread.
|
||||
func (c *Client) MarkUnread(ctx context.Context, slug string) (*Document, error) {
|
||||
resp, err := c.do(ctx, http.MethodPost, "/v1/documents/"+slug+"/unread", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := decodeJSON[Document](resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
var h1Re = regexp.MustCompile(`(?m)^#\s+(.+)$`)
|
||||
|
||||
// ExtractTitle returns the first H1 heading from markdown source.
|
||||
// If no H1 is found, it returns the fallback string.
|
||||
func ExtractTitle(markdown, fallback string) string {
|
||||
m := h1Re.FindStringSubmatch(markdown)
|
||||
if m == nil {
|
||||
return fallback
|
||||
}
|
||||
return strings.TrimSpace(m[1])
|
||||
}
|
||||
239
internal/client/client_test.go
Normal file
239
internal/client/client_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testServer(t *testing.T) (*httptest.Server, *Client) {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /v1/auth/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.Username == "admin" && req.Password == "pass" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": "tok123"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/documents", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"documents": []Document{
|
||||
{ID: 1, Slug: "test-doc", Title: "Test", Body: "# Test\nHello", PushedBy: "admin", PushedAt: "2026-01-01T00:00:00Z", Read: false},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "missing" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: "Test", Body: "# Test\nHello"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("PUT /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: req.Title, Body: req.Body, PushedBy: "admin"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("DELETE /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "missing" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /v1/documents/{slug}/read", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: "Test", Read: true})
|
||||
})
|
||||
|
||||
mux.HandleFunc("POST /v1/documents/{slug}/unread", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(Document{ID: 1, Slug: slug, Title: "Test", Read: false})
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
c := New(ts.URL, "tok123", WithHTTPClient(ts.Client()))
|
||||
return ts, c
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
c.token = "" // login doesn't need a pre-existing token
|
||||
|
||||
token, err := c.Login(context.Background(), "admin", "pass", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Login: %v", err)
|
||||
}
|
||||
if token != "tok123" {
|
||||
t.Fatalf("got token %q, want %q", token, "tok123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBadCredentials(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
c.token = ""
|
||||
|
||||
_, err := c.Login(context.Background(), "admin", "wrong", "")
|
||||
if err != ErrUnauthorized {
|
||||
t.Fatalf("got %v, want ErrUnauthorized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDocuments(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
docs, err := c.ListDocuments(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListDocuments: %v", err)
|
||||
}
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("got %d docs, want 1", len(docs))
|
||||
}
|
||||
if docs[0].Slug != "test-doc" {
|
||||
t.Fatalf("got slug %q, want %q", docs[0].Slug, "test-doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDocument(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
doc, err := c.GetDocument(context.Background(), "test-doc")
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocument: %v", err)
|
||||
}
|
||||
if doc.Slug != "test-doc" {
|
||||
t.Fatalf("got slug %q, want %q", doc.Slug, "test-doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDocumentNotFound(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
_, err := c.GetDocument(context.Background(), "missing")
|
||||
if err != ErrNotFound {
|
||||
t.Fatalf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutDocument(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
doc, err := c.PutDocument(context.Background(), "new-doc", "New Doc", "# New\nContent")
|
||||
if err != nil {
|
||||
t.Fatalf("PutDocument: %v", err)
|
||||
}
|
||||
if doc.Slug != "new-doc" {
|
||||
t.Fatalf("got slug %q, want %q", doc.Slug, "new-doc")
|
||||
}
|
||||
if doc.Title != "New Doc" {
|
||||
t.Fatalf("got title %q, want %q", doc.Title, "New Doc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocument(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
if err := c.DeleteDocument(context.Background(), "test-doc"); err != nil {
|
||||
t.Fatalf("DeleteDocument: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocumentNotFound(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
err := c.DeleteDocument(context.Background(), "missing")
|
||||
if err != ErrNotFound {
|
||||
t.Fatalf("got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkRead(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
doc, err := c.MarkRead(context.Background(), "test-doc")
|
||||
if err != nil {
|
||||
t.Fatalf("MarkRead: %v", err)
|
||||
}
|
||||
if !doc.Read {
|
||||
t.Fatal("expected doc to be marked read")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUnread(t *testing.T) {
|
||||
_, c := testServer(t)
|
||||
|
||||
doc, err := c.MarkUnread(context.Background(), "test-doc")
|
||||
if err != nil {
|
||||
t.Fatalf("MarkUnread: %v", err)
|
||||
}
|
||||
if doc.Read {
|
||||
t.Fatal("expected doc to be marked unread")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
markdown string
|
||||
fallback string
|
||||
want string
|
||||
}{
|
||||
{"h1 found", "# My Title\nSome content", "default", "My Title"},
|
||||
{"no h1", "Some content without heading", "default", "default"},
|
||||
{"h2 not matched", "## Subtitle\nContent", "default", "default"},
|
||||
{"h1 with spaces", "# Spaced Title \nContent", "default", "Spaced Title"},
|
||||
{"multiple h1s", "# First\n# Second", "default", "First"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ExtractTitle(tt.markdown, tt.fallback)
|
||||
if got != tt.want {
|
||||
t.Errorf("ExtractTitle() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenSent(t *testing.T) {
|
||||
var gotAuth string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"documents": []Document{}})
|
||||
}))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
c := New(ts.URL, "my-secret-token", WithHTTPClient(ts.Client()))
|
||||
_, _ = c.ListDocuments(context.Background())
|
||||
|
||||
if gotAuth != "Bearer my-secret-token" {
|
||||
t.Fatalf("got Authorization %q, want %q", gotAuth, "Bearer my-secret-token")
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,17 @@ type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
MCIAS mcdslauth.Config `toml:"mcias"`
|
||||
SSO SSOConfig `toml:"sso"`
|
||||
Log LogConfig `toml:"log"`
|
||||
}
|
||||
|
||||
// SSOConfig holds SSO redirect settings for the web UI.
|
||||
type SSOConfig struct {
|
||||
// RedirectURI is the callback URL that MCIAS redirects to after login.
|
||||
// Must exactly match the redirect_uri registered in MCIAS config.
|
||||
RedirectURI string `toml:"redirect_uri"`
|
||||
}
|
||||
|
||||
// ServerConfig holds HTTP/gRPC server settings. TLS fields are optional;
|
||||
// when empty, MCQ serves plain HTTP (for use behind mc-proxy L7).
|
||||
type ServerConfig struct {
|
||||
|
||||
153
internal/mcpserver/server.go
Normal file
153
internal/mcpserver/server.go
Normal file
@@ -0,0 +1,153 @@
|
||||
// Package mcpserver implements an MCP stdio server for MCQ.
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
|
||||
"git.wntrmute.dev/mc/mcq/internal/client"
|
||||
)
|
||||
|
||||
// New creates an MCP server backed by the given MCQ client.
|
||||
func New(c *client.Client, version string) *server.MCPServer {
|
||||
s := server.NewMCPServer("mcq", version,
|
||||
server.WithToolCapabilities(false),
|
||||
)
|
||||
|
||||
s.AddTool(pushDocumentTool(), pushDocumentHandler(c))
|
||||
s.AddTool(listDocumentsTool(), listDocumentsHandler(c))
|
||||
s.AddTool(getDocumentTool(), getDocumentHandler(c))
|
||||
s.AddTool(deleteDocumentTool(), deleteDocumentHandler(c))
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func pushDocumentTool() mcp.Tool {
|
||||
return mcp.NewTool("push_document",
|
||||
mcp.WithDescription("Push a markdown document to the MCQ reading queue. If a document with the same slug exists, it will be replaced and marked as unread."),
|
||||
mcp.WithString("slug", mcp.Description("Unique identifier for the document (used in URLs)"), mcp.Required()),
|
||||
mcp.WithString("title", mcp.Description("Document title"), mcp.Required()),
|
||||
mcp.WithString("body", mcp.Description("Document body in markdown format"), mcp.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
func pushDocumentHandler(c *client.Client) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
slug, err := request.RequireString("slug")
|
||||
if err != nil {
|
||||
return toolError("slug is required"), nil
|
||||
}
|
||||
title, err := request.RequireString("title")
|
||||
if err != nil {
|
||||
return toolError("title is required"), nil
|
||||
}
|
||||
body, err := request.RequireString("body")
|
||||
if err != nil {
|
||||
return toolError("body is required"), nil
|
||||
}
|
||||
|
||||
doc, err := c.PutDocument(ctx, slug, title, body)
|
||||
if err != nil {
|
||||
return toolError(fmt.Sprintf("failed to push document: %v", err)), nil
|
||||
}
|
||||
|
||||
return toolText(fmt.Sprintf("Pushed document %q (%s) to queue.", doc.Title, doc.Slug)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func listDocumentsTool() mcp.Tool {
|
||||
return mcp.NewTool("list_documents",
|
||||
mcp.WithDescription("List all documents in the MCQ reading queue."),
|
||||
)
|
||||
}
|
||||
|
||||
func listDocumentsHandler(c *client.Client) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
docs, err := c.ListDocuments(ctx)
|
||||
if err != nil {
|
||||
return toolError(fmt.Sprintf("failed to list documents: %v", err)), nil
|
||||
}
|
||||
|
||||
if len(docs) == 0 {
|
||||
return toolText("No documents in the queue."), nil
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d document(s) in queue:\n\n", len(docs))
|
||||
for _, d := range docs {
|
||||
read := "unread"
|
||||
if d.Read {
|
||||
read = "read"
|
||||
}
|
||||
fmt.Fprintf(&b, "- **%s** (`%s`) — pushed by %s at %s [%s]\n", d.Title, d.Slug, d.PushedBy, d.PushedAt, read)
|
||||
}
|
||||
|
||||
return toolText(b.String()), nil
|
||||
}
|
||||
}
|
||||
|
||||
func getDocumentTool() mcp.Tool {
|
||||
return mcp.NewTool("get_document",
|
||||
mcp.WithDescription("Get a document's markdown content from the MCQ reading queue."),
|
||||
mcp.WithString("slug", mcp.Description("Document slug"), mcp.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
func getDocumentHandler(c *client.Client) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
slug, err := request.RequireString("slug")
|
||||
if err != nil {
|
||||
return toolError("slug is required"), nil
|
||||
}
|
||||
|
||||
doc, err := c.GetDocument(ctx, slug)
|
||||
if err != nil {
|
||||
return toolError(fmt.Sprintf("failed to get document: %v", err)), nil
|
||||
}
|
||||
|
||||
return toolText(doc.Body), nil
|
||||
}
|
||||
}
|
||||
|
||||
func deleteDocumentTool() mcp.Tool {
|
||||
return mcp.NewTool("delete_document",
|
||||
mcp.WithDescription("Delete a document from the MCQ reading queue."),
|
||||
mcp.WithString("slug", mcp.Description("Document slug"), mcp.Required()),
|
||||
)
|
||||
}
|
||||
|
||||
func deleteDocumentHandler(c *client.Client) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
slug, err := request.RequireString("slug")
|
||||
if err != nil {
|
||||
return toolError("slug is required"), nil
|
||||
}
|
||||
|
||||
if err := c.DeleteDocument(ctx, slug); err != nil {
|
||||
return toolError(fmt.Sprintf("failed to delete document: %v", err)), nil
|
||||
}
|
||||
|
||||
return toolText(fmt.Sprintf("Deleted document %q from queue.", slug)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func toolText(text string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.TextContent{Type: "text", Text: text},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func toolError(msg string) *mcp.CallToolResult {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
mcp.TextContent{Type: "text", Text: msg},
|
||||
},
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
171
internal/mcpserver/server_test.go
Normal file
171
internal/mcpserver/server_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package mcpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"git.wntrmute.dev/mc/mcq/internal/client"
|
||||
)
|
||||
|
||||
func testClient(t *testing.T) *client.Client {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /v1/documents", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"documents": []client.Document{
|
||||
{ID: 1, Slug: "doc-1", Title: "First Doc", Body: "# First\nContent", PushedBy: "admin", PushedAt: "2026-01-01T00:00:00Z"},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "missing" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(client.Document{ID: 1, Slug: slug, Title: "Test Doc", Body: "# Test\nContent"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("PUT /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(client.Document{ID: 1, Slug: slug, Title: req.Title, Body: req.Body, PushedBy: "admin"})
|
||||
})
|
||||
|
||||
mux.HandleFunc("DELETE /v1/documents/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "missing" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(mux)
|
||||
t.Cleanup(ts.Close)
|
||||
return client.New(ts.URL, "test-token", client.WithHTTPClient(ts.Client()))
|
||||
}
|
||||
|
||||
func TestPushDocumentHandler(t *testing.T) {
|
||||
c := testClient(t)
|
||||
handler := pushDocumentHandler(c)
|
||||
|
||||
result, err := handler(context.Background(), mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: "push_document",
|
||||
Arguments: map[string]any{"slug": "test-doc", "title": "Test", "body": "# Test\nContent"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("push handler: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected tool error: %v", result.Content)
|
||||
}
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "test-doc") {
|
||||
t.Fatalf("expected slug in response, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDocumentsHandler(t *testing.T) {
|
||||
c := testClient(t)
|
||||
handler := listDocumentsHandler(c)
|
||||
|
||||
result, err := handler(context.Background(), mcp.CallToolRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("list handler: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected tool error: %v", result.Content)
|
||||
}
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "doc-1") {
|
||||
t.Fatalf("expected doc slug in response, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDocumentHandler(t *testing.T) {
|
||||
c := testClient(t)
|
||||
handler := getDocumentHandler(c)
|
||||
|
||||
result, err := handler(context.Background(), mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: "get_document",
|
||||
Arguments: map[string]any{"slug": "test-doc"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get handler: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected tool error: %v", result.Content)
|
||||
}
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "# Test") {
|
||||
t.Fatalf("expected markdown body, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocumentHandler(t *testing.T) {
|
||||
c := testClient(t)
|
||||
handler := deleteDocumentHandler(c)
|
||||
|
||||
result, err := handler(context.Background(), mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: "delete_document",
|
||||
Arguments: map[string]any{"slug": "test-doc"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete handler: %v", err)
|
||||
}
|
||||
if result.IsError {
|
||||
t.Fatalf("unexpected tool error: %v", result.Content)
|
||||
}
|
||||
text := result.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "Deleted") {
|
||||
t.Fatalf("expected deletion confirmation, got: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocumentNotFound(t *testing.T) {
|
||||
c := testClient(t)
|
||||
handler := deleteDocumentHandler(c)
|
||||
|
||||
result, err := handler(context.Background(), mcp.CallToolRequest{
|
||||
Params: mcp.CallToolParams{
|
||||
Name: "delete_document",
|
||||
Arguments: map[string]any{"slug": "missing"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("delete handler: %v", err)
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Fatal("expected tool error for missing document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreatesServer(t *testing.T) {
|
||||
c := testClient(t)
|
||||
s := New(c, "test")
|
||||
if s == nil {
|
||||
t.Fatal("expected non-nil server")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"git.wntrmute.dev/mc/mcdsl/auth"
|
||||
"git.wntrmute.dev/mc/mcdsl/csrf"
|
||||
mcdsso "git.wntrmute.dev/mc/mcdsl/sso"
|
||||
"git.wntrmute.dev/mc/mcdsl/web"
|
||||
|
||||
mcqweb "git.wntrmute.dev/mc/mcq/web"
|
||||
@@ -25,6 +26,11 @@ const cookieName = "mcq_session"
|
||||
type Config struct {
|
||||
ServiceName string
|
||||
Tags []string
|
||||
// SSO fields — when RedirectURI is non-empty, the web UI uses SSO instead
|
||||
// of the direct username/password login form.
|
||||
MciasURL string
|
||||
CACert string
|
||||
RedirectURI string
|
||||
}
|
||||
|
||||
// Server is the MCQ web UI server.
|
||||
@@ -32,6 +38,7 @@ type Server struct {
|
||||
db *db.DB
|
||||
auth *auth.Authenticator
|
||||
csrf *csrf.Protect
|
||||
ssoClient *mcdsso.Client
|
||||
render *render.Renderer
|
||||
logger *slog.Logger
|
||||
config Config
|
||||
@@ -45,20 +52,43 @@ func New(cfg Config, database *db.DB, authenticator *auth.Authenticator, logger
|
||||
}
|
||||
csrfProtect := csrf.New(csrfSecret, "_csrf", "csrf_token")
|
||||
|
||||
return &Server{
|
||||
s := &Server{
|
||||
db: database,
|
||||
auth: authenticator,
|
||||
csrf: csrfProtect,
|
||||
render: render.New(),
|
||||
logger: logger,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create SSO client if the service has an SSO redirect_uri configured.
|
||||
if cfg.RedirectURI != "" {
|
||||
ssoClient, err := mcdsso.New(mcdsso.Config{
|
||||
MciasURL: cfg.MciasURL,
|
||||
ClientID: "mcq",
|
||||
RedirectURI: cfg.RedirectURI,
|
||||
CACert: cfg.CACert,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create SSO client: %w", err)
|
||||
}
|
||||
s.ssoClient = ssoClient
|
||||
logger.Info("SSO enabled: redirecting to MCIAS for login", "mcias_url", cfg.MciasURL)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RegisterRoutes adds web UI routes to the given router.
|
||||
func (s *Server) RegisterRoutes(r chi.Router) {
|
||||
if s.ssoClient != nil {
|
||||
r.Get("/login", s.handleSSOLogin)
|
||||
r.Get("/sso/redirect", s.handleSSORedirect)
|
||||
r.Get("/sso/callback", s.handleSSOCallback)
|
||||
} else {
|
||||
r.Get("/login", s.handleLoginPage)
|
||||
r.Post("/login", s.csrf.Middleware(http.HandlerFunc(s.handleLogin)).ServeHTTP)
|
||||
}
|
||||
r.Get("/static/*", http.FileServer(http.FS(mcqweb.FS)).ServeHTTP)
|
||||
|
||||
// Authenticated routes.
|
||||
@@ -70,6 +100,7 @@ func (s *Server) RegisterRoutes(r chi.Router) {
|
||||
r.Get("/d/{slug}", s.handleRead)
|
||||
r.Post("/d/{slug}/read", s.handleMarkRead)
|
||||
r.Post("/d/{slug}/unread", s.handleMarkUnread)
|
||||
r.Post("/d/{slug}/delete", s.handleDelete)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
})
|
||||
}
|
||||
@@ -79,6 +110,7 @@ type pageData struct {
|
||||
Error string
|
||||
Title string
|
||||
Content any
|
||||
SSO bool
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -100,6 +132,32 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSSOLogin renders a landing page with a "Sign in with MCIAS" button.
|
||||
func (s *Server) handleSSOLogin(w http.ResponseWriter, r *http.Request) {
|
||||
web.RenderTemplate(w, mcqweb.FS, "login.html", pageData{SSO: true}, s.csrf.TemplateFunc(w))
|
||||
}
|
||||
|
||||
// handleSSORedirect initiates the SSO redirect to MCIAS.
|
||||
func (s *Server) handleSSORedirect(w http.ResponseWriter, r *http.Request) {
|
||||
if err := mcdsso.RedirectToLogin(w, r, s.ssoClient, "mcq"); err != nil {
|
||||
s.logger.Error("sso: redirect to login", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSSOCallback exchanges the authorization code for a JWT and sets the session.
|
||||
func (s *Server) handleSSOCallback(w http.ResponseWriter, r *http.Request) {
|
||||
token, returnTo, err := mcdsso.HandleCallback(w, r, s.ssoClient, "mcq")
|
||||
if err != nil {
|
||||
s.logger.Error("sso: callback", "error", err)
|
||||
http.Error(w, "Login failed. Please try again.", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
web.SetSessionCookie(w, cookieName, token)
|
||||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
token := web.GetSessionToken(r, cookieName)
|
||||
if token != "" {
|
||||
@@ -176,3 +234,11 @@ func (s *Server) handleMarkUnread(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if err := s.db.DeleteDocument(slug); err != nil {
|
||||
s.logger.Error("failed to delete document", "slug", slug, "error", err)
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
@@ -252,6 +252,14 @@ button:hover, .btn:hover {
|
||||
padding: 0.1875rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.btn-danger {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Forms
|
||||
@@ -345,8 +353,75 @@ button:hover, .btn:hover {
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
Read view
|
||||
Read view — two-column layout
|
||||
=========================== */
|
||||
.read-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 2rem 2rem 0;
|
||||
}
|
||||
.read-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
.read-main {
|
||||
max-width: 900px;
|
||||
min-width: 0;
|
||||
}
|
||||
.read-toc {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
border-right: 1px solid var(--border-lt);
|
||||
padding-right: 1rem;
|
||||
}
|
||||
.read-toc ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.read-toc li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.read-toc a {
|
||||
color: var(--text-lt);
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 0.125rem 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.read-toc a:hover {
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.read-toc a.toc-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.read-container {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
.read-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.read-toc {
|
||||
position: static;
|
||||
max-height: none;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-lt);
|
||||
padding-right: 0;
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.read-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{{define "title"}} — Queue{{end}}
|
||||
{{define "content"}}
|
||||
<h2>Reading Queue</h2>
|
||||
<h2>Metacircular Reading Queue</h2>
|
||||
{{if not .Documents}}
|
||||
<div class="card">
|
||||
<p>No documents in queue.</p>
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
{{define "content"}}
|
||||
<div class="auth-header">
|
||||
<div class="brand">mcq</div>
|
||||
<div class="tagline">Reading Queue</div>
|
||||
<div class="tagline">Metacircular Reading Queue</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
||||
{{if .SSO}}
|
||||
<div class="form-actions">
|
||||
<a href="/sso/redirect" style="display:block;text-align:center;text-decoration:none;"><button type="button" style="width:100%" class="btn">Sign in with MCIAS</button></a>
|
||||
</div>
|
||||
{{else}}
|
||||
<form method="POST" action="/login">
|
||||
{{csrfField}}
|
||||
<div class="form-group">
|
||||
@@ -25,5 +30,6 @@
|
||||
<button type="submit" class="btn">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{{define "title"}} — {{.Doc.Title}}{{end}}
|
||||
{{define "container-class"}}read-container{{end}}
|
||||
{{define "content"}}
|
||||
<div class="read-header">
|
||||
<div class="read-layout">
|
||||
<nav class="read-toc" id="toc" aria-label="Table of contents"></nav>
|
||||
<div class="read-main">
|
||||
<div class="read-header">
|
||||
<h2>{{.Doc.Title}}</h2>
|
||||
<div class="read-meta">
|
||||
<span>Pushed by {{.Doc.PushedBy}}</span>
|
||||
@@ -18,10 +22,54 @@
|
||||
<button type="submit" class="btn-ghost btn btn-sm">Mark read</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="POST" action="/d/{{.Doc.Slug}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Delete this document?')">
|
||||
{{csrfField}}
|
||||
<button type="submit" class="btn-ghost btn btn-sm btn-danger">Unqueue</button>
|
||||
</form>
|
||||
<a href="/" class="btn-ghost btn btn-sm">Back to queue</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card markdown-body">
|
||||
</div>
|
||||
<div class="card markdown-body" id="article">
|
||||
{{.HTML}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var toc=document.getElementById("toc");
|
||||
var headings=document.querySelectorAll("#article h1, #article h2, #article h3");
|
||||
if(headings.length<2){toc.style.display="none";return;}
|
||||
var ul=document.createElement("ul");
|
||||
var minLevel=6;
|
||||
headings.forEach(function(h){var l=parseInt(h.tagName[1]);if(l<minLevel)minLevel=l;});
|
||||
headings.forEach(function(h){
|
||||
if(!h.id)return;
|
||||
var li=document.createElement("li");
|
||||
var a=document.createElement("a");
|
||||
a.href="#"+h.id;
|
||||
a.textContent=h.textContent;
|
||||
var depth=parseInt(h.tagName[1])-minLevel;
|
||||
li.style.paddingLeft=(depth*0.75)+"rem";
|
||||
li.appendChild(a);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
toc.appendChild(ul);
|
||||
/* highlight current section on scroll */
|
||||
var links=toc.querySelectorAll("a");
|
||||
var ids=[];links.forEach(function(a){ids.push(a.getAttribute("href").slice(1));});
|
||||
function onScroll(){
|
||||
var current="";
|
||||
for(var i=0;i<ids.length;i++){
|
||||
var el=document.getElementById(ids[i]);
|
||||
if(el&&el.getBoundingClientRect().top<=80)current=ids[i];
|
||||
}
|
||||
links.forEach(function(a){
|
||||
a.classList.toggle("toc-active",a.getAttribute("href")==="#"+current);
|
||||
});
|
||||
}
|
||||
window.addEventListener("scroll",onScroll,{passive:true});
|
||||
onScroll();
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user