11import { Source , type ToolBuilder } from "@plotday/twister" ;
2- import type { Actor , Note , Thread , ThreadMeta } from "@plotday/twister/plot" ;
2+ import type { Actor , ActorId , Note , Thread } from "@plotday/twister/plot" ;
33import {
44 AuthProvider ,
55 type AuthToken ,
@@ -70,6 +70,10 @@ export class Gmail extends Source<Gmail> {
7070 } ;
7171 }
7272
73+ override async activate ( context : { auth : Authorization ; actor : Actor } ) : Promise < void > {
74+ await this . set ( "auth_actor_id" , context . actor . id ) ;
75+ }
76+
7377 async getChannels (
7478 _auth : Authorization ,
7579 token : AuthToken
@@ -326,14 +330,40 @@ export class Gmail extends Source<Gmail> {
326330
327331 // Save link directly via integrations
328332 await this . tools . integrations . saveLink ( plotThread ) ;
333+
334+ // Star ↔ todo sync: detect star changes and update Plot todo status
335+ const isStarred = GmailApi . isStarred ( thread ) ;
336+ const wasStarred = await this . get < boolean > ( `starred:${ thread . id } ` ) ;
337+
338+ if ( isStarred !== ! ! wasStarred ) {
339+ // Skip if this change originated from Plot todo writeback
340+ if ( await this . get ( `skip_star_sync:${ thread . id } ` ) ) {
341+ await this . clear ( `skip_star_sync:${ thread . id } ` ) ;
342+ } else {
343+ const actorId = await this . get < ActorId > ( "auth_actor_id" ) ;
344+ // Use the canonical Gmail thread URL as the source identifier
345+ const sourceUrl = `https://mail.google.com/mail/u/0/#inbox/${ thread . id } ` ;
346+ if ( actorId ) {
347+ await this . tools . integrations . setThreadToDo (
348+ sourceUrl ,
349+ actorId ,
350+ isStarred
351+ ) ;
352+ // Prevent the onThreadToDo callback from echoing back
353+ await this . set ( `skip_todo_writeback:${ thread . id } ` , true ) ;
354+ }
355+ }
356+ await this . set ( `starred:${ thread . id } ` , isStarred ) ;
357+ }
329358 } catch ( error ) {
330359 console . error ( `Failed to process Gmail thread ${ thread . id } :` , error ) ;
331360 // Continue processing other threads
332361 }
333362 }
334363 }
335364
336- async onNoteCreated ( note : Note , meta : ThreadMeta ) : Promise < void > {
365+ async onNoteCreated ( note : Note , thread : Thread ) : Promise < void > {
366+ const meta = thread . meta ?? { } ;
337367 const channelId = ( meta . channelId ?? meta . syncableId ) as string ;
338368 if ( ! channelId ) {
339369 console . error ( "No channelId in meta for Gmail reply" ) ;
@@ -430,11 +460,11 @@ export class Gmail extends Source<Gmail> {
430460 }
431461
432462 async onThreadRead (
433- _thread : Thread ,
463+ thread : Thread ,
434464 _actor : Actor ,
435- unread : boolean ,
436- meta : ThreadMeta
465+ unread : boolean
437466 ) : Promise < void > {
467+ const meta = thread . meta ?? { } ;
438468 const channelId = ( meta . channelId ?? meta . syncableId ) as string ;
439469 if ( ! channelId ) return ;
440470
@@ -450,6 +480,34 @@ export class Gmail extends Source<Gmail> {
450480 }
451481 }
452482
483+ async onThreadToDo (
484+ thread : Thread ,
485+ _actor : Actor ,
486+ todo : boolean ,
487+ _options : { date ?: Date }
488+ ) : Promise < void > {
489+ const meta = thread . meta ?? { } ;
490+ const threadId = meta . threadId as string ;
491+ const channelId = ( meta . channelId ?? meta . syncableId ) as string ;
492+ if ( ! threadId || ! channelId ) return ;
493+
494+ // Loop prevention: skip if this change originated from Gmail star sync
495+ if ( await this . get ( `skip_todo_writeback:${ threadId } ` ) ) {
496+ await this . clear ( `skip_todo_writeback:${ threadId } ` ) ;
497+ return ;
498+ }
499+
500+ const api = await this . getApi ( channelId ) ;
501+ if ( todo ) {
502+ await api . modifyThread ( threadId , [ "STARRED" ] ) ;
503+ } else {
504+ await api . modifyThread ( threadId , undefined , [ "STARRED" ] ) ;
505+ }
506+
507+ // Prevent the Gmail webhook from echoing this change back
508+ await this . set ( `skip_star_sync:${ threadId } ` , true ) ;
509+ }
510+
453511 async onGmailWebhook (
454512 request : WebhookRequest ,
455513 channelId : string
0 commit comments