@jackwener/opencli 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +20 -1
  2. package/README.zh-CN.md +20 -1
  3. package/dist/browser/daemon-client.d.ts +1 -1
  4. package/dist/browser/index.d.ts +1 -2
  5. package/dist/browser/index.js +1 -5
  6. package/dist/browser/mcp.d.ts +5 -8
  7. package/dist/browser/mcp.js +9 -10
  8. package/dist/browser/page.d.ts +8 -1
  9. package/dist/browser/page.js +23 -17
  10. package/dist/browser.test.js +6 -6
  11. package/dist/cli-manifest.json +394 -14
  12. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  13. package/dist/clis/apple-podcasts/episodes.js +28 -0
  14. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  15. package/dist/clis/apple-podcasts/search.js +29 -0
  16. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  17. package/dist/clis/apple-podcasts/top.js +34 -0
  18. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  19. package/dist/clis/apple-podcasts/utils.js +30 -0
  20. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  21. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  22. package/dist/clis/chatwise/history.js +18 -1
  23. package/dist/clis/discord-app/channels.js +33 -21
  24. package/dist/clis/twitter/accept.d.ts +1 -0
  25. package/dist/clis/twitter/accept.js +202 -0
  26. package/dist/clis/twitter/followers.js +30 -22
  27. package/dist/clis/twitter/following.js +19 -14
  28. package/dist/clis/twitter/notifications.js +29 -22
  29. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  30. package/dist/clis/twitter/reply-dm.js +181 -0
  31. package/dist/clis/twitter/search.js +50 -12
  32. package/dist/clis/weread/book.d.ts +1 -0
  33. package/dist/clis/weread/book.js +26 -0
  34. package/dist/clis/weread/highlights.d.ts +1 -0
  35. package/dist/clis/weread/highlights.js +23 -0
  36. package/dist/clis/weread/notebooks.d.ts +1 -0
  37. package/dist/clis/weread/notebooks.js +21 -0
  38. package/dist/clis/weread/notes.d.ts +1 -0
  39. package/dist/clis/weread/notes.js +29 -0
  40. package/dist/clis/weread/ranking.d.ts +1 -0
  41. package/dist/clis/weread/ranking.js +28 -0
  42. package/dist/clis/weread/search.d.ts +1 -0
  43. package/dist/clis/weread/search.js +25 -0
  44. package/dist/clis/weread/shelf.d.ts +1 -0
  45. package/dist/clis/weread/shelf.js +24 -0
  46. package/dist/clis/weread/utils.d.ts +20 -0
  47. package/dist/clis/weread/utils.js +72 -0
  48. package/dist/clis/weread/utils.test.d.ts +1 -0
  49. package/dist/clis/weread/utils.test.js +85 -0
  50. package/dist/daemon.js +2 -2
  51. package/dist/doctor.d.ts +0 -21
  52. package/dist/doctor.js +2 -24
  53. package/dist/main.js +6 -16
  54. package/dist/runtime.d.ts +1 -4
  55. package/dist/runtime.js +1 -4
  56. package/dist/setup.js +2 -2
  57. package/extension/dist/background.js +484 -0
  58. package/extension/manifest.json +1 -1
  59. package/extension/package.json +1 -1
  60. package/extension/src/background.ts +99 -22
  61. package/extension/src/protocol.ts +1 -1
  62. package/package.json +1 -1
  63. package/src/browser/daemon-client.ts +1 -1
  64. package/src/browser/index.ts +1 -6
  65. package/src/browser/mcp.ts +14 -15
  66. package/src/browser/page.ts +23 -17
  67. package/src/browser.test.ts +6 -6
  68. package/src/clis/apple-podcasts/episodes.ts +28 -0
  69. package/src/clis/apple-podcasts/search.ts +29 -0
  70. package/src/clis/apple-podcasts/top.ts +34 -0
  71. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  72. package/src/clis/apple-podcasts/utils.ts +37 -0
  73. package/src/clis/chatwise/history.ts +15 -1
  74. package/src/clis/discord-app/channels.ts +33 -21
  75. package/src/clis/twitter/accept.ts +213 -0
  76. package/src/clis/twitter/followers.ts +36 -29
  77. package/src/clis/twitter/following.ts +25 -20
  78. package/src/clis/twitter/notifications.ts +34 -27
  79. package/src/clis/twitter/reply-dm.ts +193 -0
  80. package/src/clis/twitter/search.ts +53 -13
  81. package/src/clis/weread/book.ts +28 -0
  82. package/src/clis/weread/highlights.ts +25 -0
  83. package/src/clis/weread/notebooks.ts +23 -0
  84. package/src/clis/weread/notes.ts +31 -0
  85. package/src/clis/weread/ranking.ts +29 -0
  86. package/src/clis/weread/search.ts +26 -0
  87. package/src/clis/weread/shelf.ts +26 -0
  88. package/src/clis/weread/utils.test.ts +104 -0
  89. package/src/clis/weread/utils.ts +74 -0
  90. package/src/daemon.ts +2 -2
  91. package/src/doctor.ts +2 -19
  92. package/src/main.ts +5 -11
  93. package/src/runtime.ts +2 -6
  94. package/src/setup.ts +2 -2
  95. package/tests/e2e/public-commands.test.ts +68 -1
  96. package/dist/clis/grok/debug.d.ts +0 -1
  97. package/dist/clis/grok/debug.js +0 -45
  98. package/src/clis/grok/debug.ts +0 -49
@@ -137,6 +137,119 @@
137
137
  "domain": "localhost",
138
138
  "columns": []
139
139
  },
140
+ {
141
+ "site": "apple-podcasts",
142
+ "name": "episodes",
143
+ "description": "List recent episodes of an Apple Podcast (use ID from search)",
144
+ "strategy": "public",
145
+ "browser": false,
146
+ "args": [
147
+ {
148
+ "name": "id",
149
+ "type": "str",
150
+ "required": true,
151
+ "positional": true,
152
+ "help": "Podcast ID (collectionId from search output)"
153
+ },
154
+ {
155
+ "name": "limit",
156
+ "type": "int",
157
+ "default": 15,
158
+ "required": false,
159
+ "help": "Max episodes to show"
160
+ }
161
+ ],
162
+ "type": "ts",
163
+ "modulePath": "apple-podcasts/episodes.js",
164
+ "columns": [
165
+ "title",
166
+ "duration",
167
+ "date"
168
+ ]
169
+ },
170
+ {
171
+ "site": "apple-podcasts",
172
+ "name": "search",
173
+ "description": "Search Apple Podcasts",
174
+ "strategy": "public",
175
+ "browser": false,
176
+ "args": [
177
+ {
178
+ "name": "keyword",
179
+ "type": "str",
180
+ "required": true,
181
+ "positional": true,
182
+ "help": "Search keyword"
183
+ },
184
+ {
185
+ "name": "limit",
186
+ "type": "int",
187
+ "default": 10,
188
+ "required": false,
189
+ "help": "Max results"
190
+ }
191
+ ],
192
+ "type": "ts",
193
+ "modulePath": "apple-podcasts/search.js",
194
+ "columns": [
195
+ "id",
196
+ "title",
197
+ "author",
198
+ "episodes",
199
+ "genre"
200
+ ]
201
+ },
202
+ {
203
+ "site": "apple-podcasts",
204
+ "name": "top",
205
+ "description": "Top podcasts chart on Apple Podcasts",
206
+ "strategy": "public",
207
+ "browser": false,
208
+ "args": [
209
+ {
210
+ "name": "limit",
211
+ "type": "int",
212
+ "default": 20,
213
+ "required": false,
214
+ "help": "Number of podcasts (max 100)"
215
+ },
216
+ {
217
+ "name": "country",
218
+ "type": "str",
219
+ "default": "us",
220
+ "required": false,
221
+ "help": "Country code (e.g. us, cn, gb, jp)"
222
+ }
223
+ ],
224
+ "type": "ts",
225
+ "modulePath": "apple-podcasts/top.js",
226
+ "columns": [
227
+ "rank",
228
+ "title",
229
+ "author",
230
+ "id"
231
+ ]
232
+ },
233
+ {
234
+ "site": "apple-podcasts",
235
+ "name": "utils",
236
+ "description": "",
237
+ "strategy": "cookie",
238
+ "browser": true,
239
+ "args": [],
240
+ "type": "ts",
241
+ "modulePath": "apple-podcasts/utils.js"
242
+ },
243
+ {
244
+ "site": "apple-podcasts",
245
+ "name": "utils.test",
246
+ "description": "",
247
+ "strategy": "cookie",
248
+ "browser": true,
249
+ "args": [],
250
+ "type": "ts",
251
+ "modulePath": "apple-podcasts/utils.test.js"
252
+ },
140
253
  {
141
254
  "site": "barchart",
142
255
  "name": "flow",
@@ -1956,20 +2069,6 @@
1956
2069
  "response"
1957
2070
  ]
1958
2071
  },
1959
- {
1960
- "site": "grok",
1961
- "name": "debug",
1962
- "description": "Debug grok page structure",
1963
- "strategy": "cookie",
1964
- "browser": true,
1965
- "args": [],
1966
- "type": "ts",
1967
- "modulePath": "grok/debug.js",
1968
- "domain": "grok.com",
1969
- "columns": [
1970
- "data"
1971
- ]
1972
- },
1973
2072
  {
1974
2073
  "site": "hackernews",
1975
2074
  "name": "top",
@@ -3545,6 +3644,37 @@
3545
3644
  "url"
3546
3645
  ]
3547
3646
  },
3647
+ {
3648
+ "site": "twitter",
3649
+ "name": "accept",
3650
+ "description": "Auto-accept DM requests containing specific keywords",
3651
+ "strategy": "ui",
3652
+ "browser": true,
3653
+ "args": [
3654
+ {
3655
+ "name": "keyword",
3656
+ "type": "string",
3657
+ "required": true,
3658
+ "help": "Keywords to match (comma-separated for OR, e.g. "
3659
+ },
3660
+ {
3661
+ "name": "max",
3662
+ "type": "int",
3663
+ "default": 20,
3664
+ "required": false,
3665
+ "help": "Maximum number of requests to accept (default: 20)"
3666
+ }
3667
+ ],
3668
+ "type": "ts",
3669
+ "modulePath": "twitter/accept.js",
3670
+ "domain": "x.com",
3671
+ "columns": [
3672
+ "index",
3673
+ "status",
3674
+ "user",
3675
+ "message"
3676
+ ]
3677
+ },
3548
3678
  {
3549
3679
  "site": "twitter",
3550
3680
  "name": "article",
@@ -3866,6 +3996,37 @@
3866
3996
  "created_at"
3867
3997
  ]
3868
3998
  },
3999
+ {
4000
+ "site": "twitter",
4001
+ "name": "reply-dm",
4002
+ "description": "Send a message to recent DM conversations",
4003
+ "strategy": "ui",
4004
+ "browser": true,
4005
+ "args": [
4006
+ {
4007
+ "name": "text",
4008
+ "type": "string",
4009
+ "required": true,
4010
+ "help": "Message text to send (e.g. "
4011
+ },
4012
+ {
4013
+ "name": "max",
4014
+ "type": "int",
4015
+ "default": 20,
4016
+ "required": false,
4017
+ "help": "Maximum number of conversations to reply to (default: 20)"
4018
+ }
4019
+ ],
4020
+ "type": "ts",
4021
+ "modulePath": "twitter/reply-dm.js",
4022
+ "domain": "x.com",
4023
+ "columns": [
4024
+ "index",
4025
+ "status",
4026
+ "user",
4027
+ "message"
4028
+ ]
4029
+ },
3869
4030
  {
3870
4031
  "site": "twitter",
3871
4032
  "name": "reply",
@@ -4384,6 +4545,225 @@
4384
4545
  "url"
4385
4546
  ]
4386
4547
  },
4548
+ {
4549
+ "site": "weread",
4550
+ "name": "book",
4551
+ "description": "View book details on WeRead",
4552
+ "strategy": "cookie",
4553
+ "browser": true,
4554
+ "args": [
4555
+ {
4556
+ "name": "bookId",
4557
+ "type": "str",
4558
+ "required": true,
4559
+ "positional": true,
4560
+ "help": "Book ID (numeric, from search or shelf results)"
4561
+ }
4562
+ ],
4563
+ "type": "ts",
4564
+ "modulePath": "weread/book.js",
4565
+ "domain": "weread.qq.com",
4566
+ "columns": [
4567
+ "title",
4568
+ "author",
4569
+ "publisher",
4570
+ "intro",
4571
+ "category",
4572
+ "rating"
4573
+ ]
4574
+ },
4575
+ {
4576
+ "site": "weread",
4577
+ "name": "highlights",
4578
+ "description": "List your highlights (underlines) in a book",
4579
+ "strategy": "cookie",
4580
+ "browser": true,
4581
+ "args": [
4582
+ {
4583
+ "name": "bookId",
4584
+ "type": "str",
4585
+ "required": true,
4586
+ "positional": true,
4587
+ "help": "Book ID (from shelf or search results)"
4588
+ },
4589
+ {
4590
+ "name": "limit",
4591
+ "type": "int",
4592
+ "default": 20,
4593
+ "required": false,
4594
+ "help": "Max results"
4595
+ }
4596
+ ],
4597
+ "type": "ts",
4598
+ "modulePath": "weread/highlights.js",
4599
+ "domain": "weread.qq.com",
4600
+ "columns": [
4601
+ "chapter",
4602
+ "text",
4603
+ "createTime"
4604
+ ]
4605
+ },
4606
+ {
4607
+ "site": "weread",
4608
+ "name": "notebooks",
4609
+ "description": "List books that have highlights or notes",
4610
+ "strategy": "cookie",
4611
+ "browser": true,
4612
+ "args": [],
4613
+ "type": "ts",
4614
+ "modulePath": "weread/notebooks.js",
4615
+ "domain": "weread.qq.com",
4616
+ "columns": [
4617
+ "title",
4618
+ "author",
4619
+ "noteCount",
4620
+ "bookId"
4621
+ ]
4622
+ },
4623
+ {
4624
+ "site": "weread",
4625
+ "name": "notes",
4626
+ "description": "List your notes (thoughts) on a book",
4627
+ "strategy": "cookie",
4628
+ "browser": true,
4629
+ "args": [
4630
+ {
4631
+ "name": "bookId",
4632
+ "type": "str",
4633
+ "required": true,
4634
+ "positional": true,
4635
+ "help": "Book ID (from shelf or search results)"
4636
+ },
4637
+ {
4638
+ "name": "limit",
4639
+ "type": "int",
4640
+ "default": 20,
4641
+ "required": false,
4642
+ "help": "Max results"
4643
+ }
4644
+ ],
4645
+ "type": "ts",
4646
+ "modulePath": "weread/notes.js",
4647
+ "domain": "weread.qq.com",
4648
+ "columns": [
4649
+ "chapter",
4650
+ "text",
4651
+ "review",
4652
+ "createTime"
4653
+ ]
4654
+ },
4655
+ {
4656
+ "site": "weread",
4657
+ "name": "ranking",
4658
+ "description": "WeRead book rankings by category",
4659
+ "strategy": "public",
4660
+ "browser": false,
4661
+ "args": [
4662
+ {
4663
+ "name": "category",
4664
+ "type": "str",
4665
+ "default": "all",
4666
+ "required": false,
4667
+ "positional": true,
4668
+ "help": "Category: all (default), rising, or numeric category ID"
4669
+ },
4670
+ {
4671
+ "name": "limit",
4672
+ "type": "int",
4673
+ "default": 20,
4674
+ "required": false,
4675
+ "help": "Max results"
4676
+ }
4677
+ ],
4678
+ "type": "ts",
4679
+ "modulePath": "weread/ranking.js",
4680
+ "domain": "weread.qq.com",
4681
+ "columns": [
4682
+ "rank",
4683
+ "title",
4684
+ "author",
4685
+ "category",
4686
+ "readingCount",
4687
+ "bookId"
4688
+ ]
4689
+ },
4690
+ {
4691
+ "site": "weread",
4692
+ "name": "search",
4693
+ "description": "Search books on WeRead",
4694
+ "strategy": "public",
4695
+ "browser": false,
4696
+ "args": [
4697
+ {
4698
+ "name": "keyword",
4699
+ "type": "str",
4700
+ "required": true,
4701
+ "positional": true,
4702
+ "help": "Search keyword"
4703
+ },
4704
+ {
4705
+ "name": "limit",
4706
+ "type": "int",
4707
+ "default": 10,
4708
+ "required": false,
4709
+ "help": "Max results"
4710
+ }
4711
+ ],
4712
+ "type": "ts",
4713
+ "modulePath": "weread/search.js",
4714
+ "domain": "weread.qq.com",
4715
+ "columns": [
4716
+ "rank",
4717
+ "title",
4718
+ "author",
4719
+ "bookId"
4720
+ ]
4721
+ },
4722
+ {
4723
+ "site": "weread",
4724
+ "name": "shelf",
4725
+ "description": "List books on your WeRead bookshelf",
4726
+ "strategy": "cookie",
4727
+ "browser": true,
4728
+ "args": [
4729
+ {
4730
+ "name": "limit",
4731
+ "type": "int",
4732
+ "default": 20,
4733
+ "required": false,
4734
+ "help": "Max results"
4735
+ }
4736
+ ],
4737
+ "type": "ts",
4738
+ "modulePath": "weread/shelf.js",
4739
+ "domain": "weread.qq.com",
4740
+ "columns": [
4741
+ "title",
4742
+ "author",
4743
+ "progress",
4744
+ "bookId"
4745
+ ]
4746
+ },
4747
+ {
4748
+ "site": "weread",
4749
+ "name": "utils",
4750
+ "description": "",
4751
+ "strategy": "cookie",
4752
+ "browser": false,
4753
+ "args": [],
4754
+ "type": "ts",
4755
+ "modulePath": "weread/utils.js"
4756
+ },
4757
+ {
4758
+ "site": "weread",
4759
+ "name": "utils.test",
4760
+ "description": "",
4761
+ "strategy": "cookie",
4762
+ "browser": true,
4763
+ "args": [],
4764
+ "type": "ts",
4765
+ "modulePath": "weread/utils.test.js"
4766
+ },
4387
4767
  {
4388
4768
  "site": "xiaohongshu",
4389
4769
  "name": "creator-note-detail",
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch, formatDuration, formatDate } from './utils.js';
4
+ cli({
5
+ site: 'apple-podcasts',
6
+ name: 'episodes',
7
+ description: 'List recent episodes of an Apple Podcast (use ID from search)',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'id', positional: true, required: true, help: 'Podcast ID (collectionId from search output)' },
12
+ { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
13
+ ],
14
+ columns: ['title', 'duration', 'date'],
15
+ func: async (_page, args) => {
16
+ const limit = Math.max(1, Math.min(Number(args.limit), 200));
17
+ // results[0] is the podcast itself; the rest are episodes
18
+ const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
19
+ const episodes = (data.results ?? []).filter((r) => r.kind === 'podcast-episode');
20
+ if (!episodes.length)
21
+ throw new CliError('NOT_FOUND', 'No episodes found', 'Check the podcast ID from: opencli apple-podcasts search <keyword>');
22
+ return episodes.slice(0, limit).map((ep) => ({
23
+ title: ep.trackName,
24
+ duration: formatDuration(ep.trackTimeMillis),
25
+ date: formatDate(ep.releaseDate),
26
+ }));
27
+ },
28
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { itunesFetch } from './utils.js';
4
+ cli({
5
+ site: 'apple-podcasts',
6
+ name: 'search',
7
+ description: 'Search Apple Podcasts',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword' },
12
+ { name: 'limit', type: 'int', default: 10, help: 'Max results' },
13
+ ],
14
+ columns: ['id', 'title', 'author', 'episodes', 'genre'],
15
+ func: async (_page, args) => {
16
+ const term = encodeURIComponent(args.keyword);
17
+ const limit = Math.max(1, Math.min(Number(args.limit), 25));
18
+ const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
19
+ if (!data.results?.length)
20
+ throw new CliError('NOT_FOUND', 'No podcasts found', `Try a different keyword`);
21
+ return data.results.map((p) => ({
22
+ id: p.collectionId,
23
+ title: p.collectionName,
24
+ author: p.artistName,
25
+ episodes: p.trackCount ?? '-',
26
+ genre: p.primaryGenreName ?? '-',
27
+ }));
28
+ },
29
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ // Apple Marketing Tools RSS API — public, no key required
4
+ const CHARTS_URL = 'https://rss.applemarketingtools.com/api/v2';
5
+ cli({
6
+ site: 'apple-podcasts',
7
+ name: 'top',
8
+ description: 'Top podcasts chart on Apple Podcasts',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'limit', type: 'int', default: 20, help: 'Number of podcasts (max 100)' },
13
+ { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
14
+ ],
15
+ columns: ['rank', 'title', 'author', 'id'],
16
+ func: async (_page, args) => {
17
+ const limit = Math.max(1, Math.min(Number(args.limit), 100));
18
+ const country = String(args.country || 'us').trim().toLowerCase();
19
+ const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
20
+ const resp = await fetch(url);
21
+ if (!resp.ok)
22
+ throw new CliError('FETCH_ERROR', `Charts API HTTP ${resp.status}`, `Check country code: ${country}`);
23
+ const data = await resp.json();
24
+ const results = data?.feed?.results;
25
+ if (!results?.length)
26
+ throw new CliError('NOT_FOUND', 'No chart data found', `Try a different country code`);
27
+ return results.map((p, i) => ({
28
+ rank: i + 1,
29
+ title: p.name,
30
+ author: p.artistName,
31
+ id: p.id,
32
+ }));
33
+ },
34
+ });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared Apple Podcasts utilities.
3
+ *
4
+ * Uses the public iTunes Search API — no API key required.
5
+ * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
6
+ */
7
+ export declare function itunesFetch(path: string): Promise<any>;
8
+ /** Format milliseconds to mm:ss. Returns '-' for missing input. */
9
+ export declare function formatDuration(ms: number): string;
10
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
11
+ export declare function formatDate(iso: string): string;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared Apple Podcasts utilities.
3
+ *
4
+ * Uses the public iTunes Search API — no API key required.
5
+ * https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
6
+ */
7
+ import { CliError } from '../../errors.js';
8
+ const BASE = 'https://itunes.apple.com';
9
+ export async function itunesFetch(path) {
10
+ const resp = await fetch(`${BASE}${path}`);
11
+ if (!resp.ok) {
12
+ throw new CliError('FETCH_ERROR', `iTunes API HTTP ${resp.status}`, 'Check your search term or podcast ID');
13
+ }
14
+ return resp.json();
15
+ }
16
+ /** Format milliseconds to mm:ss. Returns '-' for missing input. */
17
+ export function formatDuration(ms) {
18
+ if (!ms || !Number.isFinite(ms))
19
+ return '-';
20
+ const totalSec = Math.round(ms / 1000);
21
+ const m = Math.floor(totalSec / 60);
22
+ const s = totalSec % 60;
23
+ return `${m}:${String(s).padStart(2, '0')}`;
24
+ }
25
+ /** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
26
+ export function formatDate(iso) {
27
+ if (!iso)
28
+ return '-';
29
+ return iso.slice(0, 10);
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDuration, formatDate, itunesFetch } from './utils.js';
3
+ describe('formatDuration', () => {
4
+ it('formats typical duration in ms', () => {
5
+ expect(formatDuration(3661000)).toBe('61:01');
6
+ });
7
+ it('pads single-digit seconds', () => {
8
+ expect(formatDuration(65000)).toBe('1:05');
9
+ });
10
+ it('formats exact minutes', () => {
11
+ expect(formatDuration(3600000)).toBe('60:00');
12
+ });
13
+ it('rounds fractional milliseconds', () => {
14
+ expect(formatDuration(3600500)).toBe('60:01');
15
+ });
16
+ it('returns dash for zero', () => {
17
+ expect(formatDuration(0)).toBe('-');
18
+ });
19
+ it('returns dash for NaN', () => {
20
+ expect(formatDuration(NaN)).toBe('-');
21
+ });
22
+ });
23
+ describe('formatDate', () => {
24
+ it('extracts YYYY-MM-DD from ISO string', () => {
25
+ expect(formatDate('2026-03-19T12:00:00.000Z')).toBe('2026-03-19');
26
+ });
27
+ it('handles date-only string', () => {
28
+ expect(formatDate('2025-01-01')).toBe('2025-01-01');
29
+ });
30
+ it('returns dash for empty string', () => {
31
+ expect(formatDate('')).toBe('-');
32
+ });
33
+ it('returns dash for undefined', () => {
34
+ expect(formatDate(undefined)).toBe('-');
35
+ });
36
+ });
37
+ describe('itunesFetch', () => {
38
+ beforeEach(() => {
39
+ vi.restoreAllMocks();
40
+ });
41
+ it('returns parsed JSON on success', async () => {
42
+ const mockData = { resultCount: 1, results: [{ collectionId: 123 }] };
43
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
44
+ ok: true,
45
+ json: () => Promise.resolve(mockData),
46
+ }));
47
+ const result = await itunesFetch('/search?term=test&media=podcast&limit=1');
48
+ expect(result).toEqual(mockData);
49
+ });
50
+ it('throws CliError on HTTP error', async () => {
51
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
52
+ ok: false,
53
+ status: 403,
54
+ }));
55
+ await expect(itunesFetch('/search?term=test')).rejects.toThrow('iTunes API HTTP 403');
56
+ });
57
+ });
@@ -38,6 +38,23 @@ export const historyCommand = cli({
38
38
  if (items.length === 0) {
39
39
  return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }];
40
40
  }
41
- return items;
41
+ const dateHeaders = /^(today|yesterday|last week|last month|last year|this week|this month|older|previous \d+ days|\d+ days ago)$/i;
42
+ const numericOnly = /^[\d\s]+$/;
43
+ const modelPath = /^[\w.-]+\/[\w.-]/;
44
+ const seen = new Set();
45
+ const deduped = items.filter((item) => {
46
+ const t = item.Title.trim();
47
+ if (dateHeaders.test(t))
48
+ return false;
49
+ if (numericOnly.test(t))
50
+ return false;
51
+ if (modelPath.test(t))
52
+ return false;
53
+ if (seen.has(t))
54
+ return false;
55
+ seen.add(t);
56
+ return true;
57
+ }).map((item, i) => ({ Index: i + 1, Title: item.Title }));
58
+ return deduped;
42
59
  },
43
60
  });