Add deploy-wiki command for native DokuWiki deployment
- Add DeployWikiWithOptions function in new wiki_deploy.go - Add deploy-wiki command to sow-toolkit CLI - Supports DOKUWIKI_RPC_* and GITHUB_REF_NAME env vars - Includes --dry-run, --source-dir, --manifest options
This commit is contained in:
@@ -93,6 +93,11 @@ var commands = []command{
|
|||||||
description: "Build wiki pages from the current topdata state.",
|
description: "Build wiki pages from the current topdata state.",
|
||||||
run: runBuildWiki,
|
run: runBuildWiki,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "deploy-wiki",
|
||||||
|
description: "Deploy wiki pages to DokuWiki.",
|
||||||
|
run: runDeployWiki,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(args []string) (int, error) {
|
func Run(args []string) (int, error) {
|
||||||
@@ -595,6 +600,123 @@ func parseBuildWikiArgs(commandName string, args []string) (topdata.BuildWikiOpt
|
|||||||
return opts, nil
|
return opts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runDeployWiki(ctx context) error {
|
||||||
|
p, err := loadProject(ctx.cwd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
opts, err := parseDeployWikiArgs("deploy-wiki", ctx.args[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := topdata.DeployWikiWithOptions(p, opts, func(message string) {
|
||||||
|
fmt.Fprintf(ctx.stdout, "[deploy-wiki] %s\n", message)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(ctx.stdout, "project: %s\n", p.Config.Module.Name)
|
||||||
|
fmt.Fprintf(ctx.stdout, "local pages: %d\n", result.LocalPages)
|
||||||
|
fmt.Fprintf(ctx.stdout, "updated: %d\n", result.Updated)
|
||||||
|
fmt.Fprintf(ctx.stdout, "skipped: %d\n", result.Skipped)
|
||||||
|
fmt.Fprintf(ctx.stdout, "deleted: %d\n", result.Deleted)
|
||||||
|
fmt.Fprintf(ctx.stdout, "manifest: %s\n", result.Manifest)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiOptions, error) {
|
||||||
|
opts := topdata.DeployWikiOptions{}
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
switch arg {
|
||||||
|
case "--source-dir":
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, fmt.Errorf("--source-dir requires a value")
|
||||||
|
}
|
||||||
|
opts.SourceDir = args[i]
|
||||||
|
case "--endpoint":
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, fmt.Errorf("--endpoint requires a value")
|
||||||
|
}
|
||||||
|
opts.Endpoint = args[i]
|
||||||
|
case "--username":
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, fmt.Errorf("--username requires a value")
|
||||||
|
}
|
||||||
|
opts.Username = args[i]
|
||||||
|
case "--password":
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, fmt.Errorf("--password requires a value")
|
||||||
|
}
|
||||||
|
opts.Password = args[i]
|
||||||
|
case "--version":
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, fmt.Errorf("--version requires a value")
|
||||||
|
}
|
||||||
|
opts.Version = args[i]
|
||||||
|
case "--namespace":
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, fmt.Errorf("--namespace requires a value")
|
||||||
|
}
|
||||||
|
opts.Namespaces = append(opts.Namespaces, args[i])
|
||||||
|
case "--manifest":
|
||||||
|
i++
|
||||||
|
if i >= len(args) {
|
||||||
|
return opts, fmt.Errorf("--manifest requires a value")
|
||||||
|
}
|
||||||
|
opts.ManifestPath = args[i]
|
||||||
|
case "--dry-run":
|
||||||
|
opts.DryRun = true
|
||||||
|
case "-h", "--help":
|
||||||
|
return opts, fmt.Errorf("usage: %s [--source-dir <path>] [--endpoint <url>] [--username <user>] [--password <pass>] [--version <ver>] [--namespace <ns> ...] [--manifest <path>] [--dry-run]", commandName)
|
||||||
|
default:
|
||||||
|
if strings.HasPrefix(arg, "--source-dir=") {
|
||||||
|
opts.SourceDir = strings.TrimPrefix(arg, "--source-dir=")
|
||||||
|
} else if strings.HasPrefix(arg, "--endpoint=") {
|
||||||
|
opts.Endpoint = strings.TrimPrefix(arg, "--endpoint=")
|
||||||
|
} else if strings.HasPrefix(arg, "--username=") {
|
||||||
|
opts.Username = strings.TrimPrefix(arg, "--username=")
|
||||||
|
} else if strings.HasPrefix(arg, "--password=") {
|
||||||
|
opts.Password = strings.TrimPrefix(arg, "--password=")
|
||||||
|
} else if strings.HasPrefix(arg, "--version=") {
|
||||||
|
opts.Version = strings.TrimPrefix(arg, "--version=")
|
||||||
|
} else if strings.HasPrefix(arg, "--namespace=") {
|
||||||
|
opts.Namespaces = append(opts.Namespaces, strings.TrimPrefix(arg, "--namespace="))
|
||||||
|
} else if strings.HasPrefix(arg, "--manifest=") {
|
||||||
|
opts.ManifestPath = strings.TrimPrefix(arg, "--manifest=")
|
||||||
|
} else if arg == "--dry-run" {
|
||||||
|
opts.DryRun = true
|
||||||
|
} else {
|
||||||
|
return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Endpoint == "" {
|
||||||
|
opts.Endpoint = os.Getenv("DOKUWIKI_RPC_ENDPOINT")
|
||||||
|
}
|
||||||
|
if opts.Username == "" {
|
||||||
|
opts.Username = os.Getenv("DOKUWIKI_RPC_USERNAME")
|
||||||
|
}
|
||||||
|
if opts.Password == "" {
|
||||||
|
opts.Password = os.Getenv("DOKUWIKI_RPC_PASSWORD")
|
||||||
|
}
|
||||||
|
if opts.Version == "" {
|
||||||
|
opts.Version = os.Getenv("GITHUB_REF_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadProject(cwd string) (*project.Project, error) {
|
func loadProject(cwd string) (*project.Project, error) {
|
||||||
root, err := project.FindRoot(cwd)
|
root, err := project.FindRoot(cwd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,464 @@
|
|||||||
|
package topdata
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDeployManifestName = ".wiki_deploy_manifest.json"
|
||||||
|
defaultEditSummary = "Auto-generated from native builder"
|
||||||
|
defaultNotesDelimiter = "===== Notes ====="
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultManagedNamespaces = []string{"classes", "feat", "itemtypes", "races", "skills", "spells"}
|
||||||
|
|
||||||
|
type DeployWikiOptions struct {
|
||||||
|
SourceDir string
|
||||||
|
Endpoint string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Version string
|
||||||
|
Namespaces []string
|
||||||
|
NotesDelimiter string
|
||||||
|
ManifestPath string
|
||||||
|
DryRun bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type deployResult struct {
|
||||||
|
LocalPages int
|
||||||
|
Updated int
|
||||||
|
Deleted int
|
||||||
|
Skipped int
|
||||||
|
Manifest string
|
||||||
|
}
|
||||||
|
|
||||||
|
type deployPlan struct {
|
||||||
|
PageID string
|
||||||
|
Content string
|
||||||
|
OldContent string
|
||||||
|
}
|
||||||
|
|
||||||
|
type manifestEntry struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeployWikiWithOptions(p *project.Project, opts DeployWikiOptions, progress func(string)) (deployResult, error) {
|
||||||
|
if progress == nil {
|
||||||
|
progress = func(string) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.SourceDir == "" {
|
||||||
|
opts.SourceDir = wikiOutputPagesDir(p)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(opts.SourceDir); err != nil {
|
||||||
|
return deployResult{}, fmt.Errorf("wiki source directory not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Endpoint == "" {
|
||||||
|
return deployResult{}, errors.New("DOKUWIKI_RPC_ENDPOINT is required")
|
||||||
|
}
|
||||||
|
if opts.Username == "" {
|
||||||
|
return deployResult{}, errors.New("DOKUWIKI_RPC_USERNAME is required")
|
||||||
|
}
|
||||||
|
if opts.Password == "" {
|
||||||
|
return deployResult{}, errors.New("DOKUWIKI_RPC_PASSWORD is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
namespaces := opts.Namespaces
|
||||||
|
if len(namespaces) == 0 {
|
||||||
|
namespaces = defaultManagedNamespaces
|
||||||
|
}
|
||||||
|
|
||||||
|
notesDelimiter := opts.NotesDelimiter
|
||||||
|
if notesDelimiter == "" {
|
||||||
|
notesDelimiter = defaultNotesDelimiter
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestPath := opts.ManifestPath
|
||||||
|
if manifestPath == "" {
|
||||||
|
manifestPath = filepath.Join(filepath.Dir(opts.SourceDir), defaultDeployManifestName)
|
||||||
|
}
|
||||||
|
|
||||||
|
localPages, err := collectLocalPages(opts.SourceDir, namespaces)
|
||||||
|
if err != nil {
|
||||||
|
return deployResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := loadDeployManifest(manifestPath)
|
||||||
|
client := newDokuWikiClient(opts.Endpoint, opts.Username, opts.Password)
|
||||||
|
|
||||||
|
deployments, stalePages, err := planDeploy(localPages, client, namespaces, notesDelimiter, manifest)
|
||||||
|
if err != nil {
|
||||||
|
return deployResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(fmt.Sprintf("Deploying %d pages...", len(deployments)))
|
||||||
|
|
||||||
|
editSummary := defaultEditSummary
|
||||||
|
if opts.Version != "" {
|
||||||
|
editSummary = fmt.Sprintf("%s (%s)", defaultEditSummary, opts.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.DryRun {
|
||||||
|
for _, plan := range deployments {
|
||||||
|
if err := client.putPage(plan.PageID, plan.Content, editSummary); err != nil {
|
||||||
|
return deployResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pageID := range stalePages {
|
||||||
|
if err := client.deletePage(pageID, editSummary); err != nil {
|
||||||
|
return deployResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHashes := computePageHashes(localPages, notesDelimiter)
|
||||||
|
saveDeployManifest(manifestPath, currentHashes)
|
||||||
|
|
||||||
|
updated := len(deployments)
|
||||||
|
deleted := len(stalePages)
|
||||||
|
skipped := len(localPages) - updated
|
||||||
|
|
||||||
|
result := deployResult{
|
||||||
|
LocalPages: len(localPages),
|
||||||
|
Updated: updated,
|
||||||
|
Deleted: deleted,
|
||||||
|
Skipped: skipped,
|
||||||
|
Manifest: manifestPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(fmt.Sprintf("Updated: %d, Skipped: %d, Deleted: %d", updated, skipped, deleted))
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectLocalPages(sourceDir string, namespaces []string) (map[string]string, error) {
|
||||||
|
pages := map[string]string{}
|
||||||
|
for _, ns := range namespaces {
|
||||||
|
nsDir := filepath.Join(sourceDir, ns)
|
||||||
|
if !isDir(nsDir) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := filepath.WalkDir(nsDir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if filepath.Ext(path) != ".txt" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(sourceDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pageID := strings.TrimSuffix(rel, ".txt")
|
||||||
|
pageID = strings.ReplaceAll(pageID, string(filepath.Separator), ":")
|
||||||
|
pageID = strings.ToLower(pageID)
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pages[pageID] = string(content)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computePageHash(content, notesDelimiter string) string {
|
||||||
|
generatedBody, _ := splitNotesSection(content, notesDelimiter)
|
||||||
|
h := sha1.New()
|
||||||
|
h.Write([]byte(generatedBody))
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func computePageHashes(pages map[string]string, notesDelimiter string) map[string]string {
|
||||||
|
hashes := map[string]string{}
|
||||||
|
for pageID, content := range pages {
|
||||||
|
hashes[pageID] = computePageHash(content, notesDelimiter)
|
||||||
|
}
|
||||||
|
return hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitNotesSection(content, notesDelimiter string) (string, string) {
|
||||||
|
lines := strings.Split(content, notesDelimiter)
|
||||||
|
if len(lines) <= 1 {
|
||||||
|
return strings.TrimSpace(content), ""
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(strings.Join(lines[:len(lines)-1], notesDelimiter))
|
||||||
|
notes := strings.TrimSpace(strings.Join(lines[1:], notesDelimiter))
|
||||||
|
return body, notes
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDeployManifest(path string) map[string]string {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Pages map[string]string `json:"pages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &doc); err != nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
result := map[string]string{}
|
||||||
|
for k, v := range doc.Pages {
|
||||||
|
result[k] = v
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDeployManifest(path string, hashes map[string]string) {
|
||||||
|
os.MkdirAll(filepath.Dir(path), 0755)
|
||||||
|
doc := struct {
|
||||||
|
Pages map[string]string `json:"pages"`
|
||||||
|
}{
|
||||||
|
Pages: hashes,
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(doc, "", " ")
|
||||||
|
os.WriteFile(path, append(data, '\n'), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func planDeploy(
|
||||||
|
localPages map[string]string,
|
||||||
|
client *dokuWikiClient,
|
||||||
|
namespaces []string,
|
||||||
|
notesDelimiter string,
|
||||||
|
previousHashes map[string]string,
|
||||||
|
) ([]deployPlan, []string, error) {
|
||||||
|
var deployments []deployPlan
|
||||||
|
remotePages := map[string]string{}
|
||||||
|
|
||||||
|
for pageID, content := range localPages {
|
||||||
|
hash := computePageHash(content, notesDelimiter)
|
||||||
|
if previousHashes[pageID] == hash {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
oldContent, err := client.getPage(pageID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, errPageNotFound) {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
oldContent = ""
|
||||||
|
}
|
||||||
|
remotePages[pageID] = oldContent
|
||||||
|
|
||||||
|
merged := mergePageContent(content, oldContent, notesDelimiter)
|
||||||
|
if merged != oldContent {
|
||||||
|
deployments = append(deployments, deployPlan{
|
||||||
|
PageID: pageID,
|
||||||
|
Content: merged,
|
||||||
|
OldContent: oldContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteIDs := map[string]bool{}
|
||||||
|
for pageID := range remotePages {
|
||||||
|
remoteIDs[pageID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ns := range namespaces {
|
||||||
|
pages, err := client.listNamespacePages(ns)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, pageID := range pages {
|
||||||
|
remoteIDs[pageID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stale []string
|
||||||
|
for pageID := range remoteIDs {
|
||||||
|
if _, exists := localPages[pageID]; !exists {
|
||||||
|
stale = append(stale, pageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(stale)
|
||||||
|
|
||||||
|
return deployments, stale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergePageContent(generated, existing, notesDelimiter string) string {
|
||||||
|
_, existingNotes := splitNotesSection(existing, notesDelimiter)
|
||||||
|
generatedBody, _ := splitNotesSection(generated, notesDelimiter)
|
||||||
|
|
||||||
|
if existingNotes == "" {
|
||||||
|
return generated
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(generatedBody) + "\n\n" + notesDelimiter + "\n\n" + existingNotes + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
type dokuWikiClient struct {
|
||||||
|
endpoint string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
timeout time.Duration
|
||||||
|
retryAttempts int
|
||||||
|
retryBackoff time.Duration
|
||||||
|
requestDelay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
var errPageNotFound = errors.New("page not found")
|
||||||
|
|
||||||
|
func newDokuWikiClient(endpoint, username, password string) *dokuWikiClient {
|
||||||
|
endpoint = strings.TrimRight(endpoint, "/")
|
||||||
|
if strings.HasSuffix(endpoint, "/doku.php") {
|
||||||
|
endpoint = strings.TrimSuffix(endpoint, "/doku.php") + "/lib/exe/jsonrpc.php"
|
||||||
|
}
|
||||||
|
return &dokuWikiClient{
|
||||||
|
endpoint: endpoint,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
timeout: 30 * time.Second,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryBackoff: 500 * time.Millisecond,
|
||||||
|
requestDelay: 50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dokuWikiClient) getPage(pageID string) (string, error) {
|
||||||
|
result, err := c.rpc("wiki.getPage", []any{pageID})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return "", errPageNotFound
|
||||||
|
}
|
||||||
|
s, ok := result.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dokuWikiClient) putPage(pageID, content, summary string) error {
|
||||||
|
_, err := c.rpc("wiki.putPage", []any{pageID, content, map[string]any{"sum": summary}})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dokuWikiClient) deletePage(pageID, summary string) error {
|
||||||
|
_, err := c.rpc("wiki.putPage", []any{pageID, "", map[string]any{"sum": summary, "minor": false}})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dokuWikiClient) listNamespacePages(namespace string) ([]string, error) {
|
||||||
|
result, err := c.rpc("dokuwiki.getPagelist", []any{namespace, map[string]any{}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, ok := result.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var pages []string
|
||||||
|
for _, item := range items {
|
||||||
|
switch v := item.(type) {
|
||||||
|
case string:
|
||||||
|
pages = append(pages, v)
|
||||||
|
case map[string]any:
|
||||||
|
if id, ok := v["id"].(string); ok {
|
||||||
|
pages = append(pages, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dokuWikiClient) rpc(method string, params []any) (any, error) {
|
||||||
|
var last error
|
||||||
|
for attempt := 1; attempt <= c.retryAttempts; attempt++ {
|
||||||
|
result, err := c.rpcOnce(method, params)
|
||||||
|
if err == nil {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
last = err
|
||||||
|
if attempt < c.retryAttempts {
|
||||||
|
time.Sleep(c.retryBackoff * time.Duration(attempt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("%s failed after %d attempts: %w", method, c.retryAttempts, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *dokuWikiClient) rpcOnce(method string, params []any) (any, error) {
|
||||||
|
if c.requestDelay > 0 {
|
||||||
|
time.Sleep(c.requestDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": method,
|
||||||
|
"params": params,
|
||||||
|
"id": 1,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token := base64.StdEncoding.EncodeToString([]byte(c.username + ":" + c.password))
|
||||||
|
req, err := http.NewRequest("POST", c.endpoint, strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Basic "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
preview := strings.TrimSpace(string(data))
|
||||||
|
if len(preview) > 200 {
|
||||||
|
preview = preview[:200] + "..."
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("non-JSON response: %s", preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errMsg, ok := result["error"].(any); ok && errMsg != nil {
|
||||||
|
return nil, fmt.Errorf("RPC error: %v", errMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result["result"], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDir(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user