diff --git a/internal/app/app.go b/internal/app/app.go index acbf09e..99ec2ca 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -93,6 +93,11 @@ var commands = []command{ description: "Build wiki pages from the current topdata state.", run: runBuildWiki, }, + { + name: "deploy-wiki", + description: "Deploy wiki pages to DokuWiki.", + run: runDeployWiki, + }, } func Run(args []string) (int, error) { @@ -595,6 +600,123 @@ func parseBuildWikiArgs(commandName string, args []string) (topdata.BuildWikiOpt 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 ] [--endpoint ] [--username ] [--password ] [--version ] [--namespace ...] [--manifest ] [--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) { root, err := project.FindRoot(cwd) if err != nil { diff --git a/internal/topdata/wiki_deploy.go b/internal/topdata/wiki_deploy.go new file mode 100644 index 0000000..628b738 --- /dev/null +++ b/internal/topdata/wiki_deploy.go @@ -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() +}