feat(library): search a book on Goodreads from the library and reader (#4543) (#4548)

Adds a quick "Search on Goodreads" action so readers can jump straight to
Goodreads to track a book instead of retyping the title there.

- Library: a Goodreads button in the Book Details view (works on web,
  desktop and mobile) searching the book's title + author, plus a
  "Search on Goodreads" item in the desktop right-click context menu.
- Reader: Goodreads is added as a built-in web-search provider so
  highlighted text (e.g. a short-story title inside a magazine) can be
  looked up on Goodreads. Disabled by default like the other built-ins;
  enable it in Settings -> Dictionaries.

Both surfaces are used because the native context menu is desktop-only;
the Book Details button covers web and mobile. Adds a shared
openExternalUrl() helper and translates "Search on Goodreads" across all
locales (the Goodreads brand name is kept verbatim).

Closes #4543

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-12 14:30:17 +08:00
committed by GitHub
parent cfe2bb9116
commit ceddee3793
45 changed files with 212 additions and 39 deletions
@@ -1839,5 +1839,6 @@
"Next word (.)": "الكلمة التالية (.)",
"Start Delay": "تأخير البدء",
"Off": "إيقاف",
"Look up": "بحث"
"Look up": "بحث",
"Search on Goodreads": "البحث في Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "পরবর্তী শব্দ (.)",
"Start Delay": "শুরুর বিলম্ব",
"Off": "বন্ধ",
"Look up": "খুঁজুন"
"Look up": "খুঁজুন",
"Search on Goodreads": "Goodreads-এ অনুসন্ধান করুন"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "ཚིག་རྗེས་མ། (.)",
"Start Delay": "འགོ་འཛུགས་ཕྱིར་འགྱངས།",
"Off": "ཁ་རྒྱག",
"Look up": "འཚོལ་བ།"
"Look up": "འཚོལ་བ།",
"Search on Goodreads": "Goodreads ནང་འཚོལ།"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "Nächstes Wort (.)",
"Start Delay": "Startverzögerung",
"Off": "Aus",
"Look up": "Nachschlagen"
"Look up": "Nachschlagen",
"Search on Goodreads": "Auf Goodreads suchen"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "Επόμενη λέξη (.)",
"Start Delay": "Καθυστέρηση έναρξης",
"Off": "Ανενεργό",
"Look up": "Αναζήτηση"
"Look up": "Αναζήτηση",
"Search on Goodreads": "Αναζήτηση στο Goodreads"
}
@@ -1740,5 +1740,6 @@
"Next word (.)": "Palabra siguiente (.)",
"Start Delay": "Retardo de inicio",
"Off": "Desactivado",
"Look up": "Buscar"
"Look up": "Buscar",
"Search on Goodreads": "Buscar en Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "واژهٔ بعدی (.)",
"Start Delay": "تأخیر شروع",
"Off": "خاموش",
"Look up": "جستجو"
"Look up": "جستجو",
"Search on Goodreads": "جستجو در Goodreads"
}
@@ -1740,5 +1740,6 @@
"Next word (.)": "Mot suivant (.)",
"Start Delay": "Délai de démarrage",
"Off": "Désactivé",
"Look up": "Rechercher"
"Look up": "Rechercher",
"Search on Goodreads": "Rechercher sur Goodreads"
}
@@ -1740,5 +1740,6 @@
"Next word (.)": "מילה הבאה (.)",
"Start Delay": "השהיית התחלה",
"Off": "כבוי",
"Look up": "חיפוש"
"Look up": "חיפוש",
"Search on Goodreads": "חיפוש ב-Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "अगला शब्द (.)",
"Start Delay": "आरंभ विलंब",
"Off": "बंद",
"Look up": "खोजें"
"Look up": "खोजें",
"Search on Goodreads": "Goodreads पर खोजें"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "Következő szó (.)",
"Start Delay": "Indítási késleltetés",
"Off": "Ki",
"Look up": "Keresés"
"Look up": "Keresés",
"Search on Goodreads": "Keresés a Goodreadson"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "Kata berikutnya (.)",
"Start Delay": "Tunda mulai",
"Off": "Mati",
"Look up": "Cari"
"Look up": "Cari",
"Search on Goodreads": "Cari di Goodreads"
}
@@ -1740,5 +1740,6 @@
"Next word (.)": "Parola successiva (.)",
"Start Delay": "Ritardo di avvio",
"Off": "Disattivato",
"Look up": "Cerca"
"Look up": "Cerca",
"Search on Goodreads": "Cerca su Goodreads"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "次の単語 (.)",
"Start Delay": "開始遅延",
"Off": "オフ",
"Look up": "調べる"
"Look up": "調べる",
"Search on Goodreads": "Goodreadsで検索"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "다음 단어 (.)",
"Start Delay": "시작 지연",
"Off": "끄기",
"Look up": "찾기"
"Look up": "찾기",
"Search on Goodreads": "Goodreads에서 검색"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "Perkataan seterusnya (.)",
"Start Delay": "Lengah mula",
"Off": "Mati",
"Look up": "Cari"
"Look up": "Cari",
"Search on Goodreads": "Cari di Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "Volgend woord (.)",
"Start Delay": "Startvertraging",
"Off": "Uit",
"Look up": "Opzoeken"
"Look up": "Opzoeken",
"Search on Goodreads": "Zoeken op Goodreads"
}
@@ -1773,5 +1773,6 @@
"Next word (.)": "Następne słowo (.)",
"Start Delay": "Opóźnienie startu",
"Off": "Wył.",
"Look up": "Wyszukaj"
"Look up": "Wyszukaj",
"Search on Goodreads": "Szukaj w Goodreads"
}
@@ -1740,5 +1740,6 @@
"Next word (.)": "Próxima palavra (.)",
"Start Delay": "Atraso de início",
"Off": "Desligado",
"Look up": "Pesquisar"
"Look up": "Pesquisar",
"Search on Goodreads": "Pesquisar no Goodreads"
}
@@ -1740,5 +1740,6 @@
"Next word (.)": "Próxima palavra (.)",
"Start Delay": "Atraso de início",
"Off": "Desligado",
"Look up": "Pesquisar"
"Look up": "Pesquisar",
"Search on Goodreads": "Pesquisar no Goodreads"
}
@@ -1740,5 +1740,6 @@
"Next word (.)": "Cuvântul următor (.)",
"Start Delay": "Întârziere la pornire",
"Off": "Dezactivat",
"Look up": "Caută"
"Look up": "Caută",
"Search on Goodreads": "Caută pe Goodreads"
}
@@ -1773,5 +1773,6 @@
"Next word (.)": "Следующее слово (.)",
"Start Delay": "Задержка старта",
"Off": "Выкл.",
"Look up": "Найти"
"Look up": "Найти",
"Search on Goodreads": "Искать в Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "ඊළඟ වචනය (.)",
"Start Delay": "ආරම්භක ප්‍රමාදය",
"Off": "අක්‍රිය",
"Look up": "සොයන්න"
"Look up": "සොයන්න",
"Search on Goodreads": "Goodreads හි සොයන්න"
}
@@ -1773,5 +1773,6 @@
"Next word (.)": "Naslednja beseda (.)",
"Start Delay": "Zakasnitev začetka",
"Off": "Izklopljeno",
"Look up": "Poišči"
"Look up": "Poišči",
"Search on Goodreads": "Išči v Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "Nästa ord (.)",
"Start Delay": "Startfördröjning",
"Off": "Av",
"Look up": "Slå upp"
"Look up": "Slå upp",
"Search on Goodreads": "Sök på Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "அடுத்த சொல் (.)",
"Start Delay": "தொடக்க தாமதம்",
"Off": "அணை",
"Look up": "தேடு"
"Look up": "தேடு",
"Search on Goodreads": "Goodreads இல் தேடு"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "คำถัดไป (.)",
"Start Delay": "หน่วงเวลาเริ่มต้น",
"Off": "ปิด",
"Look up": "ค้นหา"
"Look up": "ค้นหา",
"Search on Goodreads": "ค้นหาใน Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "Sonraki kelime (.)",
"Start Delay": "Başlangıç gecikmesi",
"Off": "Kapalı",
"Look up": "Ara"
"Look up": "Ara",
"Search on Goodreads": "Goodreads'te ara"
}
@@ -1773,5 +1773,6 @@
"Next word (.)": "Наступне слово (.)",
"Start Delay": "Затримка старту",
"Off": "Вимк.",
"Look up": "Знайти"
"Look up": "Знайти",
"Search on Goodreads": "Шукати в Goodreads"
}
@@ -1707,5 +1707,6 @@
"Next word (.)": "Keyingi soʻz (.)",
"Start Delay": "Boshlash kechikishi",
"Off": "Oʻchiq",
"Look up": "Qidirish"
"Look up": "Qidirish",
"Search on Goodreads": "Goodreads'da qidirish"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "Từ tiếp theo (.)",
"Start Delay": "Độ trễ bắt đầu",
"Off": "Tắt",
"Look up": "Tra cứu"
"Look up": "Tra cứu",
"Search on Goodreads": "Tìm trên Goodreads"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "下一个词 (.)",
"Start Delay": "开始延迟",
"Off": "关闭",
"Look up": "查询"
"Look up": "查询",
"Search on Goodreads": "在 Goodreads 中搜索"
}
@@ -1674,5 +1674,6 @@
"Next word (.)": "下一個詞 (.)",
"Start Delay": "開始延遲",
"Off": "關閉",
"Look up": "查詢"
"Look up": "查詢",
"Search on Goodreads": "在 Goodreads 搜尋"
}
@@ -22,6 +22,7 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'showDetails',
'showInFinder',
'searchGoodreads',
'upload',
'share',
'delete',
@@ -37,6 +38,7 @@ describe('getBookContextMenuItemIds', () => {
'clearStatus',
'showDetails',
'showInFinder',
'searchGoodreads',
'upload',
'share',
'delete',
@@ -52,6 +54,7 @@ describe('getBookContextMenuItemIds', () => {
'clearStatus',
'showDetails',
'showInFinder',
'searchGoodreads',
'upload',
'share',
'delete',
@@ -66,6 +69,7 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'showDetails',
'showInFinder',
'searchGoodreads',
'download',
'share',
'delete',
@@ -80,6 +84,7 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'showDetails',
'showInFinder',
'searchGoodreads',
'delete',
]);
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
import {
BUILTIN_WEB_SEARCHES,
getBuiltinWebSearch,
substituteUrlTemplate,
} from '@/services/dictionaries/webSearchTemplates';
describe('Goodreads built-in web search', () => {
it('is registered as a built-in template', () => {
const tpl = getBuiltinWebSearch(BUILTIN_WEB_SEARCH_IDS.goodreads);
expect(tpl).toBeDefined();
expect(tpl?.name).toBe('Goodreads');
});
it('is included in the built-in list', () => {
const ids = BUILTIN_WEB_SEARCHES.map((t) => t.id);
expect(ids).toContain(BUILTIN_WEB_SEARCH_IDS.goodreads);
});
it('produces a Goodreads search URL when the word is substituted', () => {
const tpl = getBuiltinWebSearch(BUILTIN_WEB_SEARCH_IDS.goodreads)!;
expect(substituteUrlTemplate(tpl.urlTemplate, 'The Dispossessed')).toBe(
'https://www.goodreads.com/search?q=The%20Dispossessed',
);
});
});
@@ -652,6 +652,7 @@ describe('customDictionaryStore — loadCustomDictionaries reconciliation', () =
'web:builtin:google',
'web:builtin:urban',
'web:builtin:merriam-webster',
'web:builtin:goodreads',
]);
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads';
import { Book } from '@/types/book';
const createBook = (overrides: Partial<Book> = {}): Book => ({
hash: 'hash-1',
format: 'EPUB',
title: 'The Left Hand of Darkness',
author: 'Ursula K. Le Guin',
createdAt: 0,
updatedAt: 0,
...overrides,
});
describe('getGoodreadsSearchUrl', () => {
it('builds a Goodreads search URL with the query percent-encoded', () => {
expect(getGoodreadsSearchUrl('The Left Hand of Darkness')).toBe(
'https://www.goodreads.com/search?q=The%20Left%20Hand%20of%20Darkness',
);
});
it('trims surrounding whitespace before encoding', () => {
expect(getGoodreadsSearchUrl(' Dune ')).toBe('https://www.goodreads.com/search?q=Dune');
});
it('encodes reserved characters so the query survives intact', () => {
expect(getGoodreadsSearchUrl('Cat & Mouse')).toBe(
'https://www.goodreads.com/search?q=Cat%20%26%20Mouse',
);
});
});
describe('getBookGoodreadsQuery', () => {
it('combines title and author', () => {
expect(getBookGoodreadsQuery(createBook())).toBe('The Left Hand of Darkness Ursula K. Le Guin');
});
it('falls back to the title alone when the author is empty', () => {
expect(getBookGoodreadsQuery(createBook({ author: '' }))).toBe('The Left Hand of Darkness');
});
it('trims each part', () => {
expect(getBookGoodreadsQuery(createBook({ title: ' Dune ', author: ' Herbert ' }))).toBe(
'Dune Herbert',
);
});
});
@@ -9,6 +9,8 @@ import { useLongPress } from '@/hooks/useLongPress';
import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { eventDispatcher } from '@/utils/event';
import { openExternalUrl } from '@/utils/open';
import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { navigateToReader, showReaderWindow } from '@/utils/nav';
@@ -271,6 +273,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
revealItemInDir(folder);
},
},
searchGoodreads: {
text: _('Search on Goodreads'),
action: async () => {
openExternalUrl(getGoodreadsSearchUrl(getBookGoodreadsQuery(book)));
},
},
download: {
text: _('Download Book'),
action: async () => {
@@ -592,6 +592,7 @@ export type BookContextMenuItemId =
| 'clearStatus'
| 'showDetails'
| 'showInFinder'
| 'searchGoodreads'
| 'download'
| 'upload'
| 'share'
@@ -612,7 +613,7 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] =
if (book.readingStatus === 'finished' || book.readingStatus === 'unread') {
ids.push('clearStatus');
}
ids.push('showDetails', 'showInFinder');
ids.push('showDetails', 'showInFinder', 'searchGoodreads');
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
// Share is offered for any local-or-uploaded book; the dialog uploads first
@@ -9,9 +9,12 @@ import {
MdExpandMore,
MdExpandLess,
} from 'react-icons/md';
import { FaGoodreads } from 'react-icons/fa';
import { Book } from '@/types/book';
import { BookMetadata } from '@/libs/document';
import { openExternalUrl } from '@/utils/open';
import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads';
import { useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useEnv } from '@/context/EnvContext';
@@ -98,6 +101,12 @@ const BookDetailView: React.FC<BookDetailViewProps> = ({
<MdOutlineEdit className='hover:fill-blue-500' />
</button>
)}
<button
onClick={() => openExternalUrl(getGoodreadsSearchUrl(getBookGoodreadsQuery(book)))}
title={_('Search on Goodreads')}
>
<FaGoodreads className='fill-base-content' />
</button>
{onDelete && (
<Dropdown
label={_('Delete Book Options')}
@@ -201,6 +201,7 @@ export const BUILTIN_WEB_SEARCH_IDS = {
google: 'web:builtin:google',
urban: 'web:builtin:urban',
merriamWebster: 'web:builtin:merriam-webster',
goodreads: 'web:builtin:goodreads',
} as const;
export type BuiltinWebSearchId =
@@ -1,11 +1,11 @@
/**
* Built-in web-search templates.
*
* Three universally-useful entries (Google, Urban Dictionary,
* Merriam-Webster). They are seeded into `providerOrder` but **disabled by
* default** users opt in from the settings list. Custom URL templates
* live in `settings.webSearches`; this file is only the immutable
* built-ins.
* A few universally-useful entries (Google, Urban Dictionary,
* Merriam-Webster, and Goodreads for looking up a selected title). They are
* seeded into `providerOrder` but **disabled by default** users opt in
* from the settings list. Custom URL templates live in
* `settings.webSearches`; this file is only the immutable built-ins.
*
* The `%WORD%` placeholder marks where the looked-up word goes; the
* provider URL-encodes it at substitution time. (GoldenDict users may know
@@ -38,6 +38,12 @@ export const BUILTIN_WEB_SEARCHES: BuiltinWebSearchTemplate[] = [
nameKey: 'Merriam-Webster',
urlTemplate: 'https://www.merriam-webster.com/dictionary/%WORD%',
},
{
id: BUILTIN_WEB_SEARCH_IDS.goodreads,
name: 'Goodreads',
nameKey: 'Goodreads',
urlTemplate: 'https://www.goodreads.com/search?q=%WORD%',
},
];
const BUILTIN_BY_ID = new Map(BUILTIN_WEB_SEARCHES.map((t) => [t.id, t]));
@@ -30,6 +30,7 @@ const BUILTIN_WEB_ORDER = [
BUILTIN_WEB_SEARCH_IDS.google,
BUILTIN_WEB_SEARCH_IDS.urban,
BUILTIN_WEB_SEARCH_IDS.merriamWebster,
BUILTIN_WEB_SEARCH_IDS.goodreads,
];
const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
@@ -49,6 +50,7 @@ const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
[BUILTIN_WEB_SEARCH_IDS.google]: false,
[BUILTIN_WEB_SEARCH_IDS.urban]: false,
[BUILTIN_WEB_SEARCH_IDS.merriamWebster]: false,
[BUILTIN_WEB_SEARCH_IDS.goodreads]: false,
},
webSearches: [],
};
+17
View File
@@ -0,0 +1,17 @@
import { Book } from '@/types/book';
const GOODREADS_SEARCH_URL = 'https://www.goodreads.com/search';
/** Build a Goodreads search URL for an arbitrary query string. */
export const getGoodreadsSearchUrl = (query: string): string =>
`${GOODREADS_SEARCH_URL}?q=${encodeURIComponent(query.trim())}`;
/**
* Compose the Goodreads search query for a book from its title and author.
* The author improves match precision; it's dropped when empty.
*/
export const getBookGoodreadsQuery = (book: Pick<Book, 'title' | 'author'>): string =>
[book.title, book.author]
.map((part) => part?.trim())
.filter(Boolean)
.join(' ');
+14
View File
@@ -1,6 +1,20 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import { isTauriAppPlatform } from '@/services/environment';
/**
* Open an external URL in the system browser. On Tauri the in-app webview
* ignores `target="_blank"`, so route through the opener plugin; on web fall
* back to `window.open`. Unlike `interceptWindowOpen`, this works without the
* reader-scoped `window.open` override being installed.
*/
export const openExternalUrl = (url: string) => {
if (isTauriAppPlatform()) {
void openUrl(url).catch((err) => console.warn('Failed to open external URL', url, err));
} else {
window.open(url, '_blank', 'noopener,noreferrer');
}
};
export const interceptWindowOpen = () => {
const windowOpen = window.open;
globalThis.open = function (