Skip to content

Commit 268d315

Browse files
fix(hc): resolve www.todoist.com marketing article URLs (#310)
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
1 parent f10fe78 commit 268d315

7 files changed

Lines changed: 341 additions & 10 deletions

File tree

skills/todoist-cli/SKILL.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,9 +247,10 @@ td reminder location get id:456
247247
td hc
248248
td hc --help
249249
td hc locale --set-default pt-br
250+
td hc view https://www.todoist.com/help/articles/introduction-to-filters-V98wIH
250251
```
251252

252-
`td hc` queries the Todoist online Help Center. Run `td hc --help` for locale discovery, article search, and article viewing details. `td hc locale --set-default <locale>` persists a preferred locale in `~/.config/todoist-cli/config.json` under `hc.defaultLocale`; the `--locale` flag on individual subcommands still overrides it.
253+
`td hc` queries the Todoist online Help Center. Run `td hc --help` for locale discovery, article search, and article viewing details. `td hc locale --set-default <locale>` persists a preferred locale in `~/.config/todoist-cli/config.json` under `hc.defaultLocale`; the `--locale` flag on individual subcommands still overrides it. `td hc view` accepts `id:N`, raw numeric article IDs, `get.todoist.help` URLs, and public `www.todoist.com/help/articles/...` marketing URLs (resolved to the underlying Zendesk article via slug search).
253254

254255
### Templates
255256
```bash

src/commands/hc/hc.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,180 @@ describe('hc command', () => {
259259
expect(fetchSpy).not.toHaveBeenCalled()
260260
})
261261

262+
it('opens the marketing URL directly in --browser mode without resolving', async () => {
263+
const program = createProgram()
264+
await program.parseAsync([
265+
'node',
266+
'td',
267+
'hc',
268+
'view',
269+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
270+
'--browser',
271+
])
272+
273+
expect(openInBrowser).toHaveBeenCalledWith(
274+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
275+
)
276+
expect(fetchSpy).not.toHaveBeenCalled()
277+
})
278+
279+
it('resolves a www.todoist.com marketing URL via search and fetches the article', async () => {
280+
fetchSpy
281+
.mockResolvedValueOnce(
282+
createJsonResponse({
283+
count: 2,
284+
results: [
285+
{
286+
id: 26646901023644,
287+
title: 'Todoist glossary',
288+
html_url:
289+
'https://get.todoist.help/hc/en-us/articles/26646901023644-Todoist-glossary',
290+
},
291+
{
292+
id: 205248842,
293+
title: 'Introduction to filters',
294+
html_url:
295+
'https://get.todoist.help/hc/en-us/articles/205248842-Introduction-to-filters',
296+
},
297+
],
298+
}),
299+
)
300+
.mockResolvedValueOnce(
301+
createJsonResponse({
302+
article: {
303+
id: 205248842,
304+
title: 'Introduction to filters',
305+
html_url:
306+
'https://get.todoist.help/hc/en-us/articles/205248842-Introduction-to-filters',
307+
body: '<p>Filters help you...</p>',
308+
},
309+
}),
310+
)
311+
312+
const program = createProgram()
313+
await program.parseAsync([
314+
'node',
315+
'td',
316+
'hc',
317+
'view',
318+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
319+
])
320+
321+
expect(fetchSpy).toHaveBeenCalledTimes(2)
322+
expect(String(fetchSpy.mock.calls[0][0])).toContain(
323+
'/api/v2/help_center/articles/search?query=introduction-to-filters-V98wIH&locale=en-us&per_page=10',
324+
)
325+
expect(fetchSpy.mock.calls[1][0]).toBe(
326+
'https://todoist.zendesk.com/api/v2/help_center/en-us/articles/205248842',
327+
)
328+
329+
const output = consoleSpy.mock.calls[0][0] as string
330+
expect(output).toContain('# Introduction to filters')
331+
expect(output).toContain('Filters help you')
332+
})
333+
334+
it('searches in en-us when an English marketing URL is pasted with a non-English default locale', async () => {
335+
mockReadConfig.mockResolvedValue({ hc: { defaultLocale: 'pt-br' } })
336+
fetchSpy
337+
.mockResolvedValueOnce(
338+
createJsonResponse({
339+
count: 1,
340+
results: [
341+
{
342+
id: 205248842,
343+
title: 'Introduction to filters',
344+
html_url:
345+
'https://get.todoist.help/hc/en-us/articles/205248842-Introduction-to-filters',
346+
},
347+
],
348+
}),
349+
)
350+
.mockResolvedValueOnce(
351+
createJsonResponse({
352+
article: {
353+
id: 205248842,
354+
title: 'Introdução aos filtros',
355+
html_url:
356+
'https://get.todoist.help/hc/pt-br/articles/205248842-Introducao-aos-filtros',
357+
body: '<p>Filtros</p>',
358+
},
359+
}),
360+
)
361+
362+
const program = createProgram()
363+
await program.parseAsync([
364+
'node',
365+
'td',
366+
'hc',
367+
'view',
368+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
369+
])
370+
371+
expect(String(fetchSpy.mock.calls[0][0])).toContain('locale=en-us')
372+
expect(fetchSpy.mock.calls[1][0]).toBe(
373+
'https://todoist.zendesk.com/api/v2/help_center/pt-br/articles/205248842',
374+
)
375+
})
376+
377+
it('opens the resolved zendesk URL once when --browser is combined with --locale on a marketing URL', async () => {
378+
fetchSpy.mockResolvedValueOnce(
379+
createJsonResponse({
380+
count: 1,
381+
results: [
382+
{
383+
id: 205248842,
384+
title: 'Introduction to filters',
385+
html_url:
386+
'https://get.todoist.help/hc/en-us/articles/205248842-Introduction-to-filters',
387+
},
388+
],
389+
}),
390+
)
391+
392+
const program = createProgram()
393+
await program.parseAsync([
394+
'node',
395+
'td',
396+
'hc',
397+
'view',
398+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
399+
'--browser',
400+
'--locale',
401+
'fr',
402+
])
403+
404+
expect(fetchSpy).toHaveBeenCalledTimes(1)
405+
expect(openInBrowser).toHaveBeenCalledWith(
406+
'https://get.todoist.help/hc/en-us/articles/205248842-Introduction-to-filters',
407+
)
408+
})
409+
410+
it('errors when the marketing URL slug cannot be matched', async () => {
411+
fetchSpy.mockResolvedValueOnce(
412+
createJsonResponse({
413+
count: 1,
414+
results: [
415+
{
416+
id: 999,
417+
title: 'Something else',
418+
html_url: 'https://get.todoist.help/hc/en-us/articles/999-Something-else',
419+
},
420+
],
421+
}),
422+
)
423+
424+
const program = createProgram()
425+
await expect(
426+
program.parseAsync([
427+
'node',
428+
'td',
429+
'hc',
430+
'view',
431+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
432+
]),
433+
).rejects.toMatchObject({ code: 'NOT_FOUND' })
434+
})
435+
262436
it('outputs the raw HTML body with --html', async () => {
263437
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
264438
fetchSpy.mockResolvedValue(

src/commands/hc/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ Examples:
2222
td hc view id:360000269065
2323
td hc view 360000269065
2424
td hc view https://get.todoist.help/hc/en-us/articles/360000269065
25+
td hc view https://www.todoist.com/help/articles/introduction-to-filters-V98wIH
2526
2627
Notes:
2728
search prints real Help Center article IDs
28-
view accepts id:N, raw article IDs, and Help Center URLs`,
29+
view accepts id:N, raw article IDs, get.todoist.help URLs, and www.todoist.com/help/articles/... URLs`,
2930
)
3031

3132
hc.command('locales')

src/commands/hc/view.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatHelpCenterArticleMarkdown,
66
getHelpCenterArticle,
77
resolveHelpCenterRef,
8+
resolveMarketingArticleId,
89
} from '../../lib/help-center.js'
910
import { renderMarkdown } from '../../lib/markdown.js'
1011
import { withSpinner } from '../../lib/spinner.js'
@@ -40,9 +41,26 @@ export async function viewHelpCenterArticle(
4041
return
4142
}
4243

44+
let articleId: string
45+
if (resolved.kind === 'marketing') {
46+
const { marketingSlug, urlLocale } = resolved
47+
const resolvedMarketing = await withSpinner(
48+
{ text: 'Resolving Help Center article...', color: 'blue' },
49+
() => resolveMarketingArticleId(marketingSlug, urlLocale),
50+
)
51+
articleId = resolvedMarketing.articleId
52+
53+
if (options.browser) {
54+
await openInBrowser(resolvedMarketing.htmlUrl)
55+
return
56+
}
57+
} else {
58+
articleId = resolved.articleId
59+
}
60+
4361
const article = await withSpinner(
4462
{ text: 'Loading Help Center article...', color: 'blue' },
45-
() => getHelpCenterArticle(resolved.articleId, { locale: resolved.locale }),
63+
() => getHelpCenterArticle(articleId, { locale: resolved.locale }),
4664
)
4765

4866
if (options.browser) {

src/lib/help-center.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('help center helpers', () => {
3333

3434
it('resolves an explicit id: ref', () => {
3535
expect(resolveHelpCenterRef('id:360000269065')).toEqual({
36+
kind: 'article',
3637
articleId: '360000269065',
3738
locale: 'en-us',
3839
source: 'id',
@@ -41,6 +42,7 @@ describe('help center helpers', () => {
4142

4243
it('resolves a raw numeric article id', () => {
4344
expect(resolveHelpCenterRef('205348301')).toEqual({
45+
kind: 'article',
4446
articleId: '205348301',
4547
locale: 'en-us',
4648
source: 'id',
@@ -53,6 +55,7 @@ describe('help center helpers', () => {
5355
'https://get.todoist.help/hc/en-us/articles/360000269065-manage-your-notifications',
5456
),
5557
).toEqual({
58+
kind: 'article',
5659
articleId: '360000269065',
5760
htmlUrl:
5861
'https://get.todoist.help/hc/en-us/articles/360000269065-manage-your-notifications',
@@ -66,4 +69,45 @@ describe('help center helpers', () => {
6669
'Invalid Help Center reference "notifications".',
6770
)
6871
})
72+
73+
it('flags a www.todoist.com marketing URL for slug resolution', () => {
74+
expect(
75+
resolveHelpCenterRef(
76+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
77+
),
78+
).toEqual({
79+
kind: 'marketing',
80+
marketingSlug: 'introduction-to-filters-V98wIH',
81+
urlLocale: 'en-us',
82+
locale: 'en-us',
83+
htmlUrl: 'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
84+
source: 'url',
85+
})
86+
})
87+
88+
it('extracts the URL locale from a localized marketing URL', () => {
89+
expect(
90+
resolveHelpCenterRef(
91+
'https://www.todoist.com/de/help/articles/einfuehrung-in-filter-V98wIH',
92+
),
93+
).toMatchObject({
94+
kind: 'marketing',
95+
urlLocale: 'de',
96+
locale: 'en-us',
97+
})
98+
})
99+
100+
it('honours an explicit locale on a marketing URL while preserving the URL locale', () => {
101+
expect(
102+
resolveHelpCenterRef(
103+
'https://www.todoist.com/help/articles/introduction-to-filters-V98wIH',
104+
{ locale: 'pt-br' },
105+
),
106+
).toMatchObject({
107+
kind: 'marketing',
108+
marketingSlug: 'introduction-to-filters-V98wIH',
109+
urlLocale: 'en-us',
110+
locale: 'pt-br',
111+
})
112+
})
69113
})

0 commit comments

Comments
 (0)