Files
readest/apps/readest-app/src/app/library/utils/libraryUtils.ts
T
2026-07-09 00:45:12 +08:00

765 lines
27 KiB
TypeScript

import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import {
LibraryGroupByType,
LibrarySecondarySortByType,
LibrarySortByType,
} from '@/types/settings';
import { formatAuthors, formatTitle } from '@/utils/book';
import { md5Fingerprint } from '@/utils/md5';
/** Valid sort types for the library */
const VALID_SORT_TYPES: LibrarySortByType[] = Object.values(LibrarySortByType);
/** Valid group by types for the library */
const VALID_GROUP_BY_TYPES: LibraryGroupByType[] = Object.values(LibraryGroupByType);
/**
* Safely cast a query parameter to LibrarySortByType with fallback.
* Returns the value if valid, otherwise returns the fallback.
*/
export const ensureLibrarySortByType = (
value: string | null | undefined,
fallback: LibrarySortByType,
): LibrarySortByType => {
if (value && VALID_SORT_TYPES.includes(value as LibrarySortByType)) {
return value as LibrarySortByType;
}
return fallback;
};
/**
* Safely cast a query parameter to LibrarySecondarySortByType with fallback.
* Accepts any valid primary sort type plus the literal 'none'.
*/
export const ensureLibrarySecondarySortByType = (
value: string | null | undefined,
fallback: LibrarySecondarySortByType,
): LibrarySecondarySortByType => {
if (value === 'none') return 'none';
if (value && VALID_SORT_TYPES.includes(value as LibrarySortByType)) {
return value as LibrarySortByType;
}
return fallback;
};
/**
* Resolve the *effective* primary sort key, applying smart defaults derived
* from the current `groupBy`. The stored `librarySortBy` is left unchanged;
* this only substitutes a sensible implicit default when the user is still on
* auto, so grouping by Series lands an alphabetical-by-series-name listing
* without any extra clicks.
*
* - !isAuto -> use stored as-is
* - groupBy=Series and isAuto -> Series
* - everything else -> stored
*/
export const resolveEffectivePrimarySort = (
stored: LibrarySortByType,
groupBy: LibraryGroupByType,
isAuto: boolean,
): LibrarySortByType => {
if (!isAuto) return stored;
if (groupBy === LibraryGroupByType.Series) return LibrarySortByType.Series;
return stored;
};
/**
* Resolve the *effective* secondary sort key, applying smart defaults derived
* from the current `groupBy`. The stored secondary stays whatever the user
* picked; this only substitutes 'none' with a sensible implicit default so
* users get useful behavior out of the box (e.g. drilling into an Author
* group lands a series-ordered list without any extra clicks).
*
* - explicit secondary (any non-'none') -> use as-is
* - groupBy=Author and stored='none' -> Series
* - everything else -> 'none'
*
* groupBy=Series doesn't default to anything because `createWithinGroupSorter`
* already orders by `seriesIndex` for series groups.
*/
export const resolveEffectiveSecondarySort = (
secondary: LibrarySecondarySortByType,
groupBy: LibraryGroupByType,
): LibrarySecondarySortByType => {
if (secondary !== 'none') return secondary;
if (groupBy === LibraryGroupByType.Author) return LibrarySortByType.Series;
return 'none';
};
/**
* Safely cast a query parameter to LibraryGroupByType with fallback.
* Returns the value if valid, otherwise returns the fallback.
*/
export const ensureLibraryGroupByType = (
value: string | null | undefined,
fallback: LibraryGroupByType,
): LibraryGroupByType => {
if (value && VALID_GROUP_BY_TYPES.includes(value as LibraryGroupByType)) {
return value as LibraryGroupByType;
}
return fallback;
};
/**
* Find a group by ID from a list of bookshelf items.
* Works for both manual groups and series/author groups.
*/
export const findGroupById = (
items: (Book | BooksGroup)[],
groupId: string,
): BooksGroup | undefined => {
return items.find((item): item is BooksGroup => 'books' in item && item.id === groupId);
};
/**
* Get the display name for a group, useful for breadcrumbs.
*/
export const getGroupDisplayName = (
items: (Book | BooksGroup)[],
groupId: string,
): string | undefined => {
const group = findGroupById(items, groupId);
return group?.displayName || group?.name;
};
/**
* Expand a list of selection ids (book hashes or group ids from the rendered
* bookshelf) into the unique book hashes those ids represent.
*
* Group ids resolve to every (non-soft-deleted) book in the group's visible
* rollup — `generateBookshelfItems` already folds nested-folder books into
* their top-level group, so the rendered `BooksGroup.books` is the source of
* truth. Standalone book hashes (and any unknown ids) pass through unchanged,
* letting callers like the bookshelf delete flow collect the right set up
* front instead of re-deriving it later.
*/
export const expandBookshelfSelection = (ids: string[], items: (Book | BooksGroup)[]): string[] => {
const hashes = new Set<string>();
for (const id of ids) {
const group = findGroupById(items, id);
if (group) {
for (const book of group.books) {
if (!book.deletedAt) hashes.add(book.hash);
}
} else {
hashes.add(id);
}
}
return [...hashes];
};
// Calibre custom column names and values, flattened for searching (#4811).
const getCalibreColumnsText = (item: Book) =>
(item.metadata?.calibreColumns ?? [])
.map(({ name, value }) => `${name} ${Array.isArray(value) ? value.join(' ') : value}`)
.join(' ');
export const createBookFilter = (queryTerm: string | null) => (item: Book) => {
if (!queryTerm) return true;
if (item.deletedAt) return false;
let searchTerm: RegExp;
try {
searchTerm = new RegExp(queryTerm, 'i');
} catch {
const lowerQuery = queryTerm.toLowerCase();
const title = formatTitle(item.title).toLowerCase();
const authors = formatAuthors(item.author).toLowerCase();
return (
title.includes(lowerQuery) ||
authors.includes(lowerQuery) ||
item.format.toLowerCase().includes(lowerQuery) ||
(item.groupName && item.groupName.toLowerCase().includes(lowerQuery)) ||
(item.metadata?.description &&
item.metadata.description.toLowerCase().includes(lowerQuery)) ||
getCalibreColumnsText(item).toLowerCase().includes(lowerQuery)
);
}
const title = formatTitle(item.title);
const authors = formatAuthors(item.author);
return (
searchTerm.test(title) ||
searchTerm.test(authors) ||
searchTerm.test(item.format) ||
(item.groupName && searchTerm.test(item.groupName)) ||
(item.metadata?.description && searchTerm.test(item.metadata?.description)) ||
searchTerm.test(getCalibreColumnsText(item))
);
};
/**
* Fraction of the book that has been read, in [0, 1]. `progress` is a 1-based
* `[current, total]` page pair; books that have never been opened have no
* progress and read 0 (they sort to the unread end).
*/
const getBookReadRatio = (book: Book): number => {
const [current, total] = book.progress ?? [];
if (!current || !total || total <= 0) return 0;
return current / total;
};
const compareBookByKey = (a: Book, b: Book, sortBy: string, uiLanguage: string): number => {
switch (sortBy) {
case LibrarySortByType.Title: {
const aTitle = formatTitle(a.title);
const bTitle = formatTitle(b.title);
return aTitle.localeCompare(bTitle, uiLanguage || navigator.language);
}
case LibrarySortByType.Author: {
const aAuthors = formatAuthors(a.author, a?.primaryLanguage || 'en', true);
const bAuthors = formatAuthors(b.author, b?.primaryLanguage || 'en', true);
return aAuthors.localeCompare(bAuthors, uiLanguage || navigator.language);
}
case LibrarySortByType.Updated:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
case LibrarySortByType.Created:
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
case LibrarySortByType.Format:
return a.format.localeCompare(b.format, uiLanguage || navigator.language);
case LibrarySortByType.Progress:
return getBookReadRatio(a) - getBookReadRatio(b);
case LibrarySortByType.Series: {
// Group by series name first so books of the same series stay consecutive,
// then order within a series by index. Comparing index alone would interleave
// series (all #1s, then all #2s) when this key is used as a secondary sort.
const aSeries = a.metadata?.series || '';
const bSeries = b.metadata?.series || '';
const bySeries = aSeries.localeCompare(bSeries, uiLanguage || navigator.language);
if (bySeries !== 0) return bySeries;
return (a.metadata?.seriesIndex || 0) - (b.metadata?.seriesIndex || 0);
}
case LibrarySortByType.Published: {
const aPublished = a.metadata?.published || '0001-01-01';
const bPublished = b.metadata?.published || '0001-01-01';
// Handle cases where published date might not exist
if (!aPublished && !bPublished) return 0;
if (!aPublished) return 1; // Books without published date go to the end
if (!bPublished) return -1;
// Try to parse dates - handle various date formats
const aDate = new Date(aPublished).getTime();
const bDate = new Date(bPublished).getTime();
// If dates are invalid (NaN), fall back to string comparison
if (isNaN(aDate) && isNaN(bDate)) {
return aPublished.localeCompare(bPublished, uiLanguage || navigator.language);
}
if (isNaN(aDate)) return 1;
if (isNaN(bDate)) return -1;
return aDate - bDate;
}
default:
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime();
}
};
/**
* @param secondarySortBy - Optional tiebreaker key applied when the primary
* comparison returns 0. Pass `'none'` (or omit) to disable. A Series secondary
* orders by series name then index; ties on both fall through to the primary tie.
*/
export const createBookSorter =
(sortBy: string, uiLanguage: string, secondarySortBy: LibrarySecondarySortByType = 'none') =>
(a: Book, b: Book): number => {
const primary = compareBookByKey(a, b, sortBy, uiLanguage);
if (primary !== 0 || secondarySortBy === 'none') return primary;
return compareBookByKey(a, b, secondarySortBy, uiLanguage);
};
/**
* A book counts as "read" once it has reading progress. Importing a book sets
* timestamps but never `progress`; only opening it does. Gating on this keeps
* freshly-added-but-unopened books off the shelf.
*/
const hasBeenRead = (book: Book): boolean => book.progress != null;
/**
* Pick the books for the recently-read shelf: most-recently-read first, capped
* at `count`. Recency uses `updatedAt` (the library's "Updated" sort key) so the
* row matches the app's existing sort convention. NB: `updatedAt` is last-modified
* (also bumped by status/metadata edits and sync), not strictly last-read.
* Independent of the main shelf's sort/grouping — always a flat, recency slice.
*/
export const selectRecentShelfBooks = (books: Book[], count: number): Book[] => {
const byRecency = createBookSorter(LibrarySortByType.Updated, '');
return books
.filter((book) => !book.deletedAt && hasBeenRead(book))
.sort((a, b) => -byRecency(a, b))
.slice(0, count);
};
/**
* Build a `groupName -> max(book.updatedAt)` map for all groups touched by
* the given books. Each book bumps both its direct group and every ancestor
* group along its path (e.g. a book in "Literature/Fiction" also bumps
* "Literature"), so parent groups don't sink just because their direct
* members are stale.
*/
export const buildGroupNameUpdatedAt = (books: Book[]): Map<string, number> => {
const map = new Map<string, number>();
for (const book of books) {
if (!book.groupName || !book.updatedAt) continue;
let path: string | undefined = book.groupName;
while (path) {
const prev = map.get(path) ?? 0;
if (book.updatedAt > prev) map.set(path, book.updatedAt);
const slash = path.lastIndexOf('/');
path = slash === -1 ? undefined : path.slice(0, slash);
}
}
return map;
};
export const getBreadcrumbs = (currentPath: string) => {
if (!currentPath) return [];
const segments = currentPath.split('/');
return segments.map((segment, index) => ({
name: segment,
path: segments.slice(0, index + 1).join('/'),
}));
};
/**
* Parse a combined author string into individual author names.
* Handles common separators like ", ", " & ", " and ".
*/
export const parseAuthors = (authorString: string): string[] => {
if (!authorString || !authorString.trim()) {
return [];
}
// Split by common separators: comma, ampersand, "and"
// Use regex to handle variations with different spacing
const authors = authorString
.split(/\s*(?:,|&|\band\b)\s*/i)
.map((author) => author.trim())
.filter((author) => author.length > 0);
return authors;
};
/**
* Create groups from books based on the groupBy setting.
* Returns a mix of BooksGroup and ungrouped Book items.
*/
export const createBookGroups = (
books: Book[],
groupBy: LibraryGroupByType,
): (Book | BooksGroup)[] => {
// Filter out deleted books
const activeBooks = books.filter((book) => !book.deletedAt);
if (groupBy === LibraryGroupByType.None) {
return activeBooks;
}
if (groupBy === LibraryGroupByType.Series) {
return createSeriesGroups(activeBooks);
}
if (groupBy === LibraryGroupByType.Author) {
return createAuthorGroups(activeBooks);
}
// 'group' mode is handled separately by generateBookshelfItems
return activeBooks;
};
/**
* Group books by series metadata.
* Books without series appear as individual items.
*/
const createSeriesGroups = (books: Book[]): (Book | BooksGroup)[] => {
const seriesMap = new Map<string, Book[]>();
const ungroupedBooks: Book[] = [];
for (const book of books) {
const seriesName = book.metadata?.series?.trim();
if (seriesName) {
const existing = seriesMap.get(seriesName);
if (existing) {
existing.push(book);
} else {
seriesMap.set(seriesName, [book]);
}
} else {
ungroupedBooks.push(book);
}
}
const groups: BooksGroup[] = Array.from(seriesMap.entries()).map(([seriesName, seriesBooks]) => ({
id: md5Fingerprint(`series:${seriesName}`),
name: seriesName,
displayName: seriesName,
books: seriesBooks,
updatedAt: Math.max(...seriesBooks.map((b) => b.updatedAt)),
}));
return [...groups, ...ungroupedBooks];
};
/**
* Group books by author.
* Books with multiple authors appear in ALL matching author groups.
* Books without author appear as individual items.
*/
const createAuthorGroups = (books: Book[]): (Book | BooksGroup)[] => {
const authorMap = new Map<string, Book[]>();
const ungroupedBooks: Book[] = [];
for (const book of books) {
const authorString = book.author?.trim();
if (!authorString) {
ungroupedBooks.push(book);
continue;
}
const authors = parseAuthors(authorString);
if (authors.length === 0) {
ungroupedBooks.push(book);
continue;
}
// Add book to each author's group
for (const author of authors) {
const existing = authorMap.get(author);
if (existing) {
existing.push(book);
} else {
authorMap.set(author, [book]);
}
}
}
const groups: BooksGroup[] = Array.from(authorMap.entries()).map(([authorName, authorBooks]) => ({
id: md5Fingerprint(`author:${authorName}`),
name: authorName,
displayName: authorName,
books: authorBooks,
updatedAt: Math.max(...authorBooks.map((b) => b.updatedAt)),
}));
return [...groups, ...ungroupedBooks];
};
/**
* Create a sorter for books within a group.
* For series groups: sort by seriesIndex first (always ascending), then by global sort for items without index.
* For other groupings: when a secondary key is supplied, sort by secondary key first (always ascending),
* with the primary global sort as tiebreaker. Without secondary, follow global sort setting.
* @param sortAscending - When true (default), sort direction is ascending. Series index and the
* secondary key are always ascending regardless of this flag; the flag affects the fallback /
* primary tiebreaker only.
* @param secondarySortBy - When non-'none', acts as the *primary* within-group ordering for
* non-series groupings (matches the user's mental model: "group by author, then sort by series"
* should land series order inside each author).
*/
export const createWithinGroupSorter =
(
groupBy: LibraryGroupByType,
sortBy: LibrarySortByType,
uiLanguage: string,
sortAscending: boolean = true,
secondarySortBy: LibrarySecondarySortByType = 'none',
) =>
(a: Book, b: Book): number => {
const sortDirection = sortAscending ? 1 : -1;
if (groupBy === LibraryGroupByType.Series) {
const aIndex = a.metadata?.seriesIndex;
const bIndex = b.metadata?.seriesIndex;
// Both have series index - always sort ascending by index
if (aIndex != null && bIndex != null) {
return aIndex - bIndex;
}
// Only one has series index - the one with index comes first
if (aIndex != null) return -1;
if (bIndex != null) return 1;
// Neither has series index - fall back to global sort with direction
return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection;
}
// For author and other non-series groupings: when a secondary key is provided,
// use it as the within-group primary order with the global key as tiebreaker.
if (secondarySortBy !== 'none') {
const bySecondary = compareBookByKey(a, b, secondarySortBy, uiLanguage);
if (bySecondary !== 0) return bySecondary;
return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection;
}
return createBookSorter(sortBy, uiLanguage)(a, b) * sortDirection;
};
/**
* Get the sort value from a book for comparison with groups.
*/
export const getBookSortValue = (book: Book, sortBy: LibrarySortByType): number | string => {
switch (sortBy) {
case LibrarySortByType.Title:
return formatTitle(book.title);
case LibrarySortByType.Author:
return formatAuthors(book.author, book?.primaryLanguage || 'en', true);
case LibrarySortByType.Updated:
return book.updatedAt;
case LibrarySortByType.Created:
return book.createdAt;
case LibrarySortByType.Format:
return book.format;
case LibrarySortByType.Progress:
return getBookReadRatio(book);
case LibrarySortByType.Published: {
const published = book.metadata?.published;
if (!published) return 0;
const publishedTime = new Date(published).getTime();
return isNaN(publishedTime) ? 0 : publishedTime;
}
default:
return book.updatedAt;
}
};
/**
* Get the aggregate sort value from a group for sorting groups.
*/
export const getGroupSortValue = (
group: BooksGroup,
sortBy: LibrarySortByType,
groupBy?: LibraryGroupByType,
): number | string => {
const books = group.books;
if (books.length === 0) {
return sortBy === LibrarySortByType.Title ||
sortBy === LibrarySortByType.Series ||
sortBy === LibrarySortByType.Author ||
sortBy === LibrarySortByType.Format
? group.name
: 0;
}
switch (sortBy) {
case LibrarySortByType.Title:
case LibrarySortByType.Series:
case LibrarySortByType.Format:
return group.name;
case LibrarySortByType.Author: {
if (groupBy === LibraryGroupByType.Author) {
// Author group: format the group name (single author) with last-name-first
return formatAuthors(group.name, 'en', true);
}
if (groupBy === LibraryGroupByType.Series) {
// Series group: use the first book's author for sorting
const firstBook = books[0]!;
return formatAuthors(firstBook.author, firstBook.primaryLanguage || 'en', true);
}
// Custom/other groups: fall back to group name
return group.name;
}
case LibrarySortByType.Updated:
// Return the most recent updatedAt
return Math.max(...books.map((b) => b.updatedAt));
case LibrarySortByType.Created:
// Return the most recent createdAt
return Math.max(...books.map((b) => b.createdAt));
case LibrarySortByType.Progress:
// Return the most-progressed book's read ratio
return Math.max(...books.map((b) => getBookReadRatio(b)));
case LibrarySortByType.Published: {
// Return the most recent published date
const publishedDates = books
.map((b) => b.metadata?.published)
.filter((d): d is string => !!d)
.map((d) => new Date(d).getTime())
.filter((t) => !isNaN(t));
return publishedDates.length > 0 ? Math.max(...publishedDates) : 0;
}
default:
return Math.max(...books.map((b) => b.updatedAt));
}
};
/**
* Compare two sort values (string or number) for sorting.
*/
export const compareSortValues = (
aValue: number | string,
bValue: number | string,
uiLanguage: string,
): number => {
// String comparison for text-based sorts
if (typeof aValue === 'string' && typeof bValue === 'string') {
return aValue.localeCompare(bValue, uiLanguage || navigator.language);
}
// Numeric comparison for date-based sorts
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue;
}
return 0;
};
/**
* Create a sorter for groups themselves based on sort criteria.
*/
export const createGroupSorter =
(sortBy: LibrarySortByType, uiLanguage: string, groupBy?: LibraryGroupByType) =>
(a: BooksGroup, b: BooksGroup): number => {
const aValue = getGroupSortValue(a, sortBy, groupBy);
const bValue = getGroupSortValue(b, sortBy, groupBy);
// String comparison for text-based sorts
if (typeof aValue === 'string' && typeof bValue === 'string') {
return aValue.localeCompare(bValue, uiLanguage || navigator.language);
}
// Numeric comparison for date-based sorts
if (typeof aValue === 'number' && typeof bValue === 'number') {
return aValue - bValue;
}
return 0;
};
export type BookContextMenuItemId =
| 'select'
| 'group'
| 'markFinished'
| 'markUnread'
| 'markAbandoned'
| 'clearStatus'
| 'showDetails'
| 'bilingual'
| 'showInFinder'
| 'searchGoodreads'
| 'download'
| 'upload'
| 'share'
| 'delete';
/**
* Build a new Book with an explicit reading status. Stamps both `updatedAt`
* (so the library sync picks it up) and `readingStatusUpdatedAt` (so the
* field-level merge resolves status independently of progress). Use this for
* every deliberate status edit so the timestamp is never forgotten.
*/
export const withReadingStatus = (book: Book, status: ReadingStatus | undefined): Book => {
const now = Date.now();
return { ...book, readingStatus: status, readingStatusUpdatedAt: now, updatedAt: now };
};
type ReadingStatusFields = Pick<Book, 'readingStatus' | 'readingStatusUpdatedAt'>;
/**
* Field-level last-writer-wins for reading status: return whichever side's
* status was set more recently (ties → `a`). Missing timestamp = epoch 0.
* The book row's `updatedAt` is dominated by page-turn progress, so status
* must be resolved by its own timestamp or progress would clobber it.
*/
export const pickFresherReadingStatus = (
a: ReadingStatusFields,
b: ReadingStatusFields,
): ReadingStatusFields => {
const at = (x: ReadingStatusFields) => x.readingStatusUpdatedAt ?? 0;
const winner = at(a) >= at(b) ? a : b;
return {
readingStatus: winner.readingStatus,
readingStatusUpdatedAt: winner.readingStatusUpdatedAt,
};
};
type CoverFields = Pick<Book, 'coverHash' | 'coverUpdatedAt'>;
type CoverSyncFields = Pick<
Book,
'coverHash' | 'coverUpdatedAt' | 'coverDownloadedAt' | 'deletedAt' | 'uploadedAt'
>;
const coverMs = (t?: number | null) => t ?? 0;
/**
* Decide whether a peer should (re)download a book's cover from the cloud
* (issue #4544). True when the synced book is in the cloud AND either:
* - this device has never fetched the cover (first download), or
* - a newer cover edit exists (synced `coverUpdatedAt` strictly newer) whose
* content hash differs from the local one.
*
* Gating on `coverUpdatedAt` (not just the hash) prevents two failure modes:
* - churn: once a device adopts the synced `coverUpdatedAt` after downloading,
* the comparison stops firing on every subsequent sync;
* - the unpushed-local-edit race: a device that just edited its cover (newer
* local `coverUpdatedAt`) is not made to overwrite it with the stale cloud
* copy before its own push lands.
*/
export const needsCoverRefresh = (local: CoverSyncFields, synced: CoverSyncFields): boolean => {
if (synced.deletedAt || !synced.uploadedAt) return false;
if (!local.coverDownloadedAt) return true; // first download
if (!synced.coverHash) return false; // nothing to compare (legacy book)
if (coverMs(synced.coverUpdatedAt) <= coverMs(local.coverUpdatedAt)) return false;
return synced.coverHash !== local.coverHash;
};
/**
* Field-level last-writer-wins for the cover, by `coverUpdatedAt` (ties →
* `local`, which already holds the file). Mirrors {@link pickFresherReadingStatus}:
* the row's `updatedAt` is dominated by page-turn progress, so the cover must be
* resolved by its own timestamp or progress would clobber a cover edit.
*/
export const pickFresherCover = (local: CoverFields, synced: CoverFields): CoverFields =>
coverMs(synced.coverUpdatedAt) > coverMs(local.coverUpdatedAt)
? { coverHash: synced.coverHash, coverUpdatedAt: synced.coverUpdatedAt }
: { coverHash: local.coverHash, coverUpdatedAt: local.coverUpdatedAt };
/**
* Resolve the ordered list of context-menu item ids for a book from its state.
*
* The native menu MUST be built from this list in a single `Menu.new({ items })`
* call. Appending items one at a time with un-awaited `Menu.append()` promises
* races on the Tauri IPC boundary, so the items land in a non-deterministic
* order and the menu appears to shuffle on every open (issue #4389).
*/
export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] => {
const ids: BookContextMenuItemId[] = ['select', 'group'];
ids.push(book.readingStatus === 'finished' ? 'markUnread' : 'markFinished');
if (book.readingStatus !== 'abandoned') ids.push('markAbandoned');
// "Clear Status" is offered only when the book has an explicit status set.
if (
book.readingStatus === 'finished' ||
book.readingStatus === 'unread' ||
book.readingStatus === 'abandoned'
) {
ids.push('clearStatus');
}
ids.push('showDetails');
if (book.format === 'EPUB') ids.push('bilingual');
ids.push('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
// if the book hasn't been pushed yet.
if (book.downloadedAt || book.uploadedAt) ids.push('share');
ids.push('delete');
return ids;
};