Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ tdc thread reply <ref> "content" --file ./a.png # Attach a file (repeatable; co
tdc thread done <ref> # Preview thread archive (requires --yes to execute)
tdc thread done <ref> --yes # Archive thread (mark done)
tdc thread done <ref> --yes --json # Archive and return status as JSON
tdc thread mark-read <ref> # Mark a thread read
tdc thread mark-read <ref> <ref> --yes # Mark multiple threads read
tdc thread mark-read --from-file ids.txt --dry-run # Preview bulk mark-read from file
Comment thread
amix marked this conversation as resolved.
Outdated
tdc thread mute <ref> # Mute thread for 60 minutes (default)
tdc thread mute <ref> --minutes 480 # Mute for custom duration
tdc thread mute <ref> --json # Mute and return { id, mutedUntil } as JSON
Expand Down
24 changes: 24 additions & 0 deletions src/commands/thread/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createThread } from './create.js'
import { deleteThread } from './delete.js'
import { markThreadDone } from './mutate.js'
import { muteThread, unmuteThread } from './mute.js'
import { markThreadRead } from './read.js'
import { renameThread } from './rename.js'
import { replyToThread } from './reply.js'
import { updateThread } from './update.js'
Expand Down Expand Up @@ -113,6 +114,29 @@ Examples:
)
.action(markThreadDone)

const markReadCmd = thread
.command('mark-read [thread-refs...]')
.description('Mark a thread read for the current user')
.option('--from-file <path>', 'Read thread refs from a file (one per line)')
.option('--yes', 'Skip confirmation for bulk operations')
.option('--dry-run', 'Show what would happen without executing')
.option('--json', 'Output result as JSON')
.addHelpText(
'after',
`
Examples:
tdc thread mark-read 12345
tdc thread mark-read 12345 67890 --yes
tdc thread mark-read --from-file ids.txt --json`,
)
.action((refs, options) => {
if (refs.length === 0 && !options.fromFile) {
markReadCmd.help()
return
}
return markThreadRead(refs, options)
})

thread
.command('delete <thread-ref>')
.description('Permanently delete a thread')
Expand Down
172 changes: 172 additions & 0 deletions src/commands/thread/read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { readFile } from 'node:fs/promises'
import type { CommsApi, Thread } from '@doist/comms-sdk'
import { getCommsClient } from '../../lib/api.js'
import { CliError } from '../../lib/errors.js'
import type { MutationOptions } from '../../lib/options.js'
import { formatJson, pluralize } from '../../lib/output.js'
import { assertChannelIsPublic } from '../../lib/public-channels.js'
import { resolveThreadId } from '../../lib/refs.js'

export type MarkThreadReadOptions = MutationOptions & {
fromFile?: string
}

type LoadedThread = {
thread: Thread
isUnread: boolean
}

type MarkReadStatus = {
id: string
isRead: true
}

type TextStatus = 'changed' | 'preview' | 'unchanged'

export async function markThreadRead(
refs: string[],
options: MarkThreadReadOptions,
): Promise<void> {
const rawRefs = await collectThreadRefs(refs, options.fromFile)
if (rawRefs.length === 0) {
throw new CliError(
'INVALID_REF',
'No thread references provided. Pass refs as arguments or via --from-file.',
)
}

const needsConfirmation = rawRefs.length > 1 && !options.yes && !options.dryRun
if (options.json && needsConfirmation) {
throw new CliError(
'MISSING_YES_FLAG',
'--yes is required to execute bulk mark-read in --json mode.',
)
}

const client = await getCommsClient()
const unreadCache = new Map<number, Set<string>>()
const jsonStatuses: MarkReadStatus[] = []
const textStatuses: TextStatus[] = []

for (const rawRef of rawRefs) {
const threadId = resolveThreadId(rawRef)
const loaded = await loadThread(client, unreadCache, threadId)

if (!loaded.isUnread) {
jsonStatuses.push({ id: threadId, isRead: true })
textStatuses.push('unchanged')
if (!options.json) {
console.log(`Thread ${threadLabel(loaded.thread)} is already read.`)
}
continue
}

if (needsConfirmation || options.dryRun) {
jsonStatuses.push({ id: threadId, isRead: true })
textStatuses.push('preview')
if (!options.json) {
const prefix = options.dryRun ? 'Dry run: would' : 'Would'
console.log(`${prefix} mark read thread ${threadLabel(loaded.thread)}.`)
}
continue
}

await client.threads.markRead({
id: threadId,
objIndex: getLatestObjIndex(loaded.thread),
})
unreadCache.get(loaded.thread.workspaceId)?.delete(threadId)

jsonStatuses.push({ id: threadId, isRead: true })
textStatuses.push('changed')
if (!options.json) {
console.log(`Thread ${threadLabel(loaded.thread)} marked read.`)
}
}

if (options.json && !options.dryRun) {
console.log(formatJson(jsonStatuses))
return
}

if (!options.json && rawRefs.length > 1) {
printSummary(textStatuses)
}

if (!options.json && needsConfirmation) {
console.log('Use --yes to confirm.')
}
}

async function collectThreadRefs(refs: string[], fromFile: string | undefined): Promise<string[]> {
const inlineRefs = refs.map((ref) => ref.trim()).filter(Boolean)
if (!fromFile) {
return inlineRefs
}

let content: string
try {
content = await readFile(fromFile, 'utf8')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new CliError('FILE_READ_ERROR', `Could not read refs file: ${fromFile}`, [message])
}

const fileRefs = content
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== '' && !line.startsWith('#'))

return [...inlineRefs, ...fileRefs]
}

async function loadThread(
client: CommsApi,
unreadCache: Map<number, Set<string>>,
threadId: string,
): Promise<LoadedThread> {
const thread = await client.threads.getThread(threadId)
await assertChannelIsPublic(thread.channelId, thread.workspaceId)

let unreadIds = unreadCache.get(thread.workspaceId)
if (!unreadIds) {
const unread = await client.threads.getUnread(thread.workspaceId)
unreadIds = new Set(unread.data.map((unreadThread) => unreadThread.threadId))
unreadCache.set(thread.workspaceId, unreadIds)
}

return { thread, isUnread: unreadIds.has(thread.id) }
}

function getLatestObjIndex(thread: Thread): number {
return Math.max(
...[thread.lastComment?.objIndex, thread.lastObjIndex, thread.commentCount, 0]
.filter((value): value is number => typeof value === 'number')
.map((value) => Math.max(value, 0)),
)
}

function threadLabel(thread: Thread): string {
return `${thread.title} (${thread.id})`
}

function printSummary(statuses: TextStatus[]): void {
const summary = [
summarizeStatus(statuses, 'changed'),
summarizeStatus(statuses, 'unchanged'),
summarizeStatus(statuses, 'preview'),
].filter(Boolean)

console.log('')
console.log(`Summary: ${summary.join(', ')}`)
}

function summarizeStatus(statuses: TextStatus[], status: TextStatus): string | null {
const count = statuses.filter((value) => value === status).length
if (count === 0) {
return null
}

const noun = status === 'preview' ? pluralize(count, 'preview') : pluralize(count, 'thread')
return status === 'preview' ? `${count} ${noun}` : `${count} ${status} ${noun}`
}
Loading
Loading