Monorepo for Tangled tangled.org

appview: allows a default knot to be configured #991

open opened by willdot.net targeting master from willdot.net/tangled-fork: default-knot

This follows on from the work carried out in #836

I've added a select box in the Knots settings page which pulls in the users knots and also adds in knot1.tangled.sh. When the user selects one of these options, it will save to their profile in the database. NOTE: I haven't yet implemented adding that to the AT record because I'm not sure on how the lexicon setup works yet!

Then when users go to create a new repo / fork, if there is a value in their profile for the default knot, then that will pre select the knot to use for the new repo / fork.

This is a duplicate of https://tangled.org/tangled.org/core/pulls/858/ which had to be closed because there were merge conflicts which couldn't be resolved due to the origin fork being deleted 🙈

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:dadhhalkfcq3gucaq25hjqon/sh.tangled.repo.pull/3mcrzfonnxs22
+145 -15
Diff #4
+8 -1
appview/db/db.go
··· 1078 // transfer data, constructing pull_at from pulls table 1079 _, err = tx.Exec(` 1080 insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1081 - select 1082 ps.id, 1083 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1084 ps.round_number, ··· 1173 return err 1174 }) 1175 1176 return &DB{ 1177 db, 1178 logger,
··· 1078 // transfer data, constructing pull_at from pulls table 1079 _, err = tx.Exec(` 1080 insert into pull_submissions_new (id, pull_at, round_number, patch, created) 1081 + select 1082 ps.id, 1083 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey, 1084 ps.round_number, ··· 1173 return err 1174 }) 1175 1176 + orm.RunMigration(conn, logger, "add-default-knot-profile", func(tx *sql.Tx) error { 1177 + _, err := tx.Exec(` 1178 + alter table profile add column default_knot text; 1179 + `) 1180 + return err 1181 + }) 1182 + 1183 return &DB{ 1184 db, 1185 logger,
+11 -4
appview/db/profile.go
··· 138 description, 139 include_bluesky, 140 location, 141 - pronouns 142 ) 143 - values (?, ?, ?, ?, ?)`, 144 profile.Did, 145 profile.Description, 146 includeBskyValue, 147 profile.Location, 148 profile.Pronouns, 149 ) 150 151 if err != nil { ··· 324 func GetProfile(e Execer, did string) (*models.Profile, error) { 325 var profile models.Profile 326 var pronouns sql.Null[string] 327 328 profile.Did = did 329 330 includeBluesky := 0 331 332 err := e.QueryRow( 333 - `select description, include_bluesky, location, pronouns from profile where did = ?`, 334 did, 335 - ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns) 336 if err == sql.ErrNoRows { 337 profile := models.Profile{} 338 profile.Did = did ··· 351 profile.Pronouns = pronouns.V 352 } 353 354 rows, err := e.Query(`select link from profile_links where did = ?`, did) 355 if err != nil { 356 return nil, err
··· 138 description, 139 include_bluesky, 140 location, 141 + pronouns, 142 + default_knot 143 ) 144 + values (?, ?, ?, ?, ?, ?)`, 145 profile.Did, 146 profile.Description, 147 includeBskyValue, 148 profile.Location, 149 profile.Pronouns, 150 + profile.DefaultKnot, 151 ) 152 153 if err != nil { ··· 326 func GetProfile(e Execer, did string) (*models.Profile, error) { 327 var profile models.Profile 328 var pronouns sql.Null[string] 329 + var defaultKnot sql.Null[string] 330 331 profile.Did = did 332 333 includeBluesky := 0 334 335 err := e.QueryRow( 336 + `select description, include_bluesky, location, pronouns, default_knot from profile where did = ?`, 337 did, 338 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns, &defaultKnot) 339 if err == sql.ErrNoRows { 340 profile := models.Profile{} 341 profile.Did = did ··· 354 profile.Pronouns = pronouns.V 355 } 356 357 + if defaultKnot.Valid { 358 + profile.DefaultKnot = defaultKnot.V 359 + } 360 + 361 rows, err := e.Query(`select link from profile_links where did = ?`, did) 362 if err != nil { 363 return nil, err
+22 -4
appview/knots/knots.go
··· 81 return 82 } 83 84 k.Pages.Knots(w, pages.KnotsParams{ 85 - LoggedInUser: user, 86 - Registrations: registrations, 87 - Tabs: knotsTabs, 88 - Tab: "knots", 89 }) 90 } 91
··· 81 return 82 } 83 84 + availableKnots, err := k.Enforcer.GetKnotsForUser(user.Did()) 85 + if err != nil { 86 + k.Logger.Error("failed to fetch available knots for user", "err", err) 87 + w.WriteHeader(http.StatusInternalServerError) 88 + return 89 + } 90 + 91 + defaultKnot := "" 92 + profile, err := db.GetProfile(k.Db, user.Did()) 93 + if err != nil { 94 + k.Logger.Warn("gettings user profile to get default knot", "error", err) 95 + } 96 + if profile != nil { 97 + defaultKnot = profile.DefaultKnot 98 + } 99 + 100 k.Pages.Knots(w, pages.KnotsParams{ 101 + LoggedInUser: user, 102 + Registrations: registrations, 103 + Tabs: knotsTabs, 104 + Tab: "knots", 105 + AvailableKnots: availableKnots, 106 + DefaultKnot: defaultKnot, 107 }) 108 } 109
+1
appview/models/profile.go
··· 20 Stats [2]VanityStat 21 PinnedRepos [6]syntax.ATURI 22 Pronouns string 23 } 24 25 func (p Profile) IsLinksEmpty() bool {
··· 20 Stats [2]VanityStat 21 PinnedRepos [6]syntax.ATURI 22 Pronouns string 23 + DefaultKnot string 24 } 25 26 func (p Profile) IsLinksEmpty() bool {
+8 -4
appview/pages/pages.go
··· 417 } 418 419 type KnotsParams struct { 420 - LoggedInUser *oauth.MultiAccountUser 421 - Registrations []models.Registration 422 - Tabs []map[string]any 423 - Tab string 424 } 425 426 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 486 type NewRepoParams struct { 487 LoggedInUser *oauth.MultiAccountUser 488 Knots []string 489 } 490 491 func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { ··· 496 LoggedInUser *oauth.MultiAccountUser 497 Knots []string 498 RepoInfo repoinfo.RepoInfo 499 } 500 501 func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
··· 417 } 418 419 type KnotsParams struct { 420 + LoggedInUser *oauth.MultiAccountUser 421 + Registrations []models.Registration 422 + Tabs []map[string]any 423 + Tab string 424 + AvailableKnots []string 425 + DefaultKnot string 426 } 427 428 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { ··· 488 type NewRepoParams struct { 489 LoggedInUser *oauth.MultiAccountUser 490 Knots []string 491 + DefaultKnot string 492 } 493 494 func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error { ··· 499 LoggedInUser *oauth.MultiAccountUser 500 Knots []string 501 RepoInfo repoinfo.RepoInfo 502 + DefaultKnot string 503 } 504 505 func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
+28
appview/pages/templates/knots/index.html
··· 31 <div class="flex flex-col gap-6"> 32 {{ block "list" . }} {{ end }} 33 {{ block "register" . }} {{ end }} 34 </div> 35 </section> 36 {{ end }} ··· 62 </section> 63 {{ end }} 64 65 {{ define "register" }} 66 <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 67 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
··· 31 <div class="flex flex-col gap-6"> 32 {{ block "list" . }} {{ end }} 33 {{ block "register" . }} {{ end }} 34 + {{ block "default-knot" . }} {{ end }} 35 </div> 36 </section> 37 {{ end }} ··· 63 </section> 64 {{ end }} 65 66 + {{ define "default-knot" }} 67 + <section class="rounded w-full flex flex-col gap-2"> 68 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">default knot</h2> 69 + <form hx-post="/profile/default-knot" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 70 + <select 71 + id="default-knot" 72 + name="default-knot" 73 + required 74 + class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 75 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 76 + <option value="[[none]]" class="py-1" {{ if not $.DefaultKnot }}selected{{ end }}> 77 + Choose a default knot 78 + </option> 79 + {{ range $.AvailableKnots }} 80 + <option value="{{ . }}" class="py-1" {{ if eq . $.DefaultKnot }}selected{{ end }}> 81 + {{ . }} 82 + </option> 83 + {{ end }} 84 + </select> 85 + <button class="btn flex gap-2 items-center" type="submit"> 86 + {{ i "check" "size-4" }} 87 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </button> 89 + </form> 90 + </section> 91 + {{ end }} 92 + 93 {{ define "register" }} 94 <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 95 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2>
+3 -1
appview/pages/templates/repo/fork.html
··· 25 value="{{ . }}" 26 class="mr-2" 27 id="domain-{{ . }}" 28 - {{if eq (len $.Knots) 1}}checked{{end}} 29 /> 30 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 31 </div>
··· 25 value="{{ . }}" 26 class="mr-2" 27 id="domain-{{ . }}" 28 + {{if eq (len $.Knots) 1}}checked 29 + {{else if eq $.DefaultKnot . }}checked 30 + {{end}} 31 /> 32 <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 33 </div>
+3 -1
appview/pages/templates/repo/new.html
··· 155 class="mr-2" 156 id="domain-{{ . }}" 157 required 158 - {{if eq (len $.Knots) 1}}checked{{end}} 159 /> 160 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 161 </div>
··· 155 class="mr-2" 156 id="domain-{{ . }}" 157 required 158 + {{if eq (len $.Knots) 1}}checked 159 + {{else if eq $.DefaultKnot . }}checked 160 + {{end}} 161 /> 162 <label for="domain-{{ . }}" class="dark:text-white lowercase">{{ . }}</label> 163 </div>
+10
appview/repo/repo.go
··· 1004 return 1005 } 1006 1007 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1008 LoggedInUser: user, 1009 Knots: knots, 1010 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1011 }) 1012 1013 case http.MethodPost:
··· 1004 return 1005 } 1006 1007 + defaultKnot := "" 1008 + profile, err := db.GetProfile(rp.db, user.Did()) 1009 + if err != nil { 1010 + rp.logger.Warn("gettings user profile to get default knot", "error", err) 1011 + } 1012 + if profile != nil { 1013 + defaultKnot = profile.DefaultKnot 1014 + } 1015 + 1016 rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1017 LoggedInUser: user, 1018 Knots: knots, 1019 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 1020 + DefaultKnot: defaultKnot, 1021 }) 1022 1023 case http.MethodPost:
+40
appview/state/profile.go
··· 616 s.updateProfile(profile, w, r) 617 } 618 619 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 620 user := s.oauth.GetMultiAccountUser(r) 621 tx, err := s.db.BeginTx(r.Context(), nil)
··· 616 s.updateProfile(profile, w, r) 617 } 618 619 + func (s *State) UpdateProfileDefaultKnot(w http.ResponseWriter, r *http.Request) { 620 + err := r.ParseForm() 621 + if err != nil { 622 + log.Println("invalid profile update form", err) 623 + return 624 + } 625 + user := s.oauth.GetUser(r) 626 + 627 + profile, err := db.GetProfile(s.db, user.Did) 628 + if err != nil { 629 + log.Printf("getting profile data for %s: %s", user.Did, err) 630 + } 631 + 632 + if profile == nil { 633 + return 634 + } 635 + 636 + defaultKnot := r.Form.Get("default-knot") 637 + 638 + if defaultKnot == "[[none]]" { // see pages/templates/knots/index.html for more info on why we use this value 639 + defaultKnot = "" 640 + } 641 + 642 + profile.DefaultKnot = defaultKnot 643 + 644 + tx, err := s.db.BeginTx(r.Context(), nil) 645 + if err != nil { 646 + log.Println("failed to start transaction", err) 647 + return 648 + } 649 + 650 + err = db.UpsertProfile(tx, profile) 651 + if err != nil { 652 + log.Println("failed to update profile", err) 653 + return 654 + } 655 + 656 + s.pages.HxRefresh(w) 657 + } 658 + 659 func (s *State) updateProfile(profile *models.Profile, w http.ResponseWriter, r *http.Request) { 660 user := s.oauth.GetMultiAccountUser(r) 661 tx, err := s.db.BeginTx(r.Context(), nil)
+1
appview/state/router.go
··· 165 r.Get("/edit-pins", s.EditPinsFragment) 166 r.Post("/bio", s.UpdateProfileBio) 167 r.Post("/pins", s.UpdateProfilePins) 168 }) 169 170 r.Mount("/settings", s.SettingsRouter())
··· 165 r.Get("/edit-pins", s.EditPinsFragment) 166 r.Post("/bio", s.UpdateProfileBio) 167 r.Post("/pins", s.UpdateProfilePins) 168 + r.Post("/default-knot", s.UpdateProfileDefaultKnot) 169 }) 170 171 r.Mount("/settings", s.SettingsRouter())
+10
appview/state/state.go
··· 418 return 419 } 420 421 s.pages.NewRepo(w, pages.NewRepoParams{ 422 LoggedInUser: user, 423 Knots: knots, 424 }) 425 426 case http.MethodPost:
··· 418 return 419 } 420 421 + defaultKnot := "" 422 + profile, err := db.GetProfile(s.db, user.Did()) 423 + if err != nil { 424 + s.logger.Warn("gettings user profile to get default knot", "error", err) 425 + } 426 + if profile != nil { 427 + defaultKnot = profile.DefaultKnot 428 + } 429 + 430 s.pages.NewRepo(w, pages.NewRepoParams{ 431 LoggedInUser: user, 432 Knots: knots, 433 + DefaultKnot: defaultKnot, 434 }) 435 436 case http.MethodPost:

History

8 rounds 9 comments
sign up or login to add to the discussion
1 commit
expand
appview: allow default knot to be saved as a preference and then used when creating a new repo
no conflicts, ready to merge
expand 0 comments
1 commit
expand
appview: allow default knot to be saved as a preference and then used when creating a new repo
expand 0 comments
1 commit
expand
appview: allow default knot to be saved as a preference and then used when creating a new repo
expand 0 comments
1 commit
expand
appview: allows a default knot to be selected from knots available to the user
expand 5 comments

@oppi.li While attempting to implement something else related to profiles, I've noticed that UpsertProfile(...) herehttps://tangled.org/tangled.org/core/blob/master/appview/db/profile.go#L133 also gets called as part of a JS ingester here https://tangled.org/tangled.org/core/blob/master/appview/ingester.go#L352 which means the DefaultKnot field will be overwritten with an empty string when a profile event is ingested. Am I understanding that correctly?

So instead I should probably not use the UpsertProfile(...) but use a smaller scoped function that just sets the Default Knot field?

ah that is correct, the default knot will be overwritten by that call. i suppose that is one of the drawbacks of overloading models.Profile! instead of cloning UpsertProfile to add this as an exception, perhaps this is an indication that we need to avoid using the profile table/model for this. what do you think about creating a preferences table for stuff like this? we already have notification_preferences, we could do something similar for knots.

i see you have a PR open for punchcard work as well, which would fit quite well in a punchcard_preferences table.

Yh I'm 100% down for using that approach. While I was implementing the punchcard PR I had this nagging feeling in me that this and other things belonged in some sort of separate "preferences" table so splitting them out into things like knot_preferences or punchcard_preferences is the right answer for me.

I'll redo that for this PR and then do the same of the other PR and hopefully it will prevent the merge conflicts I was dreading 🙈

yeah! knot_preferences and punchcard_preferences seems okay for now. thanks for keeping at this by the way, and sorry for the slow review cycle!

No worries! I don't expect immediate reviews 😁

I've refactored to use a new knot_preferences table for this PR. I had some merge conflicts with master though, so rebased that although took me a couple attempts to get everything nice and neat, hence so many rounds 🙈

2 commits
expand
appview: allows a default knot to be configured
appview: allows a default knot to be selected from knots available to the user
expand 0 comments
1 commit
expand
appview: allows a default knot to be configured
expand 2 comments

the code itself works pretty nicely now. so i've noticed a couple issues (apologies for not looking more closely earlier!):

  • the default-knot selector only iterates over knots that the user is an admin of, whereas they should be able to choose any knot that they are a member of as the default. you can use s.enforcer.GetKnotsForUser(...) to get a list of knots that a user has access to
  • the HTML change adds knot1.tangled.sh as an explicit case, this is not necessary if the above is implemented
  • the HTML template currently triggers a request when the select changes, could we make this more like how the spindle selector works? a form with a button to submit.

I was looking for ages trying to find somewhere that did something similar. I don't use spindles so never saw that 🙈 A much better approach.

Also good shout on the knots for a user. Reading the code now, I understand what the registrations are now.

Anyhow, I've updated the PR and squashed everything so it should be good to test now.

Please note, that I have yet to figure out how to get multiple Knots running locally, and since I don't get knot1.tangled.sh locally I had to hard code some things in to test it worked, which is less than ideal.

1 commit
expand
appview: allows a default knot to be configured
expand 0 comments
1 commit
expand
appview: allows a default knot to be configured
expand 2 comments

i get this error at runtime:

2026/01/20 04:58:12 profile err 5 values for 6 columns
2026/01/20 04:58:12 failed to update profile 5 values for 6 columns

this sql query is incorrect:

		`insert or replace into profile (
			did,
			description,
			include_bluesky,
			location,
			pronouns,
			default_knot
		)
		values (?, ?, ?, ?, ?)`,

there should be 6 ? placeholders!

Ah crap, someone needs to create a lint tool that detects that 🤦‍♂️

Fixed.