···11+package handlers
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "log"
99+ "net/http"
1010+ "strings"
1111+ "time"
1212+1313+ "github.com/bluesky-social/indigo/api/atproto"
1414+ "github.com/bluesky-social/indigo/atproto/identity"
1515+ "github.com/bluesky-social/indigo/atproto/syntax"
1616+ "github.com/shindakun/attodo/internal/models"
1717+ "github.com/shindakun/attodo/internal/session"
1818+ "github.com/shindakun/bskyoauth"
1919+)
2020+2121+const ListCollection = "app.attodo.list"
2222+2323+type ListHandler struct {
2424+ client *bskyoauth.Client
2525+}
2626+2727+func NewListHandler(client *bskyoauth.Client) *ListHandler {
2828+ return &ListHandler{client: client}
2929+}
3030+3131+// HandleLists handles list CRUD operations
3232+func (h *ListHandler) HandleLists(w http.ResponseWriter, r *http.Request) {
3333+ switch r.Method {
3434+ case http.MethodGet:
3535+ h.handleListLists(w, r)
3636+ case http.MethodPost:
3737+ h.handleCreateList(w, r)
3838+ case http.MethodPut:
3939+ h.handleUpdateList(w, r)
4040+ case http.MethodDelete:
4141+ h.handleDeleteList(w, r)
4242+ case http.MethodPatch:
4343+ h.handleManageTasks(w, r)
4444+ default:
4545+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
4646+ }
4747+}
4848+4949+// HandleListDetail shows a specific list with its tasks
5050+func (h *ListHandler) HandleListDetail(w http.ResponseWriter, r *http.Request) {
5151+ sess, ok := session.GetSession(r)
5252+ if !ok {
5353+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
5454+ return
5555+ }
5656+5757+ // Extract rkey from URL path (e.g., /app/lists/view/abc123)
5858+ path := strings.TrimPrefix(r.URL.Path, "/app/lists/view/")
5959+ rkey := strings.TrimSuffix(path, "/")
6060+6161+ if rkey == "" {
6262+ http.Error(w, "List ID required", http.StatusBadRequest)
6363+ return
6464+ }
6565+6666+ // Get the list
6767+ log.Printf("Fetching list with rkey: %s", rkey)
6868+ var record map[string]interface{}
6969+ var err error
7070+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
7171+ record, err = h.getRecord(r.Context(), s, rkey)
7272+ return err
7373+ })
7474+7575+ if err != nil {
7676+ log.Printf("Failed to get list rkey=%s: %v", rkey, err)
7777+ http.Error(w, fmt.Sprintf("List not found: %v", err), http.StatusNotFound)
7878+ return
7979+ }
8080+8181+ log.Printf("Successfully fetched list record: %+v", record)
8282+8383+ // Parse list
8484+ list := parseListRecord(record)
8585+ list.RKey = rkey
8686+ list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey)
8787+8888+ // Resolve tasks from URIs
8989+ if len(list.TaskURIs) > 0 {
9090+ tasks, err := h.resolveTasksFromURIs(r.Context(), sess, list.TaskURIs)
9191+ if err != nil {
9292+ log.Printf("Failed to resolve tasks for list %s: %v", rkey, err)
9393+ // Continue anyway, just with empty tasks
9494+ } else {
9595+ list.Tasks = tasks
9696+ }
9797+ }
9898+9999+ // Update session
100100+ cookie, _ := r.Cookie("session_id")
101101+ if cookie != nil {
102102+ h.client.UpdateSession(cookie.Value, sess)
103103+ }
104104+105105+ // Render list detail view
106106+ w.Header().Set("Content-Type", "text/html")
107107+ Render(w, "list-detail.html", list)
108108+}
109109+110110+// handleCreateList creates a new list
111111+func (h *ListHandler) handleCreateList(w http.ResponseWriter, r *http.Request) {
112112+ sess, ok := session.GetSession(r)
113113+ if !ok {
114114+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
115115+ return
116116+ }
117117+118118+ // Parse form data
119119+ if err := r.ParseForm(); err != nil {
120120+ http.Error(w, "Invalid form data", http.StatusBadRequest)
121121+ return
122122+ }
123123+124124+ name := r.FormValue("name")
125125+ if name == "" {
126126+ http.Error(w, "Name is required", http.StatusBadRequest)
127127+ return
128128+ }
129129+130130+ // Create new list
131131+ list := &models.TaskList{
132132+ Name: name,
133133+ Description: r.FormValue("description"),
134134+ TaskURIs: []string{}, // Empty initially
135135+ CreatedAt: time.Now(),
136136+ UpdatedAt: time.Now(),
137137+ }
138138+139139+ // Build record
140140+ record := buildListRecord(list)
141141+142142+ // Create the record with retry logic
143143+ var output *atproto.RepoCreateRecord_Output
144144+ var err error
145145+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
146146+ output, err = h.client.CreateRecord(r.Context(), s, ListCollection, record)
147147+ return err
148148+ })
149149+150150+ if err != nil {
151151+ log.Printf("Failed to create list after retries: %v", err)
152152+ http.Error(w, "Failed to create list", http.StatusInternalServerError)
153153+ return
154154+ }
155155+156156+ // Update session
157157+ cookie, _ := r.Cookie("session_id")
158158+ if cookie != nil {
159159+ h.client.UpdateSession(cookie.Value, sess)
160160+ }
161161+162162+ // Extract RKey from URI
163163+ list.RKey = extractRKey(output.Uri)
164164+ list.URI = output.Uri
165165+166166+ log.Printf("List created: %s (%s)", list.Name, list.RKey)
167167+168168+ // Return the list partial for HTMX
169169+ w.Header().Set("Content-Type", "text/html")
170170+ Render(w, "list-item.html", list)
171171+}
172172+173173+// handleListLists retrieves all lists
174174+func (h *ListHandler) handleListLists(w http.ResponseWriter, r *http.Request) {
175175+ sess, ok := session.GetSession(r)
176176+ if !ok {
177177+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
178178+ return
179179+ }
180180+181181+ // Get lists from repository
182182+ var lists []*models.TaskList
183183+ var err error
184184+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
185185+ lists, err = h.ListRecords(r.Context(), s)
186186+ return err
187187+ })
188188+189189+ if err != nil {
190190+ log.Printf("Failed to list lists: %v", err)
191191+ http.Error(w, "Failed to list lists", http.StatusInternalServerError)
192192+ return
193193+ }
194194+195195+ // Update session
196196+ cookie, _ := r.Cookie("session_id")
197197+ if cookie != nil {
198198+ h.client.UpdateSession(cookie.Value, sess)
199199+ }
200200+201201+ // Return HTML partials for HTMX
202202+ w.Header().Set("Content-Type", "text/html")
203203+ for _, list := range lists {
204204+ Render(w, "list-item.html", list)
205205+ }
206206+}
207207+208208+// handleUpdateList updates an existing list
209209+func (h *ListHandler) handleUpdateList(w http.ResponseWriter, r *http.Request) {
210210+ sess, ok := session.GetSession(r)
211211+ if !ok {
212212+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
213213+ return
214214+ }
215215+216216+ // Parse form data
217217+ if err := r.ParseForm(); err != nil {
218218+ http.Error(w, "Invalid form data", http.StatusBadRequest)
219219+ return
220220+ }
221221+222222+ rkey := r.FormValue("rkey")
223223+ if rkey == "" {
224224+ http.Error(w, "rkey is required", http.StatusBadRequest)
225225+ return
226226+ }
227227+228228+ // Get current list
229229+ var record map[string]interface{}
230230+ var err error
231231+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
232232+ record, err = h.getRecord(r.Context(), s, rkey)
233233+ return err
234234+ })
235235+236236+ if err != nil {
237237+ log.Printf("Failed to get list: %v", err)
238238+ http.Error(w, "Failed to get list", http.StatusInternalServerError)
239239+ return
240240+ }
241241+242242+ // Parse existing record
243243+ list := parseListRecord(record)
244244+ list.RKey = rkey
245245+ // Build URI from DID and collection
246246+ list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey)
247247+248248+ // Update fields
249249+ if name := r.FormValue("name"); name != "" {
250250+ list.Name = name
251251+ }
252252+ list.Description = r.FormValue("description")
253253+ list.UpdatedAt = time.Now()
254254+255255+ // Handle task URI updates if provided
256256+ if taskURIsJSON := r.FormValue("taskUris"); taskURIsJSON != "" {
257257+ var taskURIs []string
258258+ if err := json.Unmarshal([]byte(taskURIsJSON), &taskURIs); err == nil {
259259+ list.TaskURIs = taskURIs
260260+ }
261261+ }
262262+263263+ // Build record and update
264264+ updatedRecord := buildListRecord(list)
265265+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
266266+ return h.updateRecord(r.Context(), s, rkey, updatedRecord)
267267+ })
268268+269269+ if err != nil {
270270+ log.Printf("Failed to update list: %v", err)
271271+ http.Error(w, "Failed to update list", http.StatusInternalServerError)
272272+ return
273273+ }
274274+275275+ // Update session
276276+ cookie, _ := r.Cookie("session_id")
277277+ if cookie != nil {
278278+ h.client.UpdateSession(cookie.Value, sess)
279279+ }
280280+281281+ log.Printf("List updated: %s", rkey)
282282+283283+ // Return updated list partial
284284+ w.Header().Set("Content-Type", "text/html")
285285+ Render(w, "list-item.html", list)
286286+}
287287+288288+// handleManageTasks adds or removes tasks from a list
289289+func (h *ListHandler) handleManageTasks(w http.ResponseWriter, r *http.Request) {
290290+ sess, ok := session.GetSession(r)
291291+ if !ok {
292292+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
293293+ return
294294+ }
295295+296296+ // Parse form data
297297+ if err := r.ParseForm(); err != nil {
298298+ http.Error(w, "Invalid form data", http.StatusBadRequest)
299299+ return
300300+ }
301301+302302+ rkey := r.FormValue("rkey")
303303+ taskURI := r.FormValue("taskUri")
304304+ action := r.FormValue("action") // "add" or "remove"
305305+306306+ if rkey == "" || taskURI == "" || action == "" {
307307+ http.Error(w, "rkey, taskUri, and action are required", http.StatusBadRequest)
308308+ return
309309+ }
310310+311311+ // Get current list
312312+ var record map[string]interface{}
313313+ var err error
314314+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
315315+ record, err = h.getRecord(r.Context(), s, rkey)
316316+ return err
317317+ })
318318+319319+ if err != nil {
320320+ log.Printf("Failed to get list: %v", err)
321321+ http.Error(w, "Failed to get list", http.StatusInternalServerError)
322322+ return
323323+ }
324324+325325+ // Parse list
326326+ list := parseListRecord(record)
327327+ list.RKey = rkey
328328+ list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey)
329329+330330+ // Modify task URIs based on action
331331+ switch action {
332332+ case "add":
333333+ // Check if task is already in the list
334334+ found := false
335335+ for _, uri := range list.TaskURIs {
336336+ if uri == taskURI {
337337+ found = true
338338+ break
339339+ }
340340+ }
341341+ if !found {
342342+ list.TaskURIs = append(list.TaskURIs, taskURI)
343343+ }
344344+ case "remove":
345345+ // Remove task from list
346346+ newURIs := make([]string, 0, len(list.TaskURIs))
347347+ for _, uri := range list.TaskURIs {
348348+ if uri != taskURI {
349349+ newURIs = append(newURIs, uri)
350350+ }
351351+ }
352352+ list.TaskURIs = newURIs
353353+ default:
354354+ http.Error(w, "Invalid action", http.StatusBadRequest)
355355+ return
356356+ }
357357+358358+ // Update timestamp
359359+ list.UpdatedAt = time.Now()
360360+361361+ // Build record and update
362362+ updatedRecord := buildListRecord(list)
363363+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
364364+ return h.updateRecord(r.Context(), s, rkey, updatedRecord)
365365+ })
366366+367367+ if err != nil {
368368+ log.Printf("Failed to update list tasks: %v", err)
369369+ http.Error(w, "Failed to update list", http.StatusInternalServerError)
370370+ return
371371+ }
372372+373373+ // Update session
374374+ cookie, _ := r.Cookie("session_id")
375375+ if cookie != nil {
376376+ h.client.UpdateSession(cookie.Value, sess)
377377+ }
378378+379379+ log.Printf("Task %s %sd to/from list %s", taskURI, action, rkey)
380380+381381+ // Return success
382382+ w.WriteHeader(http.StatusOK)
383383+ w.Write([]byte("Success"))
384384+}
385385+386386+// handleDeleteList deletes a list
387387+func (h *ListHandler) handleDeleteList(w http.ResponseWriter, r *http.Request) {
388388+ sess, ok := session.GetSession(r)
389389+ if !ok {
390390+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
391391+ return
392392+ }
393393+394394+ rkey := r.URL.Query().Get("rkey")
395395+ if rkey == "" {
396396+ http.Error(w, "rkey is required", http.StatusBadRequest)
397397+ return
398398+ }
399399+400400+ // Delete with retry logic
401401+ var err error
402402+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
403403+ return h.client.DeleteRecord(r.Context(), s, ListCollection, rkey)
404404+ })
405405+406406+ if err != nil {
407407+ log.Printf("Failed to delete list after retries: %v", err)
408408+ http.Error(w, "Failed to delete list", http.StatusInternalServerError)
409409+ return
410410+ }
411411+412412+ // Update session
413413+ cookie, _ := r.Cookie("session_id")
414414+ if cookie != nil {
415415+ h.client.UpdateSession(cookie.Value, sess)
416416+ }
417417+418418+ log.Printf("List deleted: %s", rkey)
419419+ w.WriteHeader(http.StatusOK)
420420+}
421421+422422+// listRecords fetches all lists from the repository
423423+// ListRecords fetches all list records for the given session (public for cross-handler access)
424424+func (h *ListHandler) ListRecords(ctx context.Context, sess *bskyoauth.Session) ([]*models.TaskList, error) {
425425+ // Build the XRPC URL
426426+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
427427+ sess.PDS, sess.DID, ListCollection)
428428+429429+ // Create request
430430+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
431431+ if err != nil {
432432+ return nil, err
433433+ }
434434+435435+ // Add authorization header
436436+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
437437+438438+ // Make request
439439+ client := &http.Client{Timeout: 10 * time.Second}
440440+ resp, err := client.Do(req)
441441+ if err != nil {
442442+ return nil, err
443443+ }
444444+ defer resp.Body.Close()
445445+446446+ if resp.StatusCode != http.StatusOK {
447447+ body, _ := io.ReadAll(resp.Body)
448448+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body))
449449+ }
450450+451451+ // Parse response
452452+ var result struct {
453453+ Records []struct {
454454+ URI string `json:"uri"`
455455+ Value map[string]interface{} `json:"value"`
456456+ } `json:"records"`
457457+ }
458458+459459+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
460460+ return nil, err
461461+ }
462462+463463+ // Convert records to TaskList objects
464464+ lists := make([]*models.TaskList, 0, len(result.Records))
465465+ for _, record := range result.Records {
466466+ list := parseListRecord(record.Value)
467467+ list.URI = record.URI
468468+ list.RKey = extractRKey(record.URI)
469469+ lists = append(lists, list)
470470+ }
471471+472472+ return lists, nil
473473+}
474474+475475+// getRecord retrieves a single record using com.atproto.repo.getRecord
476476+func (h *ListHandler) getRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) {
477477+ // Build the XRPC URL
478478+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
479479+ sess.PDS, sess.DID, ListCollection, rkey)
480480+481481+ // Create request
482482+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
483483+ if err != nil {
484484+ return nil, err
485485+ }
486486+487487+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
488488+489489+ // Create HTTP client with DPoP transport
490490+ transport := bskyoauth.NewDPoPTransport(http.DefaultTransport, sess.DPoPKey, sess.AccessToken, sess.DPoPNonce)
491491+ client := &http.Client{
492492+ Transport: transport,
493493+ Timeout: 10 * time.Second,
494494+ }
495495+496496+ resp, err := client.Do(req)
497497+ if err != nil {
498498+ return nil, err
499499+ }
500500+ defer resp.Body.Close()
501501+502502+ // Update nonce if present
503503+ if dpopTransport, ok := transport.(bskyoauth.DPoPTransport); ok {
504504+ sess.DPoPNonce = dpopTransport.GetNonce()
505505+ }
506506+507507+ if resp.StatusCode != http.StatusOK {
508508+ bodyBytes, _ := io.ReadAll(resp.Body)
509509+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes))
510510+ }
511511+512512+ // Parse response
513513+ var result struct {
514514+ URI string `json:"uri"`
515515+ CID string `json:"cid"`
516516+ Value map[string]interface{} `json:"value"`
517517+ }
518518+519519+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
520520+ return nil, fmt.Errorf("failed to decode response: %w", err)
521521+ }
522522+523523+ return result.Value, nil
524524+}
525525+526526+// updateRecord updates a record using com.atproto.repo.putRecord
527527+func (h *ListHandler) updateRecord(ctx context.Context, sess *bskyoauth.Session, rkey string, record map[string]interface{}) error {
528528+ log.Printf("updateRecord: DID=%s, Collection=%s, RKey=%s", sess.DID, ListCollection, rkey)
529529+530530+ // Resolve the actual PDS endpoint for this user
531531+ pdsHost, err := h.resolvePDSEndpoint(ctx, sess.DID)
532532+ if err != nil {
533533+ return fmt.Errorf("failed to resolve PDS endpoint: %w", err)
534534+ }
535535+ log.Printf("updateRecord: Resolved PDS=%s", pdsHost)
536536+537537+ // Add $type field to the record if not present
538538+ if _, exists := record["$type"]; !exists {
539539+ record["$type"] = ListCollection
540540+ }
541541+542542+ // Build the request body
543543+ body := map[string]interface{}{
544544+ "repo": sess.DID,
545545+ "collection": ListCollection,
546546+ "rkey": rkey,
547547+ "record": record,
548548+ }
549549+550550+ bodyJSON, err := json.Marshal(body)
551551+ if err != nil {
552552+ return fmt.Errorf("failed to marshal request: %w", err)
553553+ }
554554+555555+ // Create the request to the resolved PDS endpoint
556556+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", pdsHost)
557557+ req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(bodyJSON)))
558558+ if err != nil {
559559+ return err
560560+ }
561561+562562+ req.Header.Set("Content-Type", "application/json")
563563+564564+ // Create DPoP transport for authentication
565565+ dpopTransport := bskyoauth.NewDPoPTransport(
566566+ http.DefaultTransport,
567567+ sess.DPoPKey,
568568+ sess.AccessToken,
569569+ sess.DPoPNonce,
570570+ )
571571+572572+ httpClient := &http.Client{
573573+ Transport: dpopTransport,
574574+ Timeout: 10 * time.Second,
575575+ }
576576+577577+ resp, err := httpClient.Do(req)
578578+ if err != nil {
579579+ return err
580580+ }
581581+ defer resp.Body.Close()
582582+583583+ if resp.StatusCode != http.StatusOK {
584584+ bodyBytes, _ := io.ReadAll(resp.Body)
585585+ log.Printf("updateRecord: HTTP %d: %s", resp.StatusCode, string(bodyBytes))
586586+ return fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes))
587587+ }
588588+589589+ var output atproto.RepoPutRecord_Output
590590+ if err := json.NewDecoder(resp.Body).Decode(&output); err != nil {
591591+ return fmt.Errorf("failed to decode response: %w", err)
592592+ }
593593+594594+ log.Printf("updateRecord: Success! URI=%s", output.Uri)
595595+ return nil
596596+}
597597+598598+// resolvePDSEndpoint resolves the PDS endpoint for a given DID
599599+func (h *ListHandler) resolvePDSEndpoint(ctx context.Context, did string) (string, error) {
600600+ dir := identity.DefaultDirectory()
601601+ atid, err := syntax.ParseAtIdentifier(did)
602602+ if err != nil {
603603+ return "", err
604604+ }
605605+606606+ ident, err := dir.Lookup(ctx, *atid)
607607+ if err != nil {
608608+ return "", err
609609+ }
610610+611611+ return ident.PDSEndpoint(), nil
612612+}
613613+614614+// resolveTasksFromURIs fetches task records from their AT URIs
615615+func (h *ListHandler) resolveTasksFromURIs(ctx context.Context, sess *bskyoauth.Session, taskURIs []string) ([]*models.Task, error) {
616616+ tasks := make([]*models.Task, 0, len(taskURIs))
617617+618618+ for _, uri := range taskURIs {
619619+ // Parse the URI to extract collection and rkey
620620+ // Format: at://did:plc:xxx/app.attodo.task/rkey
621621+ parts := strings.Split(uri, "/")
622622+ if len(parts) < 4 {
623623+ log.Printf("Invalid task URI format: %s", uri)
624624+ continue
625625+ }
626626+627627+ collection := parts[len(parts)-2]
628628+ rkey := parts[len(parts)-1]
629629+630630+ // Only fetch if it's a task collection
631631+ if collection != "app.attodo.task" {
632632+ log.Printf("Skipping non-task URI: %s", uri)
633633+ continue
634634+ }
635635+636636+ // Fetch the task record
637637+ taskRecord, err := h.getTaskRecord(ctx, sess, rkey)
638638+ if err != nil {
639639+ log.Printf("Failed to fetch task %s: %v", rkey, err)
640640+ continue
641641+ }
642642+643643+ // Parse task
644644+ task := parseTaskRecord(taskRecord)
645645+ task.RKey = rkey
646646+ task.URI = uri
647647+648648+ tasks = append(tasks, task)
649649+ }
650650+651651+ return tasks, nil
652652+}
653653+654654+// getTaskRecord retrieves a single task record using com.atproto.repo.getRecord
655655+func (h *ListHandler) getTaskRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) {
656656+ // Build the XRPC URL for tasks
657657+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
658658+ sess.PDS, sess.DID, "app.attodo.task", rkey)
659659+660660+ // Create request
661661+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
662662+ if err != nil {
663663+ return nil, err
664664+ }
665665+666666+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
667667+668668+ // Create HTTP client with DPoP transport
669669+ transport := bskyoauth.NewDPoPTransport(http.DefaultTransport, sess.DPoPKey, sess.AccessToken, sess.DPoPNonce)
670670+ client := &http.Client{
671671+ Transport: transport,
672672+ Timeout: 10 * time.Second,
673673+ }
674674+675675+ resp, err := client.Do(req)
676676+ if err != nil {
677677+ return nil, err
678678+ }
679679+ defer resp.Body.Close()
680680+681681+ // Update nonce if present
682682+ if dpopTransport, ok := transport.(bskyoauth.DPoPTransport); ok {
683683+ sess.DPoPNonce = dpopTransport.GetNonce()
684684+ }
685685+686686+ if resp.StatusCode != http.StatusOK {
687687+ bodyBytes, _ := io.ReadAll(resp.Body)
688688+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes))
689689+ }
690690+691691+ // Parse response
692692+ var result struct {
693693+ URI string `json:"uri"`
694694+ CID string `json:"cid"`
695695+ Value map[string]interface{} `json:"value"`
696696+ }
697697+698698+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
699699+ return nil, fmt.Errorf("failed to decode response: %w", err)
700700+ }
701701+702702+ return result.Value, nil
703703+}
704704+705705+// parseTaskRecord parses a task record from AT Protocol
706706+func parseTaskRecord(record map[string]interface{}) *models.Task {
707707+ task := &models.Task{}
708708+709709+ if title, ok := record["title"].(string); ok {
710710+ task.Title = title
711711+ }
712712+ if desc, ok := record["description"].(string); ok {
713713+ task.Description = desc
714714+ }
715715+ if completed, ok := record["completed"].(bool); ok {
716716+ task.Completed = completed
717717+ }
718718+ if createdAt, ok := record["createdAt"].(string); ok {
719719+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
720720+ task.CreatedAt = t
721721+ }
722722+ }
723723+ if completedAt, ok := record["completedAt"].(string); ok {
724724+ if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
725725+ task.CompletedAt = &t
726726+ }
727727+ }
728728+729729+ return task
730730+}
731731+732732+// withRetry handles token refresh and retries
733733+// WithRetry executes an operation with automatic token refresh on errors (public for cross-handler access)
734734+func (h *ListHandler) WithRetry(ctx context.Context, sess *bskyoauth.Session, fn func(*bskyoauth.Session) error) (*bskyoauth.Session, error) {
735735+ const maxRetries = 3
736736+737737+ for i := 0; i < maxRetries; i++ {
738738+ err := fn(sess)
739739+ if err == nil {
740740+ return sess, nil
741741+ }
742742+743743+ // Check if it's a token expiration error
744744+ if strings.Contains(err.Error(), "400") || strings.Contains(err.Error(), "401") {
745745+ log.Printf("Token may be expired, attempting refresh (attempt %d/%d)", i+1, maxRetries)
746746+747747+ // Try to refresh the token
748748+ newSess, refreshErr := h.client.RefreshToken(ctx, sess)
749749+ if refreshErr != nil {
750750+ log.Printf("Failed to refresh token: %v", refreshErr)
751751+ return sess, err // Return original error
752752+ }
753753+754754+ sess = newSess
755755+ continue
756756+ }
757757+758758+ // Not a token error, return immediately
759759+ return sess, err
760760+ }
761761+762762+ return sess, fmt.Errorf("max retries exceeded")
763763+}
764764+765765+// Helper functions for record building/parsing
766766+767767+func buildListRecord(list *models.TaskList) map[string]interface{} {
768768+ return map[string]interface{}{
769769+ "$type": ListCollection,
770770+ "name": list.Name,
771771+ "description": list.Description,
772772+ "taskUris": list.TaskURIs,
773773+ "createdAt": list.CreatedAt.Format(time.RFC3339),
774774+ "updatedAt": list.UpdatedAt.Format(time.RFC3339),
775775+ }
776776+}
777777+778778+func parseListRecord(value map[string]interface{}) *models.TaskList {
779779+ list := &models.TaskList{}
780780+781781+ if name, ok := value["name"].(string); ok {
782782+ list.Name = name
783783+ }
784784+ if desc, ok := value["description"].(string); ok {
785785+ list.Description = desc
786786+ }
787787+ if taskURIs, ok := value["taskUris"].([]interface{}); ok {
788788+ list.TaskURIs = make([]string, 0, len(taskURIs))
789789+ for _, uri := range taskURIs {
790790+ if uriStr, ok := uri.(string); ok {
791791+ list.TaskURIs = append(list.TaskURIs, uriStr)
792792+ }
793793+ }
794794+ }
795795+ if createdAt, ok := value["createdAt"].(string); ok {
796796+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
797797+ list.CreatedAt = t
798798+ }
799799+ }
800800+ if updatedAt, ok := value["updatedAt"].(string); ok {
801801+ if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
802802+ list.UpdatedAt = t
803803+ }
804804+ }
805805+806806+ return list
807807+}
+34-1
internal/handlers/tasks.go
···2121const TaskCollection = "app.attodo.task"
22222323type TaskHandler struct {
2424- client *bskyoauth.Client
2424+ client *bskyoauth.Client
2525+ listHandler *ListHandler
2526}
26272728func NewTaskHandler(client *bskyoauth.Client) *TaskHandler {
2829 return &TaskHandler{client: client}
3030+}
3131+3232+// SetListHandler allows setting the list handler for cross-referencing
3333+func (h *TaskHandler) SetListHandler(listHandler *ListHandler) {
3434+ h.listHandler = listHandler
2935}
30363137// withRetry executes an operation with automatic token refresh on DPoP errors
···395401 log.Printf("Failed to list tasks: %v", err)
396402 // Return empty list on error rather than failing
397403 tasks = []models.Task{}
404404+ }
405405+406406+ // Fetch all lists to populate task-to-list relationships
407407+ if h.listHandler != nil {
408408+ var lists []*models.TaskList
409409+ sess, err = h.listHandler.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
410410+ lists, err = h.listHandler.ListRecords(r.Context(), s)
411411+ return err
412412+ })
413413+414414+ if err == nil && lists != nil {
415415+ // Create a map of task URI to lists
416416+ taskListMap := make(map[string][]*models.TaskList)
417417+ for _, list := range lists {
418418+ for _, taskURI := range list.TaskURIs {
419419+ taskListMap[taskURI] = append(taskListMap[taskURI], list)
420420+ }
421421+ }
422422+423423+ // Populate the Lists field for each task
424424+ for i := range tasks {
425425+ taskURI := tasks[i].URI
426426+ if taskLists, exists := taskListMap[taskURI]; exists {
427427+ tasks[i].Lists = taskLists
428428+ }
429429+ }
430430+ }
398431 }
399432400433 // Filter tasks based on completion status
+19
internal/models/task.go
···1313 // Metadata from AT Protocol (populated after creation)
1414 RKey string `json:"-"` // Record key (extracted from URI)
1515 URI string `json:"-"` // Full AT URI
1616+1717+ // Transient field - populated when fetching task with list memberships
1818+ Lists []*TaskList `json:"-"` // Lists this task belongs to (not stored in AT Protocol)
1919+}
2020+2121+// TaskList represents a collection of tasks stored in AT Protocol
2222+type TaskList struct {
2323+ Name string `json:"name"` // Name of the list (e.g., "Work", "Personal", "Shopping")
2424+ Description string `json:"description,omitempty"` // Optional description of the list
2525+ TaskURIs []string `json:"taskUris"` // Array of AT URIs referencing tasks
2626+ CreatedAt time.Time `json:"createdAt"`
2727+ UpdatedAt time.Time `json:"updatedAt"`
2828+2929+ // Metadata from AT Protocol (populated after creation)
3030+ RKey string `json:"-"` // Record key (extracted from URI)
3131+ URI string `json:"-"` // Full AT URI
3232+3333+ // Transient field - populated when fetching list with tasks
3434+ Tasks []*Task `json:"-"` // Resolved task objects (not stored in AT Protocol)
1635}