kmcom-nuxt-layers 2.5.1 → 2.6.2
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.
- package/layers/core/app/utils/browserInfo.test.ts +81 -0
- package/layers/core/app/utils/featureClasses.test.ts +103 -0
- package/layers/core/app/utils/regex.test.ts +31 -0
- package/layers/feeds/nuxt.config.ts +7 -10
- package/layers/feeds/server/utils/feed-author.test.ts +71 -0
- package/layers/feeds/server/utils/feed-config.test.ts +162 -0
- package/layers/forms/app/utils/contact-schema.test.ts +75 -0
- package/layers/forms/app/utils/fieldProps.test.ts +141 -0
- package/layers/layout/app/utils/gridPlacementStyle.test.ts +310 -0
- package/layers/metadata/providers/comicvine/nuxt.config.ts +1 -1
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/client.ts +13 -6
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.test.ts +201 -0
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/normalise.ts +34 -15
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/provider.ts +26 -9
- package/layers/metadata/providers/comicvine/server/utils/metadata-comicvine/types.ts +1 -0
- package/layers/metadata/providers/comicvine/tsconfig.json +1 -1
- package/layers/metadata/providers/google-books/nuxt.config.ts +1 -1
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts +76 -0
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.ts +20 -2
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts +144 -0
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.ts +21 -12
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts +11 -4
- package/layers/metadata/providers/google-books/server/utils/metadata-google-books/types.ts +9 -1
- package/layers/metadata/providers/google-books/tsconfig.json +1 -1
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts +25 -0
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.ts +14 -6
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts +142 -0
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.ts +30 -24
- package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/provider.ts +5 -1
- package/layers/metadata/providers/openlibrary/tsconfig.json +1 -1
- package/layers/metadata/providers/themoviedb/nuxt.config.ts +36 -0
- package/layers/metadata/providers/themoviedb/package.json +12 -0
- package/layers/metadata/providers/themoviedb/server/plugins/tmdb-register.ts +3 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.test.ts +31 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/client.ts +60 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.test.ts +162 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/normalise.ts +83 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/provider.ts +37 -0
- package/layers/metadata/providers/themoviedb/server/utils/metadata-tmdb/types.ts +81 -0
- package/layers/metadata/providers/themoviedb/tsconfig.json +5 -0
- package/layers/metadata/server/api/metadata/lookup.get.ts +7 -2
- package/layers/metadata/server/api/metadata/search.get.ts +7 -2
- package/layers/metadata/server/api/metadata/status.get.ts +24 -0
- package/layers/metadata/server/api/metadata/sync.post.ts +14 -4
- package/layers/metadata/server/utils/metadata/cache.test.ts +39 -0
- package/layers/metadata/server/utils/metadata/cache.ts +15 -3
- package/layers/metadata/server/utils/metadata/errors.ts +1 -1
- package/layers/metadata/server/utils/metadata/provider-registry.test.ts +54 -0
- package/layers/metadata/server/utils/metadata/search.test.ts +198 -0
- package/layers/metadata/server/utils/metadata/search.ts +9 -4
- package/layers/navigation/app/utils/site.test.ts +41 -0
- package/layers/seo/app/utils/seoConfig.test.ts +58 -0
- package/layers/theme/server/utils/accent-css.test.ts +56 -0
- package/layers/theme/server/utils/fouc-config.test.ts +74 -0
- package/layers/visual/app/utils/colorTokens.test.ts +51 -0
- package/layers/visual/app/utils/gradientStyle.test.ts +76 -0
- package/layers/visual/app/utils/responsiveSizes.test.ts +71 -0
- package/package.json +7 -3
|
@@ -1,26 +1,43 @@
|
|
|
1
1
|
import type { MetadataProvider } from '#layers/metadata/shared/types'
|
|
2
2
|
|
|
3
|
+
// Matches CJK, Hangul, Arabic, Hebrew, Thai, Devanagari, Cyrillic, etc.
|
|
4
|
+
const NON_LATIN_RE = /[Ͱ-ϿЀ-ӿ-ۿऀ-ॿ -鿿가-豈-]/
|
|
5
|
+
|
|
6
|
+
function isEnglish(title: string): boolean {
|
|
7
|
+
return !NON_LATIN_RE.test(title)
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
export const comicVineProvider: MetadataProvider = {
|
|
4
11
|
id: 'comicvine',
|
|
5
12
|
label: 'Comic Vine',
|
|
6
|
-
mediaTypes: ['comic', '
|
|
13
|
+
mediaTypes: ['comic', 'comic-series'],
|
|
7
14
|
|
|
8
|
-
// fallow-ignore-next-line complexity
|
|
9
15
|
async search({ query, mediaType, limit = 10 }) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (resourceType === 'volume') {
|
|
16
|
+
if (mediaType === 'comic-series') {
|
|
13
17
|
const res = await searchComicVineVolumes(query, limit)
|
|
14
|
-
return (Array.isArray(res.results) ? res.results : [])
|
|
18
|
+
return (Array.isArray(res.results) ? res.results : [])
|
|
19
|
+
.filter((v) => isEnglish(v.name))
|
|
20
|
+
.map(normaliseComicVineVolume)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (mediaType === 'comic') {
|
|
24
|
+
const res = await searchComicVineIssues(query, limit)
|
|
25
|
+
return (Array.isArray(res.results) ? res.results : [])
|
|
26
|
+
.filter((i) => isEnglish(i.name ?? i.volume?.name ?? ''))
|
|
27
|
+
.map(normaliseComicVineIssue)
|
|
15
28
|
}
|
|
16
29
|
|
|
30
|
+
// All — split limit between issues and series
|
|
17
31
|
const [issueRes, volumeRes] = await Promise.all([
|
|
18
32
|
searchComicVineIssues(query, Math.ceil(limit / 2)),
|
|
19
33
|
searchComicVineVolumes(query, Math.floor(limit / 2)),
|
|
20
34
|
])
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
const issues = (Array.isArray(issueRes.results) ? issueRes.results : [])
|
|
36
|
+
.filter((i) => isEnglish(i.name ?? i.volume?.name ?? ''))
|
|
37
|
+
.map(normaliseComicVineIssue)
|
|
38
|
+
const volumes = (Array.isArray(volumeRes.results) ? volumeRes.results : [])
|
|
39
|
+
.filter((v) => isEnglish(v.name))
|
|
40
|
+
.map(normaliseComicVineVolume)
|
|
24
41
|
return [...issues, ...volumes]
|
|
25
42
|
},
|
|
26
43
|
|
|
@@ -29,6 +29,7 @@ export type ComicVineVolume = {
|
|
|
29
29
|
description: string | null
|
|
30
30
|
deck: string | null
|
|
31
31
|
start_year: string | null
|
|
32
|
+
count_of_issues: number | null
|
|
32
33
|
site_detail_url: string
|
|
33
34
|
image: { original_url: string; medium_url: string } | null
|
|
34
35
|
publisher: { id: number; name: string } | null
|
package/layers/metadata/providers/google-books/server/utils/metadata-google-books/client.test.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { googleBooksCoverUrl, googleBooksMediaType } from './client'
|
|
4
|
+
|
|
5
|
+
describe('googleBooksMediaType', () => {
|
|
6
|
+
it('classifies manga from categories', () => {
|
|
7
|
+
expect(googleBooksMediaType(['Comics & Graphic Novels / Manga'])).toBe('manga')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('classifies comic as graphic-novel', () => {
|
|
11
|
+
expect(googleBooksMediaType(['Comics & Graphic Novels / Comic'])).toBe('graphic-novel')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('classifies "graphic novel" as graphic-novel', () => {
|
|
15
|
+
expect(googleBooksMediaType(['Fiction / Graphic Novel'])).toBe('graphic-novel')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('is case-insensitive', () => {
|
|
19
|
+
expect(googleBooksMediaType(['MANGA'])).toBe('manga')
|
|
20
|
+
expect(googleBooksMediaType(['COMIC'])).toBe('graphic-novel')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('defaults to book for unrelated categories', () => {
|
|
24
|
+
expect(googleBooksMediaType(['Fiction / Thriller'])).toBe('book')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('defaults to book when categories is undefined', () => {
|
|
28
|
+
expect(googleBooksMediaType(undefined)).toBe('book')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('defaults to book when categories is empty', () => {
|
|
32
|
+
expect(googleBooksMediaType([])).toBe('book')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('prefers manga over comic when both are present', () => {
|
|
36
|
+
expect(googleBooksMediaType(['Manga / Comic'])).toBe('manga')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('googleBooksCoverUrl', () => {
|
|
41
|
+
it('upgrades http to https', () => {
|
|
42
|
+
const url = googleBooksCoverUrl('http://books.google.com/books/content?id=abc&zoom=1')
|
|
43
|
+
expect(url.startsWith('https://')).toBe(true)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('rewrites zoom=N to zoom=3', () => {
|
|
47
|
+
const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1')
|
|
48
|
+
expect(url).toContain('&zoom=3')
|
|
49
|
+
expect(url).not.toContain('&zoom=1')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('strips &edge=curl', () => {
|
|
53
|
+
const url = googleBooksCoverUrl(
|
|
54
|
+
'https://books.google.com/books/content?id=abc&zoom=1&edge=curl'
|
|
55
|
+
)
|
|
56
|
+
expect(url).not.toContain('edge=curl')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('appends &fife=w{width} using the default width of 800', () => {
|
|
60
|
+
const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1')
|
|
61
|
+
expect(url.endsWith('&fife=w800')).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('appends &fife=w{width} using a custom width', () => {
|
|
65
|
+
const url = googleBooksCoverUrl('https://books.google.com/books/content?id=abc&zoom=1', 200)
|
|
66
|
+
expect(url.endsWith('&fife=w200')).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('applies all transformations together', () => {
|
|
70
|
+
const url = googleBooksCoverUrl(
|
|
71
|
+
'http://books.google.com/books/content?id=abc&zoom=5&edge=curl',
|
|
72
|
+
400
|
|
73
|
+
)
|
|
74
|
+
expect(url).toBe('https://books.google.com/books/content?id=abc&zoom=3&fife=w400')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -2,12 +2,30 @@ import type { GoogleBooksSearchResponse, GoogleBooksVolume } from './types'
|
|
|
2
2
|
|
|
3
3
|
const BASE = 'https://www.googleapis.com/books/v1'
|
|
4
4
|
|
|
5
|
+
export function googleBooksMediaType(categories?: string[]): import('#layers/metadata/shared/types').MetadataMediaType {
|
|
6
|
+
const joined = (categories ?? []).join(' ').toLowerCase()
|
|
7
|
+
if (joined.includes('manga')) return 'manga'
|
|
8
|
+
if (joined.includes('comic') || joined.includes('graphic novel')) return 'graphic-novel'
|
|
9
|
+
return 'book'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function googleBooksCoverUrl(url: string, width = 800): string {
|
|
13
|
+
return url
|
|
14
|
+
.replace('http://', 'https://')
|
|
15
|
+
.replace(/&zoom=\d+/, '&zoom=3')
|
|
16
|
+
.replace('&edge=curl', '')
|
|
17
|
+
+ `&fife=w${width}`
|
|
18
|
+
}
|
|
19
|
+
|
|
5
20
|
function getApiKey(): string | undefined {
|
|
6
21
|
return useRuntimeConfig().metadataGoogleBooks?.apiKey || undefined
|
|
7
22
|
}
|
|
8
23
|
|
|
9
|
-
export async function searchGoogleBooks(
|
|
10
|
-
|
|
24
|
+
export async function searchGoogleBooks(
|
|
25
|
+
query: string,
|
|
26
|
+
limit = 10
|
|
27
|
+
): Promise<GoogleBooksSearchResponse> {
|
|
28
|
+
const params: Record<string, string> = { q: query, maxResults: String(limit), langRestrict: 'en' }
|
|
11
29
|
const apiKey = getApiKey()
|
|
12
30
|
if (apiKey) params.key = apiKey
|
|
13
31
|
|
package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.test.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { googleBooksCoverUrl, googleBooksMediaType } from './client'
|
|
4
|
+
import { normaliseGoogleBooksVolume } from './normalise'
|
|
5
|
+
import type { GoogleBooksVolume } from './types'
|
|
6
|
+
|
|
7
|
+
// normalise.ts calls googleBooksMediaType()/googleBooksCoverUrl() as Nitro auto-imports;
|
|
8
|
+
// stub them with the real (pure, string-building) implementations from client.ts.
|
|
9
|
+
vi.stubGlobal('googleBooksMediaType', googleBooksMediaType)
|
|
10
|
+
vi.stubGlobal('googleBooksCoverUrl', googleBooksCoverUrl)
|
|
11
|
+
|
|
12
|
+
function baseVolume(overrides: Partial<GoogleBooksVolume['volumeInfo']> = {}): GoogleBooksVolume {
|
|
13
|
+
return {
|
|
14
|
+
id: 'abc123',
|
|
15
|
+
selfLink: 'https://www.googleapis.com/books/v1/volumes/abc123',
|
|
16
|
+
volumeInfo: {
|
|
17
|
+
title: 'Watchmen',
|
|
18
|
+
...overrides,
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('normaliseGoogleBooksVolume', () => {
|
|
24
|
+
it('prefers extraLarge over all other image sizes', () => {
|
|
25
|
+
const record = normaliseGoogleBooksVolume(
|
|
26
|
+
baseVolume({
|
|
27
|
+
imageLinks: {
|
|
28
|
+
smallThumbnail: 's.jpg',
|
|
29
|
+
thumbnail: 't.jpg',
|
|
30
|
+
small: 'sm.jpg',
|
|
31
|
+
medium: 'm.jpg',
|
|
32
|
+
large: 'l.jpg',
|
|
33
|
+
extraLarge: 'xl.jpg',
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
)
|
|
37
|
+
expect(record.coverUrl).toBe(googleBooksCoverUrl('xl.jpg'))
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('falls back to large when extraLarge is absent', () => {
|
|
41
|
+
const record = normaliseGoogleBooksVolume(
|
|
42
|
+
baseVolume({ imageLinks: { large: 'l.jpg', medium: 'm.jpg' } })
|
|
43
|
+
)
|
|
44
|
+
expect(record.coverUrl).toBe(googleBooksCoverUrl('l.jpg'))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('falls back to medium when extraLarge/large are absent', () => {
|
|
48
|
+
const record = normaliseGoogleBooksVolume(
|
|
49
|
+
baseVolume({ imageLinks: { medium: 'm.jpg', small: 'sm.jpg' } })
|
|
50
|
+
)
|
|
51
|
+
expect(record.coverUrl).toBe(googleBooksCoverUrl('m.jpg'))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('falls back to small when extraLarge/large/medium are absent', () => {
|
|
55
|
+
const record = normaliseGoogleBooksVolume(
|
|
56
|
+
baseVolume({ imageLinks: { small: 'sm.jpg', thumbnail: 't.jpg' } })
|
|
57
|
+
)
|
|
58
|
+
expect(record.coverUrl).toBe(googleBooksCoverUrl('sm.jpg'))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('falls back to thumbnail when larger sizes are absent', () => {
|
|
62
|
+
const record = normaliseGoogleBooksVolume(
|
|
63
|
+
baseVolume({ imageLinks: { thumbnail: 't.jpg', smallThumbnail: 's.jpg' } })
|
|
64
|
+
)
|
|
65
|
+
expect(record.coverUrl).toBe(googleBooksCoverUrl('t.jpg'))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('falls back to smallThumbnail as the last resort', () => {
|
|
69
|
+
const record = normaliseGoogleBooksVolume(
|
|
70
|
+
baseVolume({ imageLinks: { smallThumbnail: 's.jpg' } })
|
|
71
|
+
)
|
|
72
|
+
expect(record.coverUrl).toBe(googleBooksCoverUrl('s.jpg'))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('leaves coverUrl undefined when imageLinks is absent', () => {
|
|
76
|
+
expect(normaliseGoogleBooksVolume(baseVolume()).coverUrl).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('extracts isbn10/isbn13 from industryIdentifiers', () => {
|
|
80
|
+
const record = normaliseGoogleBooksVolume(
|
|
81
|
+
baseVolume({
|
|
82
|
+
industryIdentifiers: [
|
|
83
|
+
{ type: 'ISBN_10', identifier: '0930289232' },
|
|
84
|
+
{ type: 'ISBN_13', identifier: '9780930289232' },
|
|
85
|
+
],
|
|
86
|
+
})
|
|
87
|
+
)
|
|
88
|
+
expect(record.identifiers).toMatchObject({
|
|
89
|
+
isbn10: '0930289232',
|
|
90
|
+
isbn13: '9780930289232',
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('leaves isbn10/isbn13 undefined when industryIdentifiers is absent', () => {
|
|
95
|
+
const record = normaliseGoogleBooksVolume(baseVolume())
|
|
96
|
+
expect(record.identifiers?.isbn10).toBeUndefined()
|
|
97
|
+
expect(record.identifiers?.isbn13).toBeUndefined()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('always sets identifiers.googleBooksId to the volume id', () => {
|
|
101
|
+
const record = normaliseGoogleBooksVolume(baseVolume())
|
|
102
|
+
expect(record.identifiers?.googleBooksId).toBe('abc123')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('classifies mediaType via googleBooksMediaType(categories)', () => {
|
|
106
|
+
const manga = normaliseGoogleBooksVolume(baseVolume({ categories: ['Manga'] }))
|
|
107
|
+
expect(manga.mediaType).toBe('manga')
|
|
108
|
+
|
|
109
|
+
const book = normaliseGoogleBooksVolume(baseVolume({ categories: ['Fiction'] }))
|
|
110
|
+
expect(book.mediaType).toBe('book')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('maps authors to creators with the author role', () => {
|
|
114
|
+
const record = normaliseGoogleBooksVolume(baseVolume({ authors: ['Alan Moore'] }))
|
|
115
|
+
expect(record.creators).toEqual([{ name: 'Alan Moore', role: 'author' }])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('leaves creators undefined when authors is absent', () => {
|
|
119
|
+
expect(normaliseGoogleBooksVolume(baseVolume()).creators).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('prefers canonicalVolumeLink over infoLink for sourceUrl', () => {
|
|
123
|
+
const record = normaliseGoogleBooksVolume(
|
|
124
|
+
baseVolume({ canonicalVolumeLink: 'canonical.url', infoLink: 'info.url' })
|
|
125
|
+
)
|
|
126
|
+
expect(record.sourceUrl).toBe('canonical.url')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('falls back to infoLink when canonicalVolumeLink is absent', () => {
|
|
130
|
+
const record = normaliseGoogleBooksVolume(baseVolume({ infoLink: 'info.url' }))
|
|
131
|
+
expect(record.sourceUrl).toBe('info.url')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('stamps lastSyncedAt as a string', () => {
|
|
135
|
+
expect(normaliseGoogleBooksVolume(baseVolume()).lastSyncedAt).toEqual(expect.any(String))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('sets id and provider fields from the volume id', () => {
|
|
139
|
+
const record = normaliseGoogleBooksVolume(baseVolume())
|
|
140
|
+
expect(record.id).toBe('google-books:volume:abc123')
|
|
141
|
+
expect(record.provider).toBe('google-books')
|
|
142
|
+
expect(record.providerId).toBe('abc123')
|
|
143
|
+
})
|
|
144
|
+
})
|
package/layers/metadata/providers/google-books/server/utils/metadata-google-books/normalise.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { MetadataCreator, MetadataRecord } from '#layers/metadata/shared/types'
|
|
2
|
+
|
|
2
3
|
import type { GoogleBooksVolume } from './types'
|
|
3
4
|
|
|
4
5
|
// fallow-ignore-next-line complexity
|
|
@@ -13,26 +14,34 @@ export function normaliseGoogleBooksVolume(volume: GoogleBooksVolume): MetadataR
|
|
|
13
14
|
const isbn10 = volumeInfo.industryIdentifiers?.find((i) => i.type === 'ISBN_10')?.identifier
|
|
14
15
|
const isbn13 = volumeInfo.industryIdentifiers?.find((i) => i.type === 'ISBN_13')?.identifier
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
+
const rawCover =
|
|
18
|
+
volumeInfo.imageLinks?.extraLarge ??
|
|
19
|
+
volumeInfo.imageLinks?.large ??
|
|
20
|
+
volumeInfo.imageLinks?.medium ??
|
|
21
|
+
volumeInfo.imageLinks?.small ??
|
|
22
|
+
volumeInfo.imageLinks?.thumbnail ??
|
|
23
|
+
volumeInfo.imageLinks?.smallThumbnail
|
|
24
|
+
|
|
25
|
+
const sourceUrl = volumeInfo.canonicalVolumeLink ?? volumeInfo.infoLink
|
|
17
26
|
|
|
18
27
|
return {
|
|
19
28
|
id: `google-books:volume:${volume.id}`,
|
|
20
29
|
provider: 'google-books',
|
|
21
30
|
providerId: volume.id,
|
|
22
|
-
mediaType:
|
|
31
|
+
mediaType: googleBooksMediaType(volumeInfo.categories),
|
|
23
32
|
title: volumeInfo.title,
|
|
24
|
-
subtitle: volumeInfo.subtitle,
|
|
25
|
-
description: volumeInfo.description,
|
|
26
|
-
creators
|
|
27
|
-
publisher: volumeInfo.publisher,
|
|
28
|
-
publishedAt: volumeInfo.publishedDate,
|
|
29
|
-
coverUrl:
|
|
33
|
+
...(volumeInfo.subtitle && { subtitle: volumeInfo.subtitle }),
|
|
34
|
+
...(volumeInfo.description && { description: volumeInfo.description }),
|
|
35
|
+
...(creators.length && { creators }),
|
|
36
|
+
...(volumeInfo.publisher && { publisher: volumeInfo.publisher }),
|
|
37
|
+
...(volumeInfo.publishedDate && { publishedAt: volumeInfo.publishedDate }),
|
|
38
|
+
...(rawCover && { coverUrl: googleBooksCoverUrl(rawCover) }),
|
|
30
39
|
identifiers: {
|
|
31
|
-
isbn10,
|
|
32
|
-
isbn13,
|
|
40
|
+
...(isbn10 && { isbn10 }),
|
|
41
|
+
...(isbn13 && { isbn13 }),
|
|
33
42
|
googleBooksId: volume.id,
|
|
34
43
|
},
|
|
35
|
-
sourceUrl
|
|
44
|
+
...(sourceUrl && { sourceUrl }),
|
|
36
45
|
raw: volume,
|
|
37
46
|
lastSyncedAt: new Date().toISOString(),
|
|
38
47
|
}
|
package/layers/metadata/providers/google-books/server/utils/metadata-google-books/provider.ts
CHANGED
|
@@ -3,11 +3,18 @@ import type { MetadataProvider } from '#layers/metadata/shared/types'
|
|
|
3
3
|
export const googleBooksProvider: MetadataProvider = {
|
|
4
4
|
id: 'google-books',
|
|
5
5
|
label: 'Google Books',
|
|
6
|
-
mediaTypes: ['book', 'graphic-novel', '
|
|
6
|
+
mediaTypes: ['book', 'graphic-novel', 'manga'],
|
|
7
7
|
|
|
8
|
-
async search({ query, limit = 10 }) {
|
|
9
|
-
const
|
|
10
|
-
|
|
8
|
+
async search({ query, mediaType, limit = 10 }) {
|
|
9
|
+
const subjectHints: Record<string, string> = {
|
|
10
|
+
'graphic-novel': '+subject:"Comics & Graphic Novels"',
|
|
11
|
+
'manga': '+subject:manga',
|
|
12
|
+
'book': '+-subject:"Comics & Graphic Novels"+-subject:manga',
|
|
13
|
+
}
|
|
14
|
+
const q = query + (mediaType ? (subjectHints[mediaType] ?? '') : '')
|
|
15
|
+
const res = await searchGoogleBooks(q, limit)
|
|
16
|
+
const records = (res.items ?? []).map(normaliseGoogleBooksVolume)
|
|
17
|
+
return mediaType ? records.filter((r) => r.mediaType === mediaType) : records
|
|
11
18
|
},
|
|
12
19
|
|
|
13
20
|
async lookup({ providerId }) {
|
|
@@ -7,7 +7,15 @@ export type GoogleBooksVolumeInfo = {
|
|
|
7
7
|
description?: string
|
|
8
8
|
industryIdentifiers?: Array<{ type: 'ISBN_10' | 'ISBN_13' | string; identifier: string }>
|
|
9
9
|
pageCount?: number
|
|
10
|
-
imageLinks?: {
|
|
10
|
+
imageLinks?: {
|
|
11
|
+
smallThumbnail?: string
|
|
12
|
+
thumbnail?: string
|
|
13
|
+
small?: string
|
|
14
|
+
medium?: string
|
|
15
|
+
large?: string
|
|
16
|
+
extraLarge?: string
|
|
17
|
+
}
|
|
18
|
+
categories?: string[]
|
|
11
19
|
previewLink?: string
|
|
12
20
|
infoLink?: string
|
|
13
21
|
canonicalVolumeLink?: string
|
package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/client.test.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { openLibraryCoverUrl } from './client'
|
|
4
|
+
|
|
5
|
+
describe('openLibraryCoverUrl', () => {
|
|
6
|
+
it('builds the covers.openlibrary.org URL shape', () => {
|
|
7
|
+
expect(openLibraryCoverUrl(12345)).toBe('https://covers.openlibrary.org/b/id/12345-L.jpg')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('defaults to size L when no size is given', () => {
|
|
11
|
+
expect(openLibraryCoverUrl(1)).toBe('https://covers.openlibrary.org/b/id/1-L.jpg')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('supports the S size', () => {
|
|
15
|
+
expect(openLibraryCoverUrl(1, 'S')).toBe('https://covers.openlibrary.org/b/id/1-S.jpg')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('supports the M size', () => {
|
|
19
|
+
expect(openLibraryCoverUrl(1, 'M')).toBe('https://covers.openlibrary.org/b/id/1-M.jpg')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('supports the L size explicitly', () => {
|
|
23
|
+
expect(openLibraryCoverUrl(1, 'L')).toBe('https://covers.openlibrary.org/b/id/1-L.jpg')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import type { OpenLibrarySearchResponse, OpenLibraryWork
|
|
1
|
+
import type { OpenLibraryEdition, OpenLibrarySearchResponse, OpenLibraryWork } from './types'
|
|
2
2
|
|
|
3
3
|
const BASE = 'https://openlibrary.org'
|
|
4
4
|
|
|
5
|
-
export function openLibraryCoverUrl(coverId: number, size: 'S' | 'M' | 'L' = '
|
|
5
|
+
export function openLibraryCoverUrl(coverId: number, size: 'S' | 'M' | 'L' = 'L'): string {
|
|
6
6
|
return `https://covers.openlibrary.org/b/id/${coverId}-${size}.jpg`
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export async function searchOpenLibrary(
|
|
9
|
+
export async function searchOpenLibrary(
|
|
10
|
+
query: string,
|
|
11
|
+
limit = 10
|
|
12
|
+
): Promise<OpenLibrarySearchResponse> {
|
|
10
13
|
return $fetch<OpenLibrarySearchResponse>(`${BASE}/search.json`, {
|
|
11
|
-
|
|
14
|
+
timeout: 10000,
|
|
15
|
+
query: {
|
|
16
|
+
q: query,
|
|
17
|
+
limit,
|
|
18
|
+
fields:
|
|
19
|
+
'key,title,subtitle,author_name,author_key,publisher,first_publish_year,isbn,cover_i,number_of_pages_median',
|
|
20
|
+
},
|
|
12
21
|
})
|
|
13
22
|
}
|
|
14
23
|
|
|
@@ -23,8 +32,7 @@ export async function lookupOpenLibraryEdition(editionId: string): Promise<OpenL
|
|
|
23
32
|
export async function lookupByIsbn(isbn: string): Promise<OpenLibraryEdition | null> {
|
|
24
33
|
try {
|
|
25
34
|
return await $fetch<OpenLibraryEdition>(`${BASE}/isbn/${isbn}.json`)
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
35
|
+
} catch {
|
|
28
36
|
return null
|
|
29
37
|
}
|
|
30
38
|
}
|
package/layers/metadata/providers/openlibrary/server/utils/metadata-openlibrary/normalise.test.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { openLibraryCoverUrl } from './client'
|
|
4
|
+
import {
|
|
5
|
+
normaliseOpenLibraryEdition,
|
|
6
|
+
normaliseOpenLibrarySearchDoc,
|
|
7
|
+
normaliseOpenLibraryWork,
|
|
8
|
+
} from './normalise'
|
|
9
|
+
import type { OpenLibraryEdition, OpenLibrarySearchDoc, OpenLibraryWork } from './types'
|
|
10
|
+
|
|
11
|
+
// normalise.ts calls openLibraryCoverUrl() as a Nitro auto-import; stub it with the real
|
|
12
|
+
// implementation from client.ts since that function is pure (no $fetch/config involved).
|
|
13
|
+
vi.stubGlobal('openLibraryCoverUrl', openLibraryCoverUrl)
|
|
14
|
+
|
|
15
|
+
function baseSearchDoc(overrides: Partial<OpenLibrarySearchDoc> = {}): OpenLibrarySearchDoc {
|
|
16
|
+
return {
|
|
17
|
+
key: '/works/OL82563W',
|
|
18
|
+
title: 'Watchmen',
|
|
19
|
+
...overrides,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function baseWork(overrides: Partial<OpenLibraryWork> = {}): OpenLibraryWork {
|
|
24
|
+
return {
|
|
25
|
+
key: '/works/OL82563W',
|
|
26
|
+
title: 'Watchmen',
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function baseEdition(overrides: Partial<OpenLibraryEdition> = {}): OpenLibraryEdition {
|
|
32
|
+
return {
|
|
33
|
+
key: '/books/OL456M',
|
|
34
|
+
title: 'Watchmen',
|
|
35
|
+
...overrides,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('normaliseOpenLibrarySearchDoc', () => {
|
|
40
|
+
it('strips /works/ from the key to derive the id', () => {
|
|
41
|
+
const record = normaliseOpenLibrarySearchDoc(baseSearchDoc({ key: '/works/OL82563W' }))
|
|
42
|
+
expect(record.providerId).toBe('OL82563W')
|
|
43
|
+
expect(record.id).toBe('openlibrary:work:OL82563W')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('splits isbn[] into isbn10/isbn13 by length', () => {
|
|
47
|
+
const record = normaliseOpenLibrarySearchDoc(
|
|
48
|
+
baseSearchDoc({ isbn: ['0930289232', '9780930289232'] })
|
|
49
|
+
)
|
|
50
|
+
expect(record.identifiers).toMatchObject({ isbn10: '0930289232', isbn13: '9780930289232' })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('leaves isbn10/isbn13 undefined when isbn is absent', () => {
|
|
54
|
+
const record = normaliseOpenLibrarySearchDoc(baseSearchDoc())
|
|
55
|
+
expect(record.identifiers?.isbn10).toBeUndefined()
|
|
56
|
+
expect(record.identifiers?.isbn13).toBeUndefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('maps author_name/author_key pairs into creators, stripping /authors/', () => {
|
|
60
|
+
const record = normaliseOpenLibrarySearchDoc(
|
|
61
|
+
baseSearchDoc({ author_name: ['Alan Moore'], author_key: ['/authors/OL123A'] })
|
|
62
|
+
)
|
|
63
|
+
expect(record.creators).toEqual([{ name: 'Alan Moore', role: 'author', providerId: 'OL123A' }])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('leaves creators undefined when there are no authors', () => {
|
|
67
|
+
expect(normaliseOpenLibrarySearchDoc(baseSearchDoc()).creators).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('builds coverUrl from cover_i using openLibraryCoverUrl', () => {
|
|
71
|
+
const record = normaliseOpenLibrarySearchDoc(baseSearchDoc({ cover_i: 12345 }))
|
|
72
|
+
expect(record.coverUrl).toBe(openLibraryCoverUrl(12345))
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('leaves coverUrl undefined when cover_i is absent', () => {
|
|
76
|
+
expect(normaliseOpenLibrarySearchDoc(baseSearchDoc()).coverUrl).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('stamps lastSyncedAt as a string', () => {
|
|
80
|
+
expect(normaliseOpenLibrarySearchDoc(baseSearchDoc()).lastSyncedAt).toEqual(expect.any(String))
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('normaliseOpenLibraryWork', () => {
|
|
85
|
+
it('strips /works/ from the key to derive the id', () => {
|
|
86
|
+
const record = normaliseOpenLibraryWork(baseWork({ key: '/works/OL82563W' }))
|
|
87
|
+
expect(record.providerId).toBe('OL82563W')
|
|
88
|
+
expect(record.id).toBe('openlibrary:work:OL82563W')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('uses the description string directly when it is a string', () => {
|
|
92
|
+
const record = normaliseOpenLibraryWork(baseWork({ description: 'Plain text description' }))
|
|
93
|
+
expect(record.description).toBe('Plain text description')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('extracts description.value when description is an object', () => {
|
|
97
|
+
const record = normaliseOpenLibraryWork(
|
|
98
|
+
baseWork({ description: { value: 'Wrapped description' } })
|
|
99
|
+
)
|
|
100
|
+
expect(record.description).toBe('Wrapped description')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('leaves description undefined when absent', () => {
|
|
104
|
+
expect(normaliseOpenLibraryWork(baseWork()).description).toBeUndefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('builds coverUrl from covers[0]', () => {
|
|
108
|
+
const record = normaliseOpenLibraryWork(baseWork({ covers: [777, 888] }))
|
|
109
|
+
expect(record.coverUrl).toBe(openLibraryCoverUrl(777))
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('normaliseOpenLibraryEdition', () => {
|
|
114
|
+
it('strips /books/ from the key to derive the id', () => {
|
|
115
|
+
const record = normaliseOpenLibraryEdition(baseEdition({ key: '/books/OL456M' }))
|
|
116
|
+
expect(record.providerId).toBe('OL456M')
|
|
117
|
+
expect(record.id).toBe('openlibrary:edition:OL456M')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('reads isbn10/isbn13 from the first entries of isbn_10/isbn_13', () => {
|
|
121
|
+
const record = normaliseOpenLibraryEdition(
|
|
122
|
+
baseEdition({ isbn_10: ['0930289232'], isbn_13: ['9780930289232', '9999999999999'] })
|
|
123
|
+
)
|
|
124
|
+
expect(record.identifiers).toMatchObject({ isbn10: '0930289232', isbn13: '9780930289232' })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('leaves isbn10/isbn13 undefined when absent', () => {
|
|
128
|
+
const record = normaliseOpenLibraryEdition(baseEdition())
|
|
129
|
+
expect(record.identifiers?.isbn10).toBeUndefined()
|
|
130
|
+
expect(record.identifiers?.isbn13).toBeUndefined()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('builds coverUrl from covers[0]', () => {
|
|
134
|
+
const record = normaliseOpenLibraryEdition(baseEdition({ covers: [111] }))
|
|
135
|
+
expect(record.coverUrl).toBe(openLibraryCoverUrl(111))
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('takes publisher from publishers[0]', () => {
|
|
139
|
+
const record = normaliseOpenLibraryEdition(baseEdition({ publishers: ['DC Comics', 'Vertigo'] }))
|
|
140
|
+
expect(record.publisher).toBe('DC Comics')
|
|
141
|
+
})
|
|
142
|
+
})
|