Skip to content

Commit aa677cc

Browse files
authored
Merge pull request #181 from linyows/fix/support-notion-icon-type
fix: support Notion API icon type for page/database/callout icons
2 parents 62d617f + 5fb00a6 commit aa677cc

9 files changed

Lines changed: 197 additions & 47 deletions

File tree

src/exporter/blocks.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
incrementalCache,
99
debug,
1010
} from './variables.js'
11-
import { FetchPage } from './page.js'
11+
import { FetchPage, getNotionIconUrl } from './page.js'
1212
import {
1313
createDirWhenNotfound,
1414
saveImage,
@@ -124,6 +124,16 @@ export const FetchBlocks = async ({ block_id, last_edited_time }: FetchBlocksArg
124124
console.log(`Failed to save callout icon: ${e}`)
125125
}
126126
}
127+
} else if (block.callout.icon?.type === 'icon') {
128+
const url = getNotionIconUrl(block.callout.icon.icon)
129+
try {
130+
const ipws = await saveImage(url, `block-${block.id}`)
131+
;(block.callout as any).icon = { type: 'external', external: { url }, src: ipws.path }
132+
} catch (e) {
133+
if (debug) {
134+
console.log(`Failed to save callout icon: ${e}`)
135+
}
136+
}
127137
}
128138
if (block.has_children) {
129139
block.children = await FetchBlocks({ block_id: block.id, last_edited_time: block.last_edited_time })

src/exporter/breadcrumbs.test.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as td from 'testdouble'
33
import * as assert from 'uvu/assert'
44
import type { FetchBreadcrumbsProps } from './breadcrumbs.js'
55
import { FetchBreadcrumbs } from './breadcrumbs.js'
6+
import type { Breadcrumb } from './types.js'
67

78
test.before(() => {
89
td.replace(console, 'log')
@@ -141,23 +142,23 @@ test('FetchBreadcrumbs returns correct data structure', async () => {
141142

142143
try {
143144
const result = await FetchBreadcrumbs(props)
144-
145+
145146
// Always returns an array
146147
assert.ok(Array.isArray(result))
147-
148+
148149
// Each breadcrumb should have required structure
149150
for (const breadcrumb of result) {
150151
assert.ok(typeof breadcrumb === 'object')
151152
assert.ok('id' in breadcrumb)
152153
assert.ok('name' in breadcrumb)
153154
assert.ok(typeof breadcrumb.id === 'string')
154155
assert.ok(typeof breadcrumb.name === 'string')
155-
156+
156157
// Icon is optional but if present should have correct structure
157158
if ('icon' in breadcrumb && breadcrumb.icon) {
158159
assert.ok('type' in breadcrumb.icon)
159160
assert.ok(['emoji', 'external', 'file'].includes(breadcrumb.icon.type))
160-
161+
161162
if (breadcrumb.icon.type === 'emoji') {
162163
assert.ok('emoji' in breadcrumb.icon)
163164
} else {
@@ -172,6 +173,46 @@ test('FetchBreadcrumbs returns correct data structure', async () => {
172173
}
173174
})
174175

176+
test('Breadcrumb type allows entries without icon', () => {
177+
// Pages without icon should still produce valid breadcrumb entries
178+
const breadcrumb: Breadcrumb = {
179+
id: 'page-without-icon',
180+
name: 'No Icon Page',
181+
}
182+
183+
assert.ok(typeof breadcrumb.id === 'string')
184+
assert.ok(typeof breadcrumb.name === 'string')
185+
assert.equal(breadcrumb.icon, undefined)
186+
})
187+
188+
test('Breadcrumb type allows entries with emoji icon', () => {
189+
const breadcrumb: Breadcrumb = {
190+
id: 'page-with-emoji',
191+
name: 'Emoji Page',
192+
icon: {
193+
type: 'emoji',
194+
emoji: '📚',
195+
},
196+
}
197+
198+
assert.equal(breadcrumb.icon?.type, 'emoji')
199+
})
200+
201+
test('Breadcrumb type allows entries with external icon', () => {
202+
const breadcrumb: Breadcrumb = {
203+
id: 'page-with-icon',
204+
name: 'Icon Page',
205+
icon: {
206+
type: 'external',
207+
src: '/images/page-icon-123.svg',
208+
url: 'https://www.notion.so/icons/bookmark-outline_blue.svg',
209+
},
210+
}
211+
212+
assert.equal(breadcrumb.icon?.type, 'external')
213+
assert.ok(breadcrumb.icon?.src?.includes('/images/'))
214+
})
215+
175216
test('FetchBreadcrumbs supports all parent types correctly', async () => {
176217
const testCases: Array<{
177218
type: FetchBreadcrumbsProps['type'],

src/exporter/breadcrumbs.ts

Lines changed: 37 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { notion, reqAPIWithBackoffAndCache } from './api.js'
2-
import { savePageIcon } from './page.js'
2+
import { savePageIcon, getNotionIconUrl } from './page.js'
33
import { saveDatabaseIcon } from './database.js'
44
import type {
55
GetBlockResponse,
@@ -69,26 +69,27 @@ export const FetchBreadcrumbs = async ({ type, id, limit }: FetchBreadcrumbsProp
6969
nextType = page.parent.type
7070
nextID = getID(page.parent)
7171
const name = page.properties.title.type === 'title' ? page.properties.title.title.map(v => v.plain_text).join('') : ''
72+
const breadcrumb: Breadcrumb = { id: page.id, name }
7273
if (page.icon?.type === 'emoji') {
73-
breadcrumbs.unshift({
74-
id: page.id,
75-
name,
76-
icon: {
77-
type: page.icon.type,
78-
emoji: page.icon.emoji,
79-
}
80-
})
74+
breadcrumb.icon = {
75+
type: page.icon.type,
76+
emoji: page.icon.emoji,
77+
}
8178
} else if (page.icon?.type === 'external' || page.icon?.type === 'file') {
82-
breadcrumbs.unshift({
83-
id: page.id,
84-
name,
85-
icon: {
86-
type: page.icon.type,
87-
src: page.icon.src,
88-
url: page.icon.type === 'external' ? page.icon.external.url : page.icon.file.url,
89-
}
90-
})
79+
breadcrumb.icon = {
80+
type: page.icon.type,
81+
src: page.icon.src,
82+
url: page.icon.type === 'external' ? page.icon.external.url : page.icon.file.url,
83+
}
84+
} else if (page.icon?.type === 'icon') {
85+
const url = getNotionIconUrl(page.icon.icon)
86+
breadcrumb.icon = {
87+
type: 'external',
88+
src: page.icon.src ?? url,
89+
url,
90+
}
9191
}
92+
breadcrumbs.unshift(breadcrumb)
9293
count++
9394
break
9495
}
@@ -104,26 +105,27 @@ export const FetchBreadcrumbs = async ({ type, id, limit }: FetchBreadcrumbsProp
104105
nextType = db.parent.type
105106
nextID = getID(db.parent)
106107
const name = db.title.map(v => v.plain_text).join('')
108+
const breadcrumb: Breadcrumb = { id: db.id, name }
107109
if (db.icon?.type === 'emoji') {
108-
breadcrumbs.unshift({
109-
id: db.id,
110-
name,
111-
icon: {
112-
type: db.icon.type,
113-
emoji: db.icon.emoji,
114-
}
115-
})
110+
breadcrumb.icon = {
111+
type: db.icon.type,
112+
emoji: db.icon.emoji,
113+
}
116114
} else if (db.icon?.type === 'external' || db.icon?.type === 'file') {
117-
breadcrumbs.unshift({
118-
id: db.id,
119-
name,
120-
icon: {
121-
type: db.icon.type,
122-
src: db.icon.src,
123-
url: db.icon.type === 'external' ? db.icon.external.url : db.icon.file.url,
124-
}
125-
})
115+
breadcrumb.icon = {
116+
type: db.icon.type,
117+
src: db.icon.src,
118+
url: db.icon.type === 'external' ? db.icon.external.url : db.icon.file.url,
119+
}
120+
} else if (db.icon?.type === 'icon') {
121+
const url = getNotionIconUrl(db.icon.icon)
122+
breadcrumb.icon = {
123+
type: 'external',
124+
src: db.icon.src ?? url,
125+
url,
126+
}
126127
}
128+
breadcrumbs.unshift(breadcrumb)
127129
count++
128130
break
129131
}

src/exporter/database.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ test('saveDatabaseIcon handles undefined icon gracefully', async () => {
117117

118118
// Should not set src property when icon is undefined
119119
assert.equal(mockDatabase.icon, undefined)
120-
})
120+
})
121+
122+
test('saveDatabaseIcon saves notion icon type and converts to external', async () => {
123+
const mockDatabase = {
124+
id: 'test-db-id',
125+
icon: {
126+
type: 'icon',
127+
icon: {
128+
name: 'list-indent',
129+
color: 'blue',
130+
},
131+
}
132+
} as unknown as GetDatabaseResponseEx
133+
134+
await saveDatabaseIcon(mockDatabase)
135+
136+
const icon = mockDatabase.icon as any
137+
assert.equal(icon.type, 'external')
138+
assert.ok(icon.src !== undefined)
139+
assert.ok(icon.src.includes('/images/database-icon-test-db-id'))
140+
assert.ok(icon.src.includes('.svg'))
141+
assert.equal(icon.external.url, 'https://www.notion.so/icons/list-indent_blue.svg')
142+
})
121143

122-
test.run()
144+
test.run()

src/exporter/database.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
reqAPIWithBackoff,
33
notion,
44
} from './api.js'
5+
import { getNotionIconUrl } from './page.js'
56
import {
67
cacheDir,
78
incrementalCache,
@@ -188,6 +189,10 @@ export async function saveDatabaseIcon(db: GetDatabaseResponseEx) {
188189
} else if (db.icon.type === 'file') {
189190
const ipws = await saveImage(db.icon.file.url, `database-icon-${db.id}`)
190191
db.icon.src = ipws.path
192+
} else if (db.icon.type === 'icon') {
193+
const url = getNotionIconUrl(db.icon.icon)
194+
const ipws = await saveImage(url, `database-icon-${db.id}`)
195+
;(db as any).icon = { type: 'external', external: { url }, src: ipws.path }
191196
}
192197
} catch (e) {
193198
if (debug) {

src/exporter/page.test.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { test } from 'uvu'
22
import * as td from 'testdouble'
33
import * as assert from 'uvu/assert'
44
import type { GetPageResponseEx, PageObjectResponseEx } from './types.js'
5-
import { savePageCover, savePageIcon } from './page.js'
5+
import { savePageCover, savePageIcon, getNotionIconUrl } from './page.js'
66

77
test.before(() => {
88
td.replace(console, 'log')
@@ -179,4 +179,63 @@ test('savePageIcon works with PageObjectResponseEx type', async () => {
179179
assert.ok((mockPage.icon as any)?.src.includes('.png'))
180180
})
181181

182-
test.run()
182+
test('getNotionIconUrl builds correct URL from icon name and color', () => {
183+
const url = getNotionIconUrl({ name: 'bookmark-outline', color: 'blue' })
184+
assert.equal(url, 'https://www.notion.so/icons/bookmark-outline_blue.svg')
185+
})
186+
187+
test('getNotionIconUrl handles various icon names and colors', () => {
188+
const cases = [
189+
{ icon: { name: 'light-bulb', color: 'gray' }, expected: 'https://www.notion.so/icons/light-bulb_gray.svg' },
190+
{ icon: { name: 'circle', color: 'red' }, expected: 'https://www.notion.so/icons/circle_red.svg' },
191+
{ icon: { name: 'bread', color: 'blue' }, expected: 'https://www.notion.so/icons/bread_blue.svg' },
192+
]
193+
for (const { icon, expected } of cases) {
194+
assert.equal(getNotionIconUrl(icon), expected)
195+
}
196+
})
197+
198+
test('savePageIcon saves notion icon type and converts to external', async () => {
199+
const mockPage = {
200+
id: 'test-page-id',
201+
icon: {
202+
type: 'icon',
203+
icon: {
204+
name: 'bookmark-outline',
205+
color: 'blue',
206+
},
207+
}
208+
} as unknown as GetPageResponseEx
209+
210+
await savePageIcon(mockPage)
211+
212+
// Should convert icon type to external with src set
213+
const icon = mockPage.icon as any
214+
assert.equal(icon.type, 'external')
215+
assert.ok(icon.src !== undefined)
216+
assert.ok(icon.src.includes('/images/page-icon-test-page-id'))
217+
assert.ok(icon.src.includes('.svg'))
218+
assert.equal(icon.external.url, 'https://www.notion.so/icons/bookmark-outline_blue.svg')
219+
})
220+
221+
test('savePageIcon saves notion icon type with PageObjectResponseEx', async () => {
222+
const mockPage = {
223+
id: 'test-page-id',
224+
icon: {
225+
type: 'icon',
226+
icon: {
227+
name: 'circle',
228+
color: 'red',
229+
},
230+
}
231+
} as unknown as PageObjectResponseEx
232+
233+
await savePageIcon(mockPage)
234+
235+
const icon = mockPage.icon as any
236+
assert.equal(icon.type, 'external')
237+
assert.ok(icon.src.includes('/images/page-icon-test-page-id'))
238+
assert.equal(icon.external.url, 'https://www.notion.so/icons/circle_red.svg')
239+
})
240+
241+
test.run()

src/exporter/page.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ export async function savePageCover(page: GetPageResponseEx | PageObjectResponse
120120
}
121121
}
122122

123+
export function getNotionIconUrl(icon: { name: string, color: string }): string {
124+
return `https://www.notion.so/icons/${encodeURIComponent(icon.name)}_${encodeURIComponent(icon.color)}.svg`
125+
}
126+
123127
export async function savePageIcon(page: GetPageResponseEx | PageObjectResponseEx) {
124128
if (page.icon === undefined || page.icon === null) {
125129
return
@@ -131,6 +135,10 @@ export async function savePageIcon(page: GetPageResponseEx | PageObjectResponseE
131135
} else if (page.icon.type === 'file') {
132136
const ipws = await saveImage(page.icon.file.url, `page-icon-${page.id}`)
133137
page.icon.src = ipws.path
138+
} else if (page.icon.type === 'icon') {
139+
const url = getNotionIconUrl(page.icon.icon)
140+
const ipws = await saveImage(url, `page-icon-${page.id}`)
141+
;(page as any).icon = { type: 'external', external: { url }, src: ipws.path }
134142
}
135143
} catch (e) {
136144
if (debug) {

src/exporter/types.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export type CalloutBlockObjectResponseEx = CalloutBlockObjectResponse & {
150150
| { type: 'emoji', emoji: EmojiRequest }
151151
| { src: string, type: 'external', external: { url: TextRequest } }
152152
| { src: string, type: 'file', file: { url: string, expiry_time: string } }
153+
| { src: string, type: 'icon', icon: { name: string, color: string } }
153154
}
154155
children?: ListBlockChildrenResponseEx
155156
}
@@ -237,7 +238,7 @@ export type ParagraphBlockObjectResponseEx = ParagraphBlockObjectResponse & {
237238
export type Breadcrumb = {
238239
id: string
239240
name: string
240-
icon: MentionIcon
241+
icon?: MentionIcon
241242
}
242243
export type BreadcrumbBlockObjectResponseEx = BreadcrumbBlockObjectResponse & {
243244
list: Breadcrumb[]
@@ -352,6 +353,7 @@ export type GetPageResponseEx = PageObjectResponse & {
352353
| { src: string, type: 'emoji'; emoji: EmojiRequest }
353354
| { src: string, type: 'external', external: { url: string, expiry_time: string } }
354355
| { src: string, type: 'file', file: { url: string, expiry_time: string } }
356+
| { src: string, type: 'icon', icon: { name: string, color: string } }
355357
| null
356358
meta?: GetPagePropertyResponse
357359
}
@@ -450,6 +452,7 @@ export type GetDatabaseResponseEx = DatabaseObjectResponse & {
450452
| { type: 'emoji'; emoji: EmojiRequest }
451453
| { src: string, type: 'external'; external: { url: TextRequest } }
452454
| { src: string, type: 'file'; file: { url: string; expiry_time: string } }
455+
| { src: string, type: 'icon'; icon: { name: string; color: string } }
453456
| null
454457
cover:
455458
| { src: string, type: 'external'; external: { url: TextRequest } }

src/ui/components/Page/BreadcrumbBlock/Breadcrumbs.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const Breadcrumbs = ({ list, link, hrefs, query }: BreadcrumbsProps) => {
1616
link={link}
1717
query={query}
1818
>
19-
{v.icon.type === 'emoji' && <span className="rotion-breadcrumb-emoji">{v.icon.emoji}</span>}
20-
{v.icon.type !== 'emoji' && (
19+
{v.icon?.type === 'emoji' && <span className="rotion-breadcrumb-emoji">{v.icon.emoji}</span>}
20+
{v.icon && v.icon.type !== 'emoji' && (
2121
<img className="rotion-breadcrumb-icon" src={v.icon.src} width={20} height={20} alt={v.name} />
2222
)}
2323
<span className="rotion-breadcrumb-title">{v.name}</span>

0 commit comments

Comments
 (0)