forked from tangled.org/core
this repo has no description
1package pages 2 3import ( 4 "bytes" 5 "crypto/sha256" 6 "embed" 7 "encoding/hex" 8 "fmt" 9 "html/template" 10 "io" 11 "io/fs" 12 "log" 13 "net/http" 14 "path" 15 "path/filepath" 16 "slices" 17 "strings" 18 19 "github.com/alecthomas/chroma/v2" 20 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 21 "github.com/alecthomas/chroma/v2/lexers" 22 "github.com/alecthomas/chroma/v2/styles" 23 "github.com/bluesky-social/indigo/atproto/syntax" 24 "github.com/microcosm-cc/bluemonday" 25 "tangled.sh/tangled.sh/core/appview/auth" 26 "tangled.sh/tangled.sh/core/appview/db" 27 "tangled.sh/tangled.sh/core/appview/pages/markup" 28 "tangled.sh/tangled.sh/core/appview/state/userutil" 29 "tangled.sh/tangled.sh/core/types" 30) 31 32//go:embed templates/* static 33var Files embed.FS 34 35type Pages struct { 36 t map[string]*template.Template 37} 38 39 40 41func NewPages() *Pages { 42 templates := make(map[string]*template.Template) 43 fragmentPaths := []string{} 44 45 // First, collect all fragment paths 46 err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 47 if err != nil { 48 return err 49 } 50 51 if !d.IsDir() && strings.HasSuffix(path, ".html") && strings.Contains(path, "fragments/") { 52 fragmentPaths = append(fragmentPaths, path) 53 } 54 return nil 55 }) 56 if err != nil { 57 log.Fatalf("walking template dir for fragments: %v", err) 58 } 59 60 // Load all fragments first 61 for _, path := range fragmentPaths { 62 name := strings.TrimPrefix(path, "templates/") 63 name = strings.TrimSuffix(name, ".html") 64 65 tmpl, err := template.New(name). 66 Funcs(funcMap()). 67 ParseFS(Files, path) 68 if err != nil { 69 log.Fatalf("setting up fragment: %v", err) 70 } 71 72 templates[name] = tmpl 73 log.Printf("loaded fragment: %s", name) 74 } 75 76 // Then walk through and setup the rest of the templates 77 err = fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 78 if err != nil { 79 return err 80 } 81 82 if !d.IsDir() && strings.HasSuffix(path, ".html") { 83 name := strings.TrimPrefix(path, "templates/") 84 name = strings.TrimSuffix(name, ".html") 85 86 // Skip fragments as they've already been loaded 87 if strings.Contains(path, "fragments/") { 88 return nil 89 } 90 91 // Load layouts and main templates 92 if !strings.HasPrefix(path, "templates/layouts/") { 93 // Add the page template on top of the base 94 tmpl, err := template.New(name). 95 Funcs(funcMap()). 96 ParseFS(Files, "templates/layouts/*.html", "templates/**/fragments/*.html", path) 97 if err != nil { 98 return fmt.Errorf("setting up template: %w", err) 99 } 100 101 templates[name] = tmpl 102 log.Printf("loaded template: %s", name) 103 } 104 } 105 return nil 106 }) 107 if err != nil { 108 log.Fatalf("walking template dir: %v", err) 109 } 110 111 log.Printf("total templates loaded: %d", len(templates)) 112 113 return &Pages{ 114 t: templates, 115 } 116} 117 118type LoginParams struct { 119} 120 121func (p *Pages) execute(name string, w io.Writer, params any) error { 122 return p.t[name].ExecuteTemplate(w, "layouts/base", params) 123} 124 125func (p *Pages) executePlain(name string, w io.Writer, params any) error { 126 return p.t[name].Execute(w, params) 127} 128 129func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 130 return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 131} 132 133func (p *Pages) Login(w io.Writer, params LoginParams) error { 134 return p.executePlain("user/login", w, params) 135} 136 137type TimelineParams struct { 138 LoggedInUser *auth.User 139 Timeline []db.TimelineEvent 140 DidHandleMap map[string]string 141} 142 143func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 144 return p.execute("timeline", w, params) 145} 146 147type SettingsParams struct { 148 LoggedInUser *auth.User 149 PubKeys []db.PublicKey 150 Emails []db.Email 151} 152 153func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 154 return p.execute("settings", w, params) 155} 156 157type KnotsParams struct { 158 LoggedInUser *auth.User 159 Registrations []db.Registration 160} 161 162func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 163 return p.execute("knots", w, params) 164} 165 166type KnotParams struct { 167 LoggedInUser *auth.User 168 DidHandleMap map[string]string 169 Registration *db.Registration 170 Members []string 171 IsOwner bool 172} 173 174func (p *Pages) Knot(w io.Writer, params KnotParams) error { 175 return p.execute("knot", w, params) 176} 177 178type NewRepoParams struct { 179 LoggedInUser *auth.User 180 Knots []string 181} 182 183func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { 184 return p.execute("repo/new", w, params) 185} 186 187type ForkRepoParams struct { 188 LoggedInUser *auth.User 189 Knots []string 190 RepoInfo RepoInfo 191} 192 193func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 194 return p.execute("repo/fork", w, params) 195} 196 197type ProfilePageParams struct { 198 LoggedInUser *auth.User 199 UserDid string 200 UserHandle string 201 Repos []db.Repo 202 CollaboratingRepos []db.Repo 203 ProfileStats ProfileStats 204 FollowStatus db.FollowStatus 205 AvatarUri string 206 ProfileTimeline *db.ProfileTimeline 207 208 DidHandleMap map[string]string 209} 210 211type ProfileStats struct { 212 Followers int 213 Following int 214} 215 216func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 217 return p.execute("user/profile", w, params) 218} 219 220type FollowFragmentParams struct { 221 UserDid string 222 FollowStatus db.FollowStatus 223} 224 225func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 226 return p.executePlain("user/fragments/follow", w, params) 227} 228 229type RepoActionsFragmentParams struct { 230 IsStarred bool 231 RepoAt syntax.ATURI 232 Stats db.RepoStats 233} 234 235func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 236 return p.executePlain("repo/fragments/repoActions", w, params) 237} 238 239type RepoDescriptionParams struct { 240 RepoInfo RepoInfo 241} 242 243func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 244 return p.executePlain("repo/fragments/editRepoDescription", w, params) 245} 246 247func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 248 return p.executePlain("repo/fragments/repoDescription", w, params) 249} 250 251type RepoInfo struct { 252 Name string 253 OwnerDid string 254 OwnerHandle string 255 Description string 256 Knot string 257 RepoAt syntax.ATURI 258 IsStarred bool 259 Stats db.RepoStats 260 Roles RolesInRepo 261 Source *db.Repo 262 SourceHandle string 263 DisableFork bool 264} 265 266type RolesInRepo struct { 267 Roles []string 268} 269 270func (r RolesInRepo) SettingsAllowed() bool { 271 return slices.Contains(r.Roles, "repo:settings") 272} 273 274func (r RolesInRepo) CollaboratorInviteAllowed() bool { 275 return slices.Contains(r.Roles, "repo:invite") 276} 277 278func (r RolesInRepo) RepoDeleteAllowed() bool { 279 return slices.Contains(r.Roles, "repo:delete") 280} 281 282func (r RolesInRepo) IsOwner() bool { 283 return slices.Contains(r.Roles, "repo:owner") 284} 285 286func (r RolesInRepo) IsCollaborator() bool { 287 return slices.Contains(r.Roles, "repo:collaborator") 288} 289 290func (r RolesInRepo) IsPushAllowed() bool { 291 return slices.Contains(r.Roles, "repo:push") 292} 293 294func (r RepoInfo) OwnerWithAt() string { 295 if r.OwnerHandle != "" { 296 return fmt.Sprintf("@%s", r.OwnerHandle) 297 } else { 298 return r.OwnerDid 299 } 300} 301 302func (r RepoInfo) FullName() string { 303 return path.Join(r.OwnerWithAt(), r.Name) 304} 305 306func (r RepoInfo) OwnerWithoutAt() string { 307 if strings.HasPrefix(r.OwnerWithAt(), "@") { 308 return strings.TrimPrefix(r.OwnerWithAt(), "@") 309 } else { 310 return userutil.FlattenDid(r.OwnerDid) 311 } 312} 313 314func (r RepoInfo) FullNameWithoutAt() string { 315 return path.Join(r.OwnerWithoutAt(), r.Name) 316} 317 318func (r RepoInfo) GetTabs() [][]string { 319 tabs := [][]string{ 320 {"overview", "/", "square-chart-gantt"}, 321 {"issues", "/issues", "circle-dot"}, 322 {"pulls", "/pulls", "git-pull-request"}, 323 } 324 325 if r.Roles.SettingsAllowed() { 326 tabs = append(tabs, []string{"settings", "/settings", "cog"}) 327 } 328 329 return tabs 330} 331 332// each tab on a repo could have some metadata: 333// 334// issues -> number of open issues etc. 335// settings -> a warning icon to setup branch protection? idk 336// 337// we gather these bits of info here, because go templates 338// are difficult to program in 339func (r RepoInfo) TabMetadata() map[string]any { 340 meta := make(map[string]any) 341 342 if r.Stats.PullCount.Open > 0 { 343 meta["pulls"] = r.Stats.PullCount.Open 344 } 345 346 if r.Stats.IssueCount.Open > 0 { 347 meta["issues"] = r.Stats.IssueCount.Open 348 } 349 350 // more stuff? 351 352 return meta 353} 354 355type RepoIndexParams struct { 356 LoggedInUser *auth.User 357 RepoInfo RepoInfo 358 Active string 359 TagMap map[string][]string 360 types.RepoIndexResponse 361 HTMLReadme template.HTML 362 Raw bool 363 EmailToDidOrHandle map[string]string 364} 365 366func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error { 367 params.Active = "overview" 368 if params.IsEmpty { 369 return p.executeRepo("repo/empty", w, params) 370 } 371 372 if params.ReadmeFileName != "" { 373 var htmlString string 374 ext := filepath.Ext(params.ReadmeFileName) 375 switch ext { 376 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 377 htmlString = markup.RenderMarkdown(params.Readme) 378 params.Raw = false 379 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 380 default: 381 htmlString = string(params.Readme) 382 params.Raw = true 383 params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 384 } 385 } 386 387 return p.executeRepo("repo/index", w, params) 388} 389 390type RepoLogParams struct { 391 LoggedInUser *auth.User 392 RepoInfo RepoInfo 393 types.RepoLogResponse 394 Active string 395 EmailToDidOrHandle map[string]string 396} 397 398func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 399 params.Active = "overview" 400 return p.execute("repo/log", w, params) 401} 402 403type RepoCommitParams struct { 404 LoggedInUser *auth.User 405 RepoInfo RepoInfo 406 Active string 407 types.RepoCommitResponse 408 EmailToDidOrHandle map[string]string 409} 410 411func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { 412 params.Active = "overview" 413 return p.executeRepo("repo/commit", w, params) 414} 415 416type RepoTreeParams struct { 417 LoggedInUser *auth.User 418 RepoInfo RepoInfo 419 Active string 420 BreadCrumbs [][]string 421 BaseTreeLink string 422 BaseBlobLink string 423 types.RepoTreeResponse 424} 425 426type RepoTreeStats struct { 427 NumFolders uint64 428 NumFiles uint64 429} 430 431func (r RepoTreeParams) TreeStats() RepoTreeStats { 432 numFolders, numFiles := 0, 0 433 for _, f := range r.Files { 434 if !f.IsFile { 435 numFolders += 1 436 } else if f.IsFile { 437 numFiles += 1 438 } 439 } 440 441 return RepoTreeStats{ 442 NumFolders: uint64(numFolders), 443 NumFiles: uint64(numFiles), 444 } 445} 446 447func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 448 params.Active = "overview" 449 return p.execute("repo/tree", w, params) 450} 451 452type RepoBranchesParams struct { 453 LoggedInUser *auth.User 454 RepoInfo RepoInfo 455 types.RepoBranchesResponse 456} 457 458func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 459 return p.executeRepo("repo/branches", w, params) 460} 461 462type RepoTagsParams struct { 463 LoggedInUser *auth.User 464 RepoInfo RepoInfo 465 types.RepoTagsResponse 466} 467 468func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 469 return p.executeRepo("repo/tags", w, params) 470} 471 472type RepoBlobParams struct { 473 LoggedInUser *auth.User 474 RepoInfo RepoInfo 475 Active string 476 BreadCrumbs [][]string 477 ShowRendered bool 478 RenderToggle bool 479 RenderedContents template.HTML 480 types.RepoBlobResponse 481} 482 483func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 484 style := styles.Get("bw") 485 b := style.Builder() 486 b.Add(chroma.LiteralString, "noitalic") 487 style, _ = b.Build() 488 489 if params.ShowRendered { 490 switch markup.GetFormat(params.Path) { 491 case markup.FormatMarkdown: 492 params.RenderedContents = template.HTML(markup.RenderMarkdown(params.Contents)) 493 } 494 } 495 496 if params.Lines < 5000 { 497 c := params.Contents 498 formatter := chromahtml.New( 499 chromahtml.InlineCode(false), 500 chromahtml.WithLineNumbers(true), 501 chromahtml.WithLinkableLineNumbers(true, "L"), 502 chromahtml.Standalone(false), 503 ) 504 505 lexer := lexers.Get(filepath.Base(params.Path)) 506 if lexer == nil { 507 lexer = lexers.Fallback 508 } 509 510 iterator, err := lexer.Tokenise(nil, c) 511 if err != nil { 512 return fmt.Errorf("chroma tokenize: %w", err) 513 } 514 515 var code bytes.Buffer 516 err = formatter.Format(&code, style, iterator) 517 if err != nil { 518 return fmt.Errorf("chroma format: %w", err) 519 } 520 521 params.Contents = code.String() 522 } 523 524 params.Active = "overview" 525 return p.executeRepo("repo/blob", w, params) 526} 527 528type Collaborator struct { 529 Did string 530 Handle string 531 Role string 532} 533 534type RepoSettingsParams struct { 535 LoggedInUser *auth.User 536 RepoInfo RepoInfo 537 Collaborators []Collaborator 538 Active string 539 Branches []string 540 DefaultBranch string 541 // TODO: use repoinfo.roles 542 IsCollaboratorInviteAllowed bool 543} 544 545func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error { 546 params.Active = "settings" 547 return p.executeRepo("repo/settings", w, params) 548} 549 550type RepoIssuesParams struct { 551 LoggedInUser *auth.User 552 RepoInfo RepoInfo 553 Active string 554 Issues []db.Issue 555 DidHandleMap map[string]string 556 557 FilteringByOpen bool 558} 559 560func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error { 561 params.Active = "issues" 562 return p.executeRepo("repo/issues/issues", w, params) 563} 564 565type RepoSingleIssueParams struct { 566 LoggedInUser *auth.User 567 RepoInfo RepoInfo 568 Active string 569 Issue db.Issue 570 Comments []db.Comment 571 IssueOwnerHandle string 572 DidHandleMap map[string]string 573 574 State string 575} 576 577func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 578 params.Active = "issues" 579 if params.Issue.Open { 580 params.State = "open" 581 } else { 582 params.State = "closed" 583 } 584 return p.execute("repo/issues/issue", w, params) 585} 586 587type RepoNewIssueParams struct { 588 LoggedInUser *auth.User 589 RepoInfo RepoInfo 590 Active string 591} 592 593func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 594 params.Active = "issues" 595 return p.executeRepo("repo/issues/new", w, params) 596} 597 598type EditIssueCommentParams struct { 599 LoggedInUser *auth.User 600 RepoInfo RepoInfo 601 Issue *db.Issue 602 Comment *db.Comment 603} 604 605func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 606 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 607} 608 609type SingleIssueCommentParams struct { 610 LoggedInUser *auth.User 611 DidHandleMap map[string]string 612 RepoInfo RepoInfo 613 Issue *db.Issue 614 Comment *db.Comment 615} 616 617func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 618 return p.executePlain("repo/issues/fragments/issueComment", w, params) 619} 620 621type RepoNewPullParams struct { 622 LoggedInUser *auth.User 623 RepoInfo RepoInfo 624 Branches []types.Branch 625 Active string 626} 627 628func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error { 629 params.Active = "pulls" 630 return p.executeRepo("repo/pulls/new", w, params) 631} 632 633type RepoPullsParams struct { 634 LoggedInUser *auth.User 635 RepoInfo RepoInfo 636 Pulls []db.Pull 637 Active string 638 DidHandleMap map[string]string 639 FilteringBy db.PullState 640} 641 642func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { 643 params.Active = "pulls" 644 return p.executeRepo("repo/pulls/pulls", w, params) 645} 646 647type ResubmitResult uint64 648 649const ( 650 ShouldResubmit ResubmitResult = iota 651 ShouldNotResubmit 652 Unknown 653) 654 655func (r ResubmitResult) Yes() bool { 656 return r == ShouldResubmit 657} 658func (r ResubmitResult) No() bool { 659 return r == ShouldNotResubmit 660} 661func (r ResubmitResult) Unknown() bool { 662 return r == Unknown 663} 664 665type RepoSinglePullParams struct { 666 LoggedInUser *auth.User 667 RepoInfo RepoInfo 668 Active string 669 DidHandleMap map[string]string 670 Pull *db.Pull 671 PullSourceRepo *db.Repo 672 MergeCheck types.MergeCheckResponse 673 ResubmitCheck ResubmitResult 674} 675 676func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { 677 params.Active = "pulls" 678 return p.executeRepo("repo/pulls/pull", w, params) 679} 680 681type RepoPullPatchParams struct { 682 LoggedInUser *auth.User 683 DidHandleMap map[string]string 684 RepoInfo RepoInfo 685 Pull *db.Pull 686 Diff types.NiceDiff 687 Round int 688 Submission *db.PullSubmission 689} 690 691// this name is a mouthful 692func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error { 693 return p.execute("repo/pulls/patch", w, params) 694} 695 696type PullPatchUploadParams struct { 697 RepoInfo RepoInfo 698} 699 700func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 701 return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 702} 703 704type PullCompareBranchesParams struct { 705 RepoInfo RepoInfo 706 Branches []types.Branch 707} 708 709func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 710 return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 711} 712 713type PullCompareForkParams struct { 714 RepoInfo RepoInfo 715 Forks []db.Repo 716} 717 718func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 719 return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 720} 721 722type PullCompareForkBranchesParams struct { 723 RepoInfo RepoInfo 724 SourceBranches []types.Branch 725 TargetBranches []types.Branch 726} 727 728func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 729 return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 730} 731 732type PullResubmitParams struct { 733 LoggedInUser *auth.User 734 RepoInfo RepoInfo 735 Pull *db.Pull 736 SubmissionId int 737} 738 739func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 740 return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 741} 742 743type PullActionsParams struct { 744 LoggedInUser *auth.User 745 RepoInfo RepoInfo 746 Pull *db.Pull 747 RoundNumber int 748 MergeCheck types.MergeCheckResponse 749 ResubmitCheck ResubmitResult 750} 751 752func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 753 return p.executePlain("repo/pulls/fragments/pullActions", w, params) 754} 755 756type PullNewCommentParams struct { 757 LoggedInUser *auth.User 758 RepoInfo RepoInfo 759 Pull *db.Pull 760 RoundNumber int 761} 762 763func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 764 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 765} 766 767func (p *Pages) Static() http.Handler { 768 sub, err := fs.Sub(Files, "static") 769 if err != nil { 770 log.Fatalf("no static dir found? that's crazy: %v", err) 771 } 772 // Custom handler to apply Cache-Control headers for font files 773 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 774} 775 776func Cache(h http.Handler) http.Handler { 777 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 778 path := strings.Split(r.URL.Path, "?")[0] 779 780 if strings.HasSuffix(path, ".css") { 781 // on day for css files 782 w.Header().Set("Cache-Control", "public, max-age=86400") 783 } else { 784 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 785 } 786 h.ServeHTTP(w, r) 787 }) 788} 789 790func CssContentHash() string { 791 cssFile, err := Files.Open("static/tw.css") 792 if err != nil { 793 log.Printf("Error opening CSS file: %v", err) 794 return "" 795 } 796 defer cssFile.Close() 797 798 hasher := sha256.New() 799 if _, err := io.Copy(hasher, cssFile); err != nil { 800 log.Printf("Error hashing CSS file: %v", err) 801 return "" 802 } 803 804 return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 805} 806 807func (p *Pages) Error500(w io.Writer) error { 808 return p.execute("errors/500", w, nil) 809} 810 811func (p *Pages) Error404(w io.Writer) error { 812 return p.execute("errors/404", w, nil) 813} 814 815func (p *Pages) Error503(w io.Writer) error { 816 return p.execute("errors/503", w, nil) 817}