···1+package handlers
2+3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "io"
8+ "log"
9+ "net/http"
10+ "strings"
11+ "time"
12+13+ "github.com/bluesky-social/indigo/api/atproto"
14+ "github.com/bluesky-social/indigo/atproto/identity"
15+ "github.com/bluesky-social/indigo/atproto/syntax"
16+ "github.com/shindakun/attodo/internal/models"
17+ "github.com/shindakun/attodo/internal/session"
18+ "github.com/shindakun/bskyoauth"
19+)
20+21+const ListCollection = "app.attodo.list"
22+23+type ListHandler struct {
24+ client *bskyoauth.Client
25+}
26+27+func NewListHandler(client *bskyoauth.Client) *ListHandler {
28+ return &ListHandler{client: client}
29+}
30+31+// HandleLists handles list CRUD operations
32+func (h *ListHandler) HandleLists(w http.ResponseWriter, r *http.Request) {
33+ switch r.Method {
34+ case http.MethodGet:
35+ h.handleListLists(w, r)
36+ case http.MethodPost:
37+ h.handleCreateList(w, r)
38+ case http.MethodPut:
39+ h.handleUpdateList(w, r)
40+ case http.MethodDelete:
41+ h.handleDeleteList(w, r)
42+ case http.MethodPatch:
43+ h.handleManageTasks(w, r)
44+ default:
45+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
46+ }
47+}
48+49+// HandleListDetail shows a specific list with its tasks
50+func (h *ListHandler) HandleListDetail(w http.ResponseWriter, r *http.Request) {
51+ sess, ok := session.GetSession(r)
52+ if !ok {
53+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
54+ return
55+ }
56+57+ // Extract rkey from URL path (e.g., /app/lists/view/abc123)
58+ path := strings.TrimPrefix(r.URL.Path, "/app/lists/view/")
59+ rkey := strings.TrimSuffix(path, "/")
60+61+ if rkey == "" {
62+ http.Error(w, "List ID required", http.StatusBadRequest)
63+ return
64+ }
65+66+ // Get the list
67+ log.Printf("Fetching list with rkey: %s", rkey)
68+ var record map[string]interface{}
69+ var err error
70+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
71+ record, err = h.getRecord(r.Context(), s, rkey)
72+ return err
73+ })
74+75+ if err != nil {
76+ log.Printf("Failed to get list rkey=%s: %v", rkey, err)
77+ http.Error(w, fmt.Sprintf("List not found: %v", err), http.StatusNotFound)
78+ return
79+ }
80+81+ log.Printf("Successfully fetched list record: %+v", record)
82+83+ // Parse list
84+ list := parseListRecord(record)
85+ list.RKey = rkey
86+ list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey)
87+88+ // Resolve tasks from URIs
89+ if len(list.TaskURIs) > 0 {
90+ tasks, err := h.resolveTasksFromURIs(r.Context(), sess, list.TaskURIs)
91+ if err != nil {
92+ log.Printf("Failed to resolve tasks for list %s: %v", rkey, err)
93+ // Continue anyway, just with empty tasks
94+ } else {
95+ list.Tasks = tasks
96+ }
97+ }
98+99+ // Update session
100+ cookie, _ := r.Cookie("session_id")
101+ if cookie != nil {
102+ h.client.UpdateSession(cookie.Value, sess)
103+ }
104+105+ // Render list detail view
106+ w.Header().Set("Content-Type", "text/html")
107+ Render(w, "list-detail.html", list)
108+}
109+110+// handleCreateList creates a new list
111+func (h *ListHandler) handleCreateList(w http.ResponseWriter, r *http.Request) {
112+ sess, ok := session.GetSession(r)
113+ if !ok {
114+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
115+ return
116+ }
117+118+ // Parse form data
119+ if err := r.ParseForm(); err != nil {
120+ http.Error(w, "Invalid form data", http.StatusBadRequest)
121+ return
122+ }
123+124+ name := r.FormValue("name")
125+ if name == "" {
126+ http.Error(w, "Name is required", http.StatusBadRequest)
127+ return
128+ }
129+130+ // Create new list
131+ list := &models.TaskList{
132+ Name: name,
133+ Description: r.FormValue("description"),
134+ TaskURIs: []string{}, // Empty initially
135+ CreatedAt: time.Now(),
136+ UpdatedAt: time.Now(),
137+ }
138+139+ // Build record
140+ record := buildListRecord(list)
141+142+ // Create the record with retry logic
143+ var output *atproto.RepoCreateRecord_Output
144+ var err error
145+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
146+ output, err = h.client.CreateRecord(r.Context(), s, ListCollection, record)
147+ return err
148+ })
149+150+ if err != nil {
151+ log.Printf("Failed to create list after retries: %v", err)
152+ http.Error(w, "Failed to create list", http.StatusInternalServerError)
153+ return
154+ }
155+156+ // Update session
157+ cookie, _ := r.Cookie("session_id")
158+ if cookie != nil {
159+ h.client.UpdateSession(cookie.Value, sess)
160+ }
161+162+ // Extract RKey from URI
163+ list.RKey = extractRKey(output.Uri)
164+ list.URI = output.Uri
165+166+ log.Printf("List created: %s (%s)", list.Name, list.RKey)
167+168+ // Return the list partial for HTMX
169+ w.Header().Set("Content-Type", "text/html")
170+ Render(w, "list-item.html", list)
171+}
172+173+// handleListLists retrieves all lists
174+func (h *ListHandler) handleListLists(w http.ResponseWriter, r *http.Request) {
175+ sess, ok := session.GetSession(r)
176+ if !ok {
177+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
178+ return
179+ }
180+181+ // Get lists from repository
182+ var lists []*models.TaskList
183+ var err error
184+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
185+ lists, err = h.ListRecords(r.Context(), s)
186+ return err
187+ })
188+189+ if err != nil {
190+ log.Printf("Failed to list lists: %v", err)
191+ http.Error(w, "Failed to list lists", http.StatusInternalServerError)
192+ return
193+ }
194+195+ // Update session
196+ cookie, _ := r.Cookie("session_id")
197+ if cookie != nil {
198+ h.client.UpdateSession(cookie.Value, sess)
199+ }
200+201+ // Return HTML partials for HTMX
202+ w.Header().Set("Content-Type", "text/html")
203+ for _, list := range lists {
204+ Render(w, "list-item.html", list)
205+ }
206+}
207+208+// handleUpdateList updates an existing list
209+func (h *ListHandler) handleUpdateList(w http.ResponseWriter, r *http.Request) {
210+ sess, ok := session.GetSession(r)
211+ if !ok {
212+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
213+ return
214+ }
215+216+ // Parse form data
217+ if err := r.ParseForm(); err != nil {
218+ http.Error(w, "Invalid form data", http.StatusBadRequest)
219+ return
220+ }
221+222+ rkey := r.FormValue("rkey")
223+ if rkey == "" {
224+ http.Error(w, "rkey is required", http.StatusBadRequest)
225+ return
226+ }
227+228+ // Get current list
229+ var record map[string]interface{}
230+ var err error
231+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
232+ record, err = h.getRecord(r.Context(), s, rkey)
233+ return err
234+ })
235+236+ if err != nil {
237+ log.Printf("Failed to get list: %v", err)
238+ http.Error(w, "Failed to get list", http.StatusInternalServerError)
239+ return
240+ }
241+242+ // Parse existing record
243+ list := parseListRecord(record)
244+ list.RKey = rkey
245+ // Build URI from DID and collection
246+ list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey)
247+248+ // Update fields
249+ if name := r.FormValue("name"); name != "" {
250+ list.Name = name
251+ }
252+ list.Description = r.FormValue("description")
253+ list.UpdatedAt = time.Now()
254+255+ // Handle task URI updates if provided
256+ if taskURIsJSON := r.FormValue("taskUris"); taskURIsJSON != "" {
257+ var taskURIs []string
258+ if err := json.Unmarshal([]byte(taskURIsJSON), &taskURIs); err == nil {
259+ list.TaskURIs = taskURIs
260+ }
261+ }
262+263+ // Build record and update
264+ updatedRecord := buildListRecord(list)
265+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
266+ return h.updateRecord(r.Context(), s, rkey, updatedRecord)
267+ })
268+269+ if err != nil {
270+ log.Printf("Failed to update list: %v", err)
271+ http.Error(w, "Failed to update list", http.StatusInternalServerError)
272+ return
273+ }
274+275+ // Update session
276+ cookie, _ := r.Cookie("session_id")
277+ if cookie != nil {
278+ h.client.UpdateSession(cookie.Value, sess)
279+ }
280+281+ log.Printf("List updated: %s", rkey)
282+283+ // Return updated list partial
284+ w.Header().Set("Content-Type", "text/html")
285+ Render(w, "list-item.html", list)
286+}
287+288+// handleManageTasks adds or removes tasks from a list
289+func (h *ListHandler) handleManageTasks(w http.ResponseWriter, r *http.Request) {
290+ sess, ok := session.GetSession(r)
291+ if !ok {
292+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
293+ return
294+ }
295+296+ // Parse form data
297+ if err := r.ParseForm(); err != nil {
298+ http.Error(w, "Invalid form data", http.StatusBadRequest)
299+ return
300+ }
301+302+ rkey := r.FormValue("rkey")
303+ taskURI := r.FormValue("taskUri")
304+ action := r.FormValue("action") // "add" or "remove"
305+306+ if rkey == "" || taskURI == "" || action == "" {
307+ http.Error(w, "rkey, taskUri, and action are required", http.StatusBadRequest)
308+ return
309+ }
310+311+ // Get current list
312+ var record map[string]interface{}
313+ var err error
314+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
315+ record, err = h.getRecord(r.Context(), s, rkey)
316+ return err
317+ })
318+319+ if err != nil {
320+ log.Printf("Failed to get list: %v", err)
321+ http.Error(w, "Failed to get list", http.StatusInternalServerError)
322+ return
323+ }
324+325+ // Parse list
326+ list := parseListRecord(record)
327+ list.RKey = rkey
328+ list.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, ListCollection, rkey)
329+330+ // Modify task URIs based on action
331+ switch action {
332+ case "add":
333+ // Check if task is already in the list
334+ found := false
335+ for _, uri := range list.TaskURIs {
336+ if uri == taskURI {
337+ found = true
338+ break
339+ }
340+ }
341+ if !found {
342+ list.TaskURIs = append(list.TaskURIs, taskURI)
343+ }
344+ case "remove":
345+ // Remove task from list
346+ newURIs := make([]string, 0, len(list.TaskURIs))
347+ for _, uri := range list.TaskURIs {
348+ if uri != taskURI {
349+ newURIs = append(newURIs, uri)
350+ }
351+ }
352+ list.TaskURIs = newURIs
353+ default:
354+ http.Error(w, "Invalid action", http.StatusBadRequest)
355+ return
356+ }
357+358+ // Update timestamp
359+ list.UpdatedAt = time.Now()
360+361+ // Build record and update
362+ updatedRecord := buildListRecord(list)
363+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
364+ return h.updateRecord(r.Context(), s, rkey, updatedRecord)
365+ })
366+367+ if err != nil {
368+ log.Printf("Failed to update list tasks: %v", err)
369+ http.Error(w, "Failed to update list", http.StatusInternalServerError)
370+ return
371+ }
372+373+ // Update session
374+ cookie, _ := r.Cookie("session_id")
375+ if cookie != nil {
376+ h.client.UpdateSession(cookie.Value, sess)
377+ }
378+379+ log.Printf("Task %s %sd to/from list %s", taskURI, action, rkey)
380+381+ // Return success
382+ w.WriteHeader(http.StatusOK)
383+ w.Write([]byte("Success"))
384+}
385+386+// handleDeleteList deletes a list
387+func (h *ListHandler) handleDeleteList(w http.ResponseWriter, r *http.Request) {
388+ sess, ok := session.GetSession(r)
389+ if !ok {
390+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
391+ return
392+ }
393+394+ rkey := r.URL.Query().Get("rkey")
395+ if rkey == "" {
396+ http.Error(w, "rkey is required", http.StatusBadRequest)
397+ return
398+ }
399+400+ // Delete with retry logic
401+ var err error
402+ sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
403+ return h.client.DeleteRecord(r.Context(), s, ListCollection, rkey)
404+ })
405+406+ if err != nil {
407+ log.Printf("Failed to delete list after retries: %v", err)
408+ http.Error(w, "Failed to delete list", http.StatusInternalServerError)
409+ return
410+ }
411+412+ // Update session
413+ cookie, _ := r.Cookie("session_id")
414+ if cookie != nil {
415+ h.client.UpdateSession(cookie.Value, sess)
416+ }
417+418+ log.Printf("List deleted: %s", rkey)
419+ w.WriteHeader(http.StatusOK)
420+}
421+422+// listRecords fetches all lists from the repository
423+// ListRecords fetches all list records for the given session (public for cross-handler access)
424+func (h *ListHandler) ListRecords(ctx context.Context, sess *bskyoauth.Session) ([]*models.TaskList, error) {
425+ // Build the XRPC URL
426+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
427+ sess.PDS, sess.DID, ListCollection)
428+429+ // Create request
430+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
431+ if err != nil {
432+ return nil, err
433+ }
434+435+ // Add authorization header
436+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
437+438+ // Make request
439+ client := &http.Client{Timeout: 10 * time.Second}
440+ resp, err := client.Do(req)
441+ if err != nil {
442+ return nil, err
443+ }
444+ defer resp.Body.Close()
445+446+ if resp.StatusCode != http.StatusOK {
447+ body, _ := io.ReadAll(resp.Body)
448+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body))
449+ }
450+451+ // Parse response
452+ var result struct {
453+ Records []struct {
454+ URI string `json:"uri"`
455+ Value map[string]interface{} `json:"value"`
456+ } `json:"records"`
457+ }
458+459+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
460+ return nil, err
461+ }
462+463+ // Convert records to TaskList objects
464+ lists := make([]*models.TaskList, 0, len(result.Records))
465+ for _, record := range result.Records {
466+ list := parseListRecord(record.Value)
467+ list.URI = record.URI
468+ list.RKey = extractRKey(record.URI)
469+ lists = append(lists, list)
470+ }
471+472+ return lists, nil
473+}
474+475+// getRecord retrieves a single record using com.atproto.repo.getRecord
476+func (h *ListHandler) getRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) {
477+ // Build the XRPC URL
478+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
479+ sess.PDS, sess.DID, ListCollection, rkey)
480+481+ // Create request
482+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
483+ if err != nil {
484+ return nil, err
485+ }
486+487+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
488+489+ // Create HTTP client with DPoP transport
490+ transport := bskyoauth.NewDPoPTransport(http.DefaultTransport, sess.DPoPKey, sess.AccessToken, sess.DPoPNonce)
491+ client := &http.Client{
492+ Transport: transport,
493+ Timeout: 10 * time.Second,
494+ }
495+496+ resp, err := client.Do(req)
497+ if err != nil {
498+ return nil, err
499+ }
500+ defer resp.Body.Close()
501+502+ // Update nonce if present
503+ if dpopTransport, ok := transport.(bskyoauth.DPoPTransport); ok {
504+ sess.DPoPNonce = dpopTransport.GetNonce()
505+ }
506+507+ if resp.StatusCode != http.StatusOK {
508+ bodyBytes, _ := io.ReadAll(resp.Body)
509+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes))
510+ }
511+512+ // Parse response
513+ var result struct {
514+ URI string `json:"uri"`
515+ CID string `json:"cid"`
516+ Value map[string]interface{} `json:"value"`
517+ }
518+519+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
520+ return nil, fmt.Errorf("failed to decode response: %w", err)
521+ }
522+523+ return result.Value, nil
524+}
525+526+// updateRecord updates a record using com.atproto.repo.putRecord
527+func (h *ListHandler) updateRecord(ctx context.Context, sess *bskyoauth.Session, rkey string, record map[string]interface{}) error {
528+ log.Printf("updateRecord: DID=%s, Collection=%s, RKey=%s", sess.DID, ListCollection, rkey)
529+530+ // Resolve the actual PDS endpoint for this user
531+ pdsHost, err := h.resolvePDSEndpoint(ctx, sess.DID)
532+ if err != nil {
533+ return fmt.Errorf("failed to resolve PDS endpoint: %w", err)
534+ }
535+ log.Printf("updateRecord: Resolved PDS=%s", pdsHost)
536+537+ // Add $type field to the record if not present
538+ if _, exists := record["$type"]; !exists {
539+ record["$type"] = ListCollection
540+ }
541+542+ // Build the request body
543+ body := map[string]interface{}{
544+ "repo": sess.DID,
545+ "collection": ListCollection,
546+ "rkey": rkey,
547+ "record": record,
548+ }
549+550+ bodyJSON, err := json.Marshal(body)
551+ if err != nil {
552+ return fmt.Errorf("failed to marshal request: %w", err)
553+ }
554+555+ // Create the request to the resolved PDS endpoint
556+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.putRecord", pdsHost)
557+ req, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(bodyJSON)))
558+ if err != nil {
559+ return err
560+ }
561+562+ req.Header.Set("Content-Type", "application/json")
563+564+ // Create DPoP transport for authentication
565+ dpopTransport := bskyoauth.NewDPoPTransport(
566+ http.DefaultTransport,
567+ sess.DPoPKey,
568+ sess.AccessToken,
569+ sess.DPoPNonce,
570+ )
571+572+ httpClient := &http.Client{
573+ Transport: dpopTransport,
574+ Timeout: 10 * time.Second,
575+ }
576+577+ resp, err := httpClient.Do(req)
578+ if err != nil {
579+ return err
580+ }
581+ defer resp.Body.Close()
582+583+ if resp.StatusCode != http.StatusOK {
584+ bodyBytes, _ := io.ReadAll(resp.Body)
585+ log.Printf("updateRecord: HTTP %d: %s", resp.StatusCode, string(bodyBytes))
586+ return fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes))
587+ }
588+589+ var output atproto.RepoPutRecord_Output
590+ if err := json.NewDecoder(resp.Body).Decode(&output); err != nil {
591+ return fmt.Errorf("failed to decode response: %w", err)
592+ }
593+594+ log.Printf("updateRecord: Success! URI=%s", output.Uri)
595+ return nil
596+}
597+598+// resolvePDSEndpoint resolves the PDS endpoint for a given DID
599+func (h *ListHandler) resolvePDSEndpoint(ctx context.Context, did string) (string, error) {
600+ dir := identity.DefaultDirectory()
601+ atid, err := syntax.ParseAtIdentifier(did)
602+ if err != nil {
603+ return "", err
604+ }
605+606+ ident, err := dir.Lookup(ctx, *atid)
607+ if err != nil {
608+ return "", err
609+ }
610+611+ return ident.PDSEndpoint(), nil
612+}
613+614+// resolveTasksFromURIs fetches task records from their AT URIs
615+func (h *ListHandler) resolveTasksFromURIs(ctx context.Context, sess *bskyoauth.Session, taskURIs []string) ([]*models.Task, error) {
616+ tasks := make([]*models.Task, 0, len(taskURIs))
617+618+ for _, uri := range taskURIs {
619+ // Parse the URI to extract collection and rkey
620+ // Format: at://did:plc:xxx/app.attodo.task/rkey
621+ parts := strings.Split(uri, "/")
622+ if len(parts) < 4 {
623+ log.Printf("Invalid task URI format: %s", uri)
624+ continue
625+ }
626+627+ collection := parts[len(parts)-2]
628+ rkey := parts[len(parts)-1]
629+630+ // Only fetch if it's a task collection
631+ if collection != "app.attodo.task" {
632+ log.Printf("Skipping non-task URI: %s", uri)
633+ continue
634+ }
635+636+ // Fetch the task record
637+ taskRecord, err := h.getTaskRecord(ctx, sess, rkey)
638+ if err != nil {
639+ log.Printf("Failed to fetch task %s: %v", rkey, err)
640+ continue
641+ }
642+643+ // Parse task
644+ task := parseTaskRecord(taskRecord)
645+ task.RKey = rkey
646+ task.URI = uri
647+648+ tasks = append(tasks, task)
649+ }
650+651+ return tasks, nil
652+}
653+654+// getTaskRecord retrieves a single task record using com.atproto.repo.getRecord
655+func (h *ListHandler) getTaskRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) {
656+ // Build the XRPC URL for tasks
657+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
658+ sess.PDS, sess.DID, "app.attodo.task", rkey)
659+660+ // Create request
661+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
662+ if err != nil {
663+ return nil, err
664+ }
665+666+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
667+668+ // Create HTTP client with DPoP transport
669+ transport := bskyoauth.NewDPoPTransport(http.DefaultTransport, sess.DPoPKey, sess.AccessToken, sess.DPoPNonce)
670+ client := &http.Client{
671+ Transport: transport,
672+ Timeout: 10 * time.Second,
673+ }
674+675+ resp, err := client.Do(req)
676+ if err != nil {
677+ return nil, err
678+ }
679+ defer resp.Body.Close()
680+681+ // Update nonce if present
682+ if dpopTransport, ok := transport.(bskyoauth.DPoPTransport); ok {
683+ sess.DPoPNonce = dpopTransport.GetNonce()
684+ }
685+686+ if resp.StatusCode != http.StatusOK {
687+ bodyBytes, _ := io.ReadAll(resp.Body)
688+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(bodyBytes))
689+ }
690+691+ // Parse response
692+ var result struct {
693+ URI string `json:"uri"`
694+ CID string `json:"cid"`
695+ Value map[string]interface{} `json:"value"`
696+ }
697+698+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
699+ return nil, fmt.Errorf("failed to decode response: %w", err)
700+ }
701+702+ return result.Value, nil
703+}
704+705+// parseTaskRecord parses a task record from AT Protocol
706+func parseTaskRecord(record map[string]interface{}) *models.Task {
707+ task := &models.Task{}
708+709+ if title, ok := record["title"].(string); ok {
710+ task.Title = title
711+ }
712+ if desc, ok := record["description"].(string); ok {
713+ task.Description = desc
714+ }
715+ if completed, ok := record["completed"].(bool); ok {
716+ task.Completed = completed
717+ }
718+ if createdAt, ok := record["createdAt"].(string); ok {
719+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
720+ task.CreatedAt = t
721+ }
722+ }
723+ if completedAt, ok := record["completedAt"].(string); ok {
724+ if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
725+ task.CompletedAt = &t
726+ }
727+ }
728+729+ return task
730+}
731+732+// withRetry handles token refresh and retries
733+// WithRetry executes an operation with automatic token refresh on errors (public for cross-handler access)
734+func (h *ListHandler) WithRetry(ctx context.Context, sess *bskyoauth.Session, fn func(*bskyoauth.Session) error) (*bskyoauth.Session, error) {
735+ const maxRetries = 3
736+737+ for i := 0; i < maxRetries; i++ {
738+ err := fn(sess)
739+ if err == nil {
740+ return sess, nil
741+ }
742+743+ // Check if it's a token expiration error
744+ if strings.Contains(err.Error(), "400") || strings.Contains(err.Error(), "401") {
745+ log.Printf("Token may be expired, attempting refresh (attempt %d/%d)", i+1, maxRetries)
746+747+ // Try to refresh the token
748+ newSess, refreshErr := h.client.RefreshToken(ctx, sess)
749+ if refreshErr != nil {
750+ log.Printf("Failed to refresh token: %v", refreshErr)
751+ return sess, err // Return original error
752+ }
753+754+ sess = newSess
755+ continue
756+ }
757+758+ // Not a token error, return immediately
759+ return sess, err
760+ }
761+762+ return sess, fmt.Errorf("max retries exceeded")
763+}
764+765+// Helper functions for record building/parsing
766+767+func buildListRecord(list *models.TaskList) map[string]interface{} {
768+ return map[string]interface{}{
769+ "$type": ListCollection,
770+ "name": list.Name,
771+ "description": list.Description,
772+ "taskUris": list.TaskURIs,
773+ "createdAt": list.CreatedAt.Format(time.RFC3339),
774+ "updatedAt": list.UpdatedAt.Format(time.RFC3339),
775+ }
776+}
777+778+func parseListRecord(value map[string]interface{}) *models.TaskList {
779+ list := &models.TaskList{}
780+781+ if name, ok := value["name"].(string); ok {
782+ list.Name = name
783+ }
784+ if desc, ok := value["description"].(string); ok {
785+ list.Description = desc
786+ }
787+ if taskURIs, ok := value["taskUris"].([]interface{}); ok {
788+ list.TaskURIs = make([]string, 0, len(taskURIs))
789+ for _, uri := range taskURIs {
790+ if uriStr, ok := uri.(string); ok {
791+ list.TaskURIs = append(list.TaskURIs, uriStr)
792+ }
793+ }
794+ }
795+ if createdAt, ok := value["createdAt"].(string); ok {
796+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
797+ list.CreatedAt = t
798+ }
799+ }
800+ if updatedAt, ok := value["updatedAt"].(string); ok {
801+ if t, err := time.Parse(time.RFC3339, updatedAt); err == nil {
802+ list.UpdatedAt = t
803+ }
804+ }
805+806+ return list
807+}
+34-1
internal/handlers/tasks.go
···21const TaskCollection = "app.attodo.task"
2223type TaskHandler struct {
24- client *bskyoauth.Client
025}
2627func NewTaskHandler(client *bskyoauth.Client) *TaskHandler {
28 return &TaskHandler{client: client}
0000029}
3031// withRetry executes an operation with automatic token refresh on DPoP errors
···395 log.Printf("Failed to list tasks: %v", err)
396 // Return empty list on error rather than failing
397 tasks = []models.Task{}
000000000000000000000000000398 }
399400 // Filter tasks based on completion status
···21const TaskCollection = "app.attodo.task"
2223type TaskHandler struct {
24+ client *bskyoauth.Client
25+ listHandler *ListHandler
26}
2728func NewTaskHandler(client *bskyoauth.Client) *TaskHandler {
29 return &TaskHandler{client: client}
30+}
31+32+// SetListHandler allows setting the list handler for cross-referencing
33+func (h *TaskHandler) SetListHandler(listHandler *ListHandler) {
34+ h.listHandler = listHandler
35}
3637// withRetry executes an operation with automatic token refresh on DPoP errors
···401 log.Printf("Failed to list tasks: %v", err)
402 // Return empty list on error rather than failing
403 tasks = []models.Task{}
404+ }
405+406+ // Fetch all lists to populate task-to-list relationships
407+ if h.listHandler != nil {
408+ var lists []*models.TaskList
409+ sess, err = h.listHandler.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
410+ lists, err = h.listHandler.ListRecords(r.Context(), s)
411+ return err
412+ })
413+414+ if err == nil && lists != nil {
415+ // Create a map of task URI to lists
416+ taskListMap := make(map[string][]*models.TaskList)
417+ for _, list := range lists {
418+ for _, taskURI := range list.TaskURIs {
419+ taskListMap[taskURI] = append(taskListMap[taskURI], list)
420+ }
421+ }
422+423+ // Populate the Lists field for each task
424+ for i := range tasks {
425+ taskURI := tasks[i].URI
426+ if taskLists, exists := taskListMap[taskURI]; exists {
427+ tasks[i].Lists = taskLists
428+ }
429+ }
430+ }
431 }
432433 // Filter tasks based on completion status
+19
internal/models/task.go
···13 // Metadata from AT Protocol (populated after creation)
14 RKey string `json:"-"` // Record key (extracted from URI)
15 URI string `json:"-"` // Full AT URI
000000000000000000016}
···13 // Metadata from AT Protocol (populated after creation)
14 RKey string `json:"-"` // Record key (extracted from URI)
15 URI string `json:"-"` // Full AT URI
16+17+ // Transient field - populated when fetching task with list memberships
18+ Lists []*TaskList `json:"-"` // Lists this task belongs to (not stored in AT Protocol)
19+}
20+21+// TaskList represents a collection of tasks stored in AT Protocol
22+type TaskList struct {
23+ Name string `json:"name"` // Name of the list (e.g., "Work", "Personal", "Shopping")
24+ Description string `json:"description,omitempty"` // Optional description of the list
25+ TaskURIs []string `json:"taskUris"` // Array of AT URIs referencing tasks
26+ CreatedAt time.Time `json:"createdAt"`
27+ UpdatedAt time.Time `json:"updatedAt"`
28+29+ // Metadata from AT Protocol (populated after creation)
30+ RKey string `json:"-"` // Record key (extracted from URI)
31+ URI string `json:"-"` // Full AT URI
32+33+ // Transient field - populated when fetching list with tasks
34+ Tasks []*Task `json:"-"` // Resolved task objects (not stored in AT Protocol)
35}
+403
templates/dashboard.html
···76 .tab-content.active {
77 display: block;
78 }
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000079 </style>
80 <script>
000000000000000000000000000000000081 function switchTab(tabName) {
82 // Remove active class from all tabs and tab contents
83 document.querySelectorAll('.tabs button').forEach(btn => {
···108 taskItem.querySelector('.task-edit').style.display = 'none';
109 taskItem.querySelector('.task-actions').style.display = 'flex';
110 }
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111 </script>
112</head>
113<body>
000114 <script>
115 // Register service worker
116 if ('serviceWorker' in navigator) {
···129 }
130 });
131 }
0000000000000000000000000000000000000132 </script>
133 <header class="container">
134 <nav>
···176 <div class="tabs">
177 <button class="active" onclick="switchTab('incomplete')">Incomplete</button>
178 <button onclick="switchTab('completed')">Completed</button>
0179 </div>
180181 <!-- Incomplete Tasks Tab -->
···191 <!-- Completed tasks will be loaded here -->
192 </div>
193 </div>
0000000000000000000000000000000194 </section>
195 </main>
196···202 {{getVersion}}-{{getCommitID}}
203 </p>
204 </footer>
000000000000205</body>
206</html>
207{{end}}