Skip to content

Commit 8a0090e

Browse files
authored
fix(refs): tighten inbox/saved thread URL routing (#32)
1 parent 54fbf7c commit 8a0090e

3 files changed

Lines changed: 89 additions & 9 deletions

File tree

src/commands/view.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ describe('tdc view <url> routing', () => {
9595
).rejects.toThrow('Not a recognized Comms URL')
9696
})
9797

98+
it('throws for malformed inbox thread URL with message-like suffix', async () => {
99+
const program = createProgram()
100+
await expect(
101+
program.parseAsync([
102+
'node',
103+
'tdc',
104+
'view',
105+
'https://comms.todoist.com/20/inbox/t/TH1/msg/CV1',
106+
]),
107+
).rejects.toThrow('Not a recognized Comms URL')
108+
})
109+
98110
it('throws for non-Comms URL', async () => {
99111
const program = createProgram()
100112
await expect(

src/lib/refs.test.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ describe('parseCommsUrl', () => {
145145
'https://comms.todoist.com/a/12345/ch/CH1/t/TH1/c/CM1',
146146
{ workspaceId: 12345, channelId: 'CH1', threadId: 'TH1', commentId: 'CM1' },
147147
],
148+
[
149+
'people URL as workspace-only',
150+
'https://comms.todoist.com/12345/people/u/678',
151+
{ workspaceId: 12345 },
152+
],
153+
])('parses %s', (_description, url, expected) => {
154+
expect(parseCommsUrl(url)).toEqual(expected)
155+
})
156+
157+
it.each([
148158
[
149159
'inbox thread URL',
150160
'https://comms.todoist.com/12345/inbox/t/TH1/',
@@ -161,14 +171,33 @@ describe('parseCommsUrl', () => {
161171
{ workspaceId: 12345, threadId: 'TH1' },
162172
],
163173
[
164-
'people URL as workspace-only',
165-
'https://comms.todoist.com/12345/people/u/678',
166-
{ workspaceId: 12345 },
174+
'saved thread with comment URL',
175+
'https://comms.todoist.com/12345/saved/t/TH1/c/CM1',
176+
{ workspaceId: 12345, threadId: 'TH1', commentId: 'CM1' },
167177
],
168178
])('parses %s', (_description, url, expected) => {
169179
expect(parseCommsUrl(url)).toEqual(expected)
170180
})
171181

182+
it.each([
183+
['inbox root URL', 'https://comms.todoist.com/12345/inbox'],
184+
['inbox done URL', 'https://comms.todoist.com/12345/inbox/done'],
185+
['inbox done thread-like URL', 'https://comms.todoist.com/12345/inbox/done/t/TH1'],
186+
['missing thread id', 'https://comms.todoist.com/12345/inbox/t'],
187+
['missing comment id', 'https://comms.todoist.com/12345/inbox/t/TH1/c'],
188+
['comment-only path', 'https://comms.todoist.com/12345/inbox/c/CM1'],
189+
['wrong marker after thread id', 'https://comms.todoist.com/12345/inbox/t/TH1/x/CM1'],
190+
['extra segment after thread id', 'https://comms.todoist.com/12345/inbox/t/TH1/extra'],
191+
[
192+
'extra segment after comment id',
193+
'https://comms.todoist.com/12345/inbox/t/TH1/c/CM1/extra',
194+
],
195+
['msg suffix after thread id', 'https://comms.todoist.com/12345/inbox/t/TH1/msg/CV1'],
196+
['saved URL with extra segment', 'https://comms.todoist.com/12345/saved/t/TH1/extra'],
197+
])('leaves %s workspace-only', (_description, url) => {
198+
expect(parseCommsUrl(url)).toEqual({ workspaceId: 12345 })
199+
})
200+
172201
it('parses conversation URL', () => {
173202
const result = parseCommsUrl('https://comms.todoist.com/a/12345/msg/CV1')
174203
expect(result).toEqual({ workspaceId: 12345, conversationId: 'CV1' })
@@ -293,6 +322,11 @@ describe('resolveThreadId', () => {
293322
'inbox thread URL with comment suffix',
294323
'https://comms.todoist.com/12345/inbox/t/TH1/c/CM1',
295324
],
325+
['saved thread URL', 'https://comms.todoist.com/12345/saved/t/TH1'],
326+
[
327+
'saved thread URL with comment suffix',
328+
'https://comms.todoist.com/12345/saved/t/TH1/c/CM1',
329+
],
296330
])('resolves %s', (_description, url) => {
297331
expect(resolveThreadId(url)).toBe('TH1')
298332
})
@@ -550,6 +584,8 @@ describe('classifyCommsUrl', () => {
550584
['thread+comment URL', 'https://comms.todoist.com/a/20/ch/CH1/t/TH1/c/CM1', 'comment'],
551585
['inbox thread URL', 'https://comms.todoist.com/20/inbox/t/TH1/', 'thread'],
552586
['inbox thread+comment URL', 'https://comms.todoist.com/20/inbox/t/TH1/c/CM1', 'comment'],
587+
['saved thread URL', 'https://comms.todoist.com/20/saved/t/TH1', 'thread'],
588+
['saved thread+comment URL', 'https://comms.todoist.com/20/saved/t/TH1/c/CM1', 'comment'],
553589
['conversation URL', 'https://comms.todoist.com/a/20/msg/CV1', 'conversation'],
554590
['short conversation URL', 'https://comms.todoist.com/20/msg/CV1', 'conversation'],
555591
['message URL', 'https://comms.todoist.com/a/20/msg/CV1/m/MS1', 'message'],
@@ -561,6 +597,9 @@ describe('classifyCommsUrl', () => {
561597
['inbox root URL', 'https://comms.todoist.com/20/inbox'],
562598
['inbox done URL', 'https://comms.todoist.com/20/inbox/done'],
563599
['inbox done thread-like URL', 'https://comms.todoist.com/20/inbox/done/t/TH1'],
600+
['inbox thread with extra segment', 'https://comms.todoist.com/20/inbox/t/TH1/extra'],
601+
['inbox thread with msg suffix', 'https://comms.todoist.com/20/inbox/t/TH1/msg/CV1'],
602+
['saved thread with extra segment', 'https://comms.todoist.com/20/saved/t/TH1/extra'],
564603
['workspace-only URL', 'https://comms.todoist.com/a/20'],
565604
['channel-only URL', 'https://comms.todoist.com/a/20/ch/CH1'],
566605
['malformed account URL', 'https://comms.todoist.com/a/ch/CH1/t/TH1'],

src/lib/refs.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,26 @@ export interface ParsedCommsUrl {
8989
messageId?: string
9090
}
9191

92+
function parseInboxOrSavedThreadRoute(
93+
routeSegments: readonly string[],
94+
): Pick<ParsedCommsUrl, 'threadId' | 'commentId'> | null {
95+
const [threadMarker, threadId, commentMarker, commentId, ...extraSegments] = routeSegments
96+
97+
if (threadMarker !== 't' || !threadId || extraSegments.length > 0) {
98+
return null
99+
}
100+
101+
if (routeSegments.length === 2) {
102+
return { threadId }
103+
}
104+
105+
if (routeSegments.length === 4 && commentMarker === 'c' && commentId) {
106+
return { threadId, commentId }
107+
}
108+
109+
return null
110+
}
111+
92112
export function parseCommsUrl(url: string): ParsedCommsUrl | null {
93113
try {
94114
const parsed = new URL(url)
@@ -135,12 +155,21 @@ export function parseCommsUrl(url: string): ParsedCommsUrl | null {
135155
}
136156
}
137157

138-
if (
139-
(segments[routeStart] === 'inbox' || segments[routeStart] === 'saved') &&
140-
segments[routeStart + 1] === 't'
141-
) {
142-
parseRoutePairs(routeStart + 1)
143-
} else if (segments[routeStart] !== 'inbox' && segments[routeStart] !== 'saved') {
158+
const route = segments[routeStart]
159+
if (route === 'inbox' || route === 'saved') {
160+
// Inbox/saved routes only accept:
161+
// t/{thread}
162+
// t/{thread}/c/{comment}
163+
// Other inbox/saved paths stay workspace-only so malformed URLs don't get
164+
// misrouted as thread, comment, or conversation refs.
165+
const threadRoute = parseInboxOrSavedThreadRoute(segments.slice(routeStart + 1))
166+
if (threadRoute) {
167+
result.threadId = threadRoute.threadId
168+
if (threadRoute.commentId) {
169+
result.commentId = threadRoute.commentId
170+
}
171+
}
172+
} else {
144173
parseRoutePairs(routeStart)
145174
}
146175

0 commit comments

Comments
 (0)