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() }