Skip to content

Commit 67a9e09

Browse files
authored
refactor: split thread command into focused modules (#70)
1 parent 009d2de commit 67a9e09

File tree

5 files changed

+369
-343
lines changed

5 files changed

+369
-343
lines changed

src/commands/thread.ts

Lines changed: 3 additions & 343 deletions
Original file line numberDiff line numberDiff line change
@@ -1,348 +1,8 @@
1-
import type { TwistApi } from '@doist/twist-sdk'
2-
import chalk from 'chalk'
31
import { Command, Option } from 'commander'
4-
import { getTwistClient } from '../lib/api.js'
52
import { withUnvalidatedChoices } from '../lib/completion.js'
6-
import { formatRelativeDate } from '../lib/dates.js'
7-
import { openEditor, readStdin } from '../lib/input.js'
8-
import { renderMarkdown } from '../lib/markdown.js'
9-
import type { MutationOptions, PaginatedViewOptions } from '../lib/options.js'
10-
import { colors, formatJson, isAccessible } from '../lib/output.js'
11-
import { assertChannelIsPublic } from '../lib/public-channels.js'
12-
import { extractId, parseRef, resolveThreadId } from '../lib/refs.js'
13-
14-
type ViewOptions = PaginatedViewOptions & { comment?: string; unread?: boolean; context?: string }
15-
16-
type ReplyOptions = MutationOptions & { notify?: string }
17-
18-
type DoneOptions = MutationOptions
19-
20-
function printSeparator(label: string): void {
21-
const totalWidth = 60
22-
const labelWithPadding = ` ${label} `
23-
const remainingWidth = totalWidth - labelWithPadding.length
24-
const leftWidth = Math.floor(remainingWidth / 2)
25-
const rightWidth = remainingWidth - leftWidth
26-
const dashChar = isAccessible() ? '-' : '─'
27-
const line = chalk.dim(
28-
dashChar.repeat(leftWidth) + labelWithPadding + dashChar.repeat(rightWidth),
29-
)
30-
console.log('')
31-
console.log(line)
32-
console.log('')
33-
}
34-
35-
function pluralize(count: number, singular: string): string {
36-
return count === 1 ? singular : `${singular}s`
37-
}
38-
39-
interface CommentLike {
40-
id: number
41-
creator: number
42-
posted: Date
43-
content: string
44-
}
45-
46-
function printComment(comment: CommentLike, userMap: Map<number, string>, raw: boolean): void {
47-
const author = colors.author(userMap.get(comment.creator) || `user:${comment.creator}`)
48-
const time = colors.timestamp(formatRelativeDate(comment.posted))
49-
console.log(`${author} ${time} ${colors.timestamp(`id:${comment.id}`)}`)
50-
console.log(raw ? comment.content : renderMarkdown(comment.content))
51-
console.log('')
52-
}
53-
54-
async function viewSingleComment(
55-
client: TwistApi,
56-
threadId: number,
57-
commentId: number,
58-
options: ViewOptions,
59-
): Promise<void> {
60-
const [threadResponse, commentResponse] = await client.batch(
61-
client.threads.getThread(threadId, { batch: true }),
62-
client.comments.getComment(commentId, { batch: true }),
63-
)
64-
65-
const thread = threadResponse.data
66-
const comment = commentResponse.data
67-
68-
const userIds = new Set([thread.creator, comment.creator])
69-
const userCalls = [...userIds].map((id) =>
70-
client.workspaceUsers.getUserById(
71-
{ workspaceId: thread.workspaceId, userId: id },
72-
{ batch: true },
73-
),
74-
)
75-
const [channelResponse, ...userResponses] = await client.batch(
76-
client.channels.getChannel(thread.channelId, { batch: true }),
77-
...userCalls,
78-
)
79-
80-
const channel = channelResponse.data
81-
const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name]))
82-
83-
if (options.json) {
84-
const output = {
85-
...comment,
86-
creatorName: userMap.get(comment.creator),
87-
channelName: channel.name,
88-
threadTitle: thread.title,
89-
}
90-
console.log(formatJson(output, undefined, options.full))
91-
return
92-
}
93-
94-
if (options.ndjson) {
95-
console.log(
96-
JSON.stringify({
97-
type: 'comment',
98-
...comment,
99-
creatorName: userMap.get(comment.creator),
100-
}),
101-
)
102-
return
103-
}
104-
105-
console.log(chalk.bold(thread.title))
106-
console.log(colors.channel(`[${channel.name}]`))
107-
console.log('')
108-
printComment(comment, userMap, options.raw ?? false)
109-
}
110-
111-
async function viewThread(ref: string, options: ViewOptions): Promise<void> {
112-
const parsed = parseRef(ref)
113-
const threadId = resolveThreadId(ref)
114-
const urlCommentId = parsed.type === 'url' ? parsed.parsed.commentId : undefined
115-
let commentId: number | undefined
116-
if (options.comment !== undefined) {
117-
commentId = extractId(options.comment)
118-
} else {
119-
commentId = urlCommentId
120-
}
121-
const client = await getTwistClient()
122-
123-
if (commentId !== undefined) {
124-
return viewSingleComment(client, threadId, commentId, options)
125-
}
126-
127-
const limit = options.limit ? parseInt(options.limit, 10) : 50
128-
129-
const [threadResponse, commentsResponse] = await client.batch(
130-
client.threads.getThread(threadId, { batch: true }),
131-
client.comments.getComments(
132-
{
133-
threadId,
134-
from: options.since ? new Date(options.since) : undefined,
135-
limit,
136-
},
137-
{ batch: true },
138-
),
139-
)
140-
141-
const thread = threadResponse.data
142-
const comments = commentsResponse.data
143-
144-
await assertChannelIsPublic(thread.channelId, thread.workspaceId)
145-
146-
let lastReadObjIndex: number | null = null
147-
if (options.unread) {
148-
const unreadData = await client.threads.getUnread(thread.workspaceId)
149-
const threadUnread = unreadData.find((u) => u.threadId === threadId)
150-
if (!threadUnread) {
151-
console.log('No unread comments in this thread.')
152-
return
153-
}
154-
lastReadObjIndex = threadUnread.objIndex
155-
}
156-
157-
const userIds = new Set<number>([thread.creator, ...comments.map((c) => c.creator)])
158-
const userCalls = [...userIds].map((id) =>
159-
client.workspaceUsers.getUserById(
160-
{ workspaceId: thread.workspaceId, userId: id },
161-
{ batch: true },
162-
),
163-
)
164-
const [channelResponse, ...userResponses] = await client.batch(
165-
client.channels.getChannel(thread.channelId, { batch: true }),
166-
...userCalls,
167-
)
168-
169-
const channel = channelResponse.data
170-
const userMap = new Map(userResponses.map((r) => [r.data.id, r.data.name]))
171-
172-
if (options.json) {
173-
const output = {
174-
thread: {
175-
...thread,
176-
channelName: channel.name,
177-
creatorName: userMap.get(thread.creator),
178-
},
179-
comments: comments.map((c) => ({
180-
...c,
181-
creatorName: userMap.get(c.creator),
182-
})),
183-
}
184-
console.log(formatJson(output, undefined, options.full))
185-
return
186-
}
187-
188-
if (options.ndjson) {
189-
const threadOutput = {
190-
type: 'thread',
191-
...thread,
192-
channelName: channel.name,
193-
creatorName: userMap.get(thread.creator),
194-
}
195-
console.log(JSON.stringify(threadOutput))
196-
for (const c of comments) {
197-
console.log(
198-
JSON.stringify({ type: 'comment', ...c, creatorName: userMap.get(c.creator) }),
199-
)
200-
}
201-
return
202-
}
203-
204-
console.log(chalk.bold(thread.title))
205-
console.log(colors.channel(`[${channel.name}]`))
206-
console.log('')
207-
208-
if (options.unread && lastReadObjIndex !== null) {
209-
const contextSize = options.context ? parseInt(options.context, 10) : 0
210-
const unreadComments = comments.filter((c) => (c.objIndex ?? 0) > lastReadObjIndex)
211-
const contextComments = comments
212-
.filter((c) => (c.objIndex ?? 0) <= lastReadObjIndex)
213-
.sort((a, b) => (b.objIndex ?? 0) - (a.objIndex ?? 0))
214-
.slice(0, contextSize)
215-
.reverse()
216-
217-
if (unreadComments.length === 0) {
218-
console.log('No unread comments.')
219-
return
220-
}
221-
222-
const creatorName = userMap.get(thread.creator) || `user:${thread.creator}`
223-
console.log(
224-
`${colors.author(creatorName)} ${colors.timestamp(formatRelativeDate(thread.posted))} ${chalk.dim('(original post)')}`,
225-
)
226-
console.log('')
227-
console.log(options.raw ? thread.content : renderMarkdown(thread.content))
228-
229-
if (contextComments.length > 0) {
230-
const firstContextIndex = contextComments[0].objIndex ?? 0
231-
const skippedCount = firstContextIndex - 1
232-
if (skippedCount > 0) {
233-
printSeparator(`${skippedCount} ${pluralize(skippedCount, 'comment')} skipped`)
234-
} else {
235-
console.log('')
236-
}
237-
for (const comment of contextComments) {
238-
printComment(comment, userMap, options.raw ?? false)
239-
}
240-
} else if (lastReadObjIndex > 0) {
241-
printSeparator(`${lastReadObjIndex} ${pluralize(lastReadObjIndex, 'comment')} skipped`)
242-
}
243-
244-
printSeparator(`UNREAD (${unreadComments.length} new)`)
245-
246-
for (const comment of unreadComments) {
247-
printComment(comment, userMap, options.raw ?? false)
248-
}
249-
} else {
250-
const creatorName = userMap.get(thread.creator) || `user:${thread.creator}`
251-
console.log(
252-
`${colors.author(creatorName)} ${colors.timestamp(formatRelativeDate(thread.posted))}`,
253-
)
254-
console.log('')
255-
console.log(options.raw ? thread.content : renderMarkdown(thread.content))
256-
console.log('')
257-
258-
if (comments.length > 0) {
259-
console.log(
260-
chalk.dim(`--- ${comments.length} ${pluralize(comments.length, 'comment')} ---`),
261-
)
262-
console.log('')
263-
264-
for (const comment of comments) {
265-
printComment(comment, userMap, options.raw ?? false)
266-
}
267-
}
268-
}
269-
}
270-
271-
async function replyToThread(
272-
ref: string,
273-
content: string | undefined,
274-
options: ReplyOptions,
275-
): Promise<void> {
276-
const threadId = resolveThreadId(ref)
277-
278-
let replyContent = await readStdin()
279-
if (!replyContent && content) {
280-
replyContent = content
281-
}
282-
if (!replyContent) {
283-
replyContent = await openEditor()
284-
}
285-
if (!replyContent || replyContent.trim() === '') {
286-
console.error('No content provided.')
287-
process.exit(1)
288-
}
289-
290-
const notifyValue = options.notify ?? 'EVERYONE_IN_THREAD'
291-
let recipients: string | number[]
292-
if (notifyValue === 'EVERYONE' || notifyValue === 'EVERYONE_IN_THREAD') {
293-
recipients = notifyValue
294-
} else {
295-
recipients = notifyValue.split(',').map((userRef) => {
296-
const trimmed = userRef.trim()
297-
if (!trimmed) {
298-
console.error('Invalid user reference list: found empty value')
299-
process.exit(1)
300-
return 0
301-
}
302-
try {
303-
return extractId(trimmed)
304-
} catch {
305-
console.error(`Invalid user reference: ${trimmed}. Use 123 or id:123`)
306-
process.exit(1)
307-
return 0
308-
}
309-
})
310-
}
311-
312-
if (options.dryRun) {
313-
console.log('Dry run: would post comment to thread', threadId)
314-
console.log(`Notify: ${Array.isArray(recipients) ? recipients.join(', ') : recipients}`)
315-
console.log('')
316-
console.log(replyContent)
317-
return
318-
}
319-
320-
const client = await getTwistClient()
321-
const thread = await client.threads.getThread(threadId)
322-
await assertChannelIsPublic(thread.channelId, thread.workspaceId)
323-
const comment = await client.comments.createComment({
324-
threadId,
325-
content: replyContent,
326-
recipients,
327-
} as Parameters<typeof client.comments.createComment>[0])
328-
329-
console.log(`Comment posted: ${comment.url}`)
330-
}
331-
332-
async function markThreadDone(ref: string, options: DoneOptions): Promise<void> {
333-
const threadId = resolveThreadId(ref)
334-
335-
if (options.dryRun) {
336-
console.log(`Dry run: would archive thread ${threadId}`)
337-
return
338-
}
339-
340-
const client = await getTwistClient()
341-
const thread = await client.threads.getThread(threadId)
342-
await assertChannelIsPublic(thread.channelId, thread.workspaceId)
343-
await client.inbox.archiveThread(threadId)
344-
console.log(`Thread ${threadId} archived.`)
345-
}
3+
import { markThreadDone } from './thread/mutate.js'
4+
import { replyToThread } from './thread/reply.js'
5+
import { viewThread } from './thread/view.js'
3466

3477
export function registerThreadCommand(program: Command): void {
3488
const thread = program.command('thread').description('Thread operations')

src/commands/thread/helpers.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import chalk from 'chalk'
2+
import { formatRelativeDate } from '../../lib/dates.js'
3+
import { renderMarkdown } from '../../lib/markdown.js'
4+
import { colors, isAccessible } from '../../lib/output.js'
5+
6+
export function printSeparator(label: string): void {
7+
const totalWidth = 60
8+
const labelWithPadding = ` ${label} `
9+
const remainingWidth = totalWidth - labelWithPadding.length
10+
const leftWidth = Math.floor(remainingWidth / 2)
11+
const rightWidth = remainingWidth - leftWidth
12+
const dashChar = isAccessible() ? '-' : '─'
13+
const line = chalk.dim(
14+
dashChar.repeat(leftWidth) + labelWithPadding + dashChar.repeat(rightWidth),
15+
)
16+
console.log('')
17+
console.log(line)
18+
console.log('')
19+
}
20+
21+
export function pluralize(count: number, singular: string): string {
22+
return count === 1 ? singular : `${singular}s`
23+
}
24+
25+
export interface CommentLike {
26+
id: number
27+
creator: number
28+
posted: Date
29+
content: string
30+
}
31+
32+
export function printComment(
33+
comment: CommentLike,
34+
userMap: Map<number, string>,
35+
raw: boolean,
36+
): void {
37+
const author = colors.author(userMap.get(comment.creator) || `user:${comment.creator}`)
38+
const time = colors.timestamp(formatRelativeDate(comment.posted))
39+
console.log(`${author} ${time} ${colors.timestamp(`id:${comment.id}`)}`)
40+
console.log(raw ? comment.content : renderMarkdown(comment.content))
41+
console.log('')
42+
}

0 commit comments

Comments
 (0)