Files
MeBox/web/src/utils/readerBookGroups.ts
T
truewhile 60cfad47e5 优化
2026-10-03 14:05:24 +08:00

319 lines
11 KiB
TypeScript

import type { BookGroup } from '../api/reader'
// 书架分组的纯逻辑(对齐影视模块的 utils/libraryTags.ts)。
//
// 结构同构:组名 → 成员 ID 列表,「一本书只归一个组」,整份替换。
// 差别在于书架多两个内置页签:全部 / 未分组(未分组不持久化,只是「没归任何组」的视图)。
/** 与后端 model.MaxBookGroupNameLen 保持一致。 */
export const MAX_BOOK_GROUP_NAME_LENGTH = 24
/** 与后端 model.MaxBookGroups 保持一致。 */
export const MAX_BOOK_GROUPS = 50
/** 「全部」伪页签:不参与持久化,仅用于分组栏选中项。 */
export const ALL_GROUP_ID = '__all__'
/** 「未分组」伪页签:没有归入任何分组的书籍。 */
export const UNGROUPED_GROUP_ID = '__ungrouped__'
export type BookGroupTab = {
/** 选中的稳定标识:ALL_GROUP_ID / UNGROUPED_GROUP_ID / 分组名。 */
id: string
name: string
count: number
kind: 'all' | 'ungrouped' | 'group'
}
const STORAGE_KEY = 'mebox_reader_book_groups'
const SELECTED_KEY = 'mebox_reader_book_group_selected'
/** 分组名去空白并按字符数截断,与后端清洗规则一致。 */
export function normalizeBookGroupName(name: string): string {
return Array.from(name.trim()).slice(0, MAX_BOOK_GROUP_NAME_LENGTH).join('').trim()
}
export function normalizeBookGroups(raw: unknown): BookGroup[] {
if (!Array.isArray(raw)) return []
const out: BookGroup[] = []
const indexByName = new Map<string, number>()
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const name = normalizeBookGroupName(String((item as BookGroup).name ?? ''))
if (!name) continue
const key = name.toLowerCase()
let pos = indexByName.get(key)
if (pos === undefined) {
if (out.length >= MAX_BOOK_GROUPS) break
out.push({ name, book_ids: [] })
pos = out.length - 1
indexByName.set(key, pos)
}
const ids = (item as BookGroup).book_ids
if (!Array.isArray(ids)) continue
const seen = new Set(out[pos].book_ids)
for (const id of ids) {
const trimmed = String(id ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out[pos].book_ids.push(trimmed)
}
}
return out
}
/** 读取本地兜底缓存:接口不可用时分组栏仍然可用。 */
export function readCachedBookGroups(): BookGroup[] {
if (typeof window === 'undefined') return []
try {
return normalizeBookGroups(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
} catch {
return []
}
}
export function writeCachedBookGroups(groups: BookGroup[]): void {
if (typeof window === 'undefined') return
try {
if (groups.length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(groups))
} catch {
// 私密模式等场景忽略存储失败。
}
}
export function readSelectedGroupId(): string {
if (typeof window === 'undefined') return ALL_GROUP_ID
try {
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_GROUP_ID
} catch {
return ALL_GROUP_ID
}
}
export function writeSelectedGroupId(groupId: string): void {
if (typeof window === 'undefined') return
try {
if (!groupId || groupId === ALL_GROUP_ID) {
window.localStorage.removeItem(SELECTED_KEY)
return
}
window.localStorage.setItem(SELECTED_KEY, groupId)
} catch {
// 同上。
}
}
export async function loadBookGroups(): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const remote = normalizeBookGroups(await readerAPI.getBookGroups())
writeCachedBookGroups(remote)
return remote
}
export async function saveBookGroups(groups: BookGroup[]): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const saved = normalizeBookGroups(await readerAPI.setBookGroups(normalizeBookGroups(groups)))
writeCachedBookGroups(saved)
return saved
}
/**
* 收敛为「一本书只归一个组」:越靠前的分组优先,后面重复出现的书会被移除。
* 与后端 dedupeBookGroups 的语义一致。
*/
export function dedupeBookGroups(groups: BookGroup[]): BookGroup[] {
const claimed = new Set<string>()
return groups.map((group) => {
const bookIds: string[] = []
for (const id of group.book_ids) {
if (claimed.has(id)) continue
claimed.add(id)
bookIds.push(id)
}
return { ...group, book_ids: bookIds }
})
}
/** 分组名是否可用:非空、未超长、且(除自身外)没有重名。 */
export function bookGroupNameError(name: string, groups: BookGroup[], exceptName?: string): string {
const trimmed = normalizeBookGroupName(name)
if (!trimmed) return '分组名不能为空'
if (Array.from(name.trim()).length > MAX_BOOK_GROUP_NAME_LENGTH) {
return `分组名最多 ${MAX_BOOK_GROUP_NAME_LENGTH} 个字`
}
const key = trimmed.toLowerCase()
const selfKey = exceptName ? normalizeBookGroupName(exceptName).toLowerCase() : ''
if (key !== selfKey && groups.some((group) => group.name.toLowerCase() === key)) {
return '分组名已存在'
}
return ''
}
export function findBookGroupIdByName(groups: BookGroup[], name: string): string {
const key = normalizeBookGroupName(name).toLowerCase()
if (!key) return ''
return groups.find((group) => group.name.toLowerCase() === key)?.name ?? ''
}
export function groupBookIds(groups: BookGroup[], groupId: string): string[] {
if (!groupId || groupId === ALL_GROUP_ID || groupId === UNGROUPED_GROUP_ID) return []
const group = groups.find((item) => item.name === groupId)
return group ? group.book_ids : []
}
/** 一本书所属的分组名;未归组返回空串。 */
export function groupIdOfBook(groups: BookGroup[], bookId: string): string {
for (const group of groups) {
if (group.book_ids.includes(bookId)) return group.name
}
return ''
}
export function isBookInGroup(groups: BookGroup[], groupId: string, bookId: string): boolean {
return groupBookIds(groups, groupId).includes(bookId)
}
/** 所有已被某个分组认领的书籍 ID。 */
export function groupedBookIds(groups: BookGroup[]): Set<string> {
const out = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) out.add(id)
}
return out
}
/**
* 按分组过滤书籍:`全部` 原样返回,`未分组` 返回没有被任何分组认领的书。
* 只做成员筛选、保持输入顺序;展示顺序由调用方决定。
*/
export function filterBooksByGroup<T extends { id: string }>(
books: T[],
groups: BookGroup[],
groupId: string,
): T[] {
if (!groupId || groupId === ALL_GROUP_ID) return books
if (groupId === UNGROUPED_GROUP_ID) {
const claimed = groupedBookIds(groups)
return books.filter((book) => !claimed.has(book.id))
}
const ids = new Set(groupBookIds(groups, groupId))
if (ids.size === 0) return []
return books.filter((book) => ids.has(book.id))
}
/**
* 生成分组栏模型:首项固定「全部」,其后按分组顺序追加,最后(有分组时)追加「未分组」。
* 把「未分组」放到最后:它是一个兜底视图而不是一个真实分组,放在用户分组后面才不会
* 把用户自己排的分组顺序挤开(用户拖拽排序只影响中间那段)。
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
*/
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
const available = new Set(books.map((book) => book.id))
const claimed = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) {
if (available.has(id)) claimed.add(id)
}
}
const tabs: BookGroupTab[] = [
{ id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' },
]
if (groups.length === 0) return tabs
for (const group of groups) {
tabs.push({
id: group.name,
name: group.name,
count: group.book_ids.filter((id) => available.has(id)).length,
kind: 'group',
})
}
tabs.push({
id: UNGROUPED_GROUP_ID,
name: '未分组',
count: books.length - claimed.size,
kind: 'ungrouped',
})
return tabs
}
/**
* 按给定分组名顺序重排:`orderedNames` 是期望顺序(通常来自拖拽结果)。
* 未出现在顺序里的分组保持原相对顺序并追加到末尾,绝不丢失。
*/
export function reorderBookGroups(groups: BookGroup[], orderedNames: string[]): BookGroup[] {
if (groups.length < 2) return groups
const rank = new Map(orderedNames.map((name, index) => [name, index]))
const ranked: BookGroup[] = []
const rest: BookGroup[] = []
for (const group of groups) {
;(rank.has(group.name) ? ranked : rest).push(group)
}
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
return [...ranked, ...rest]
}
/** 选中的分组是否仍存在;被删除时回落到「全部」。 */
export function resolveSelectedGroupId(groups: BookGroup[], selectedId: string): string {
if (!selectedId || selectedId === ALL_GROUP_ID || selectedId === UNGROUPED_GROUP_ID) return selectedId || ALL_GROUP_ID
return groups.some((group) => group.name === selectedId) ? selectedId : ALL_GROUP_ID
}
/** 把一本书挂到分组下:先从原分组移除,保证一书一组。 */
export function attachBookToGroup(groups: BookGroup[], bookId: string, groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
if (!target) return groups
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => id !== bookId),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, bookId] } : group,
)
}
/** 批量把书籍挂到分组下;整批只产生一次写入。 */
export function attachBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
const moving = normalizeBookIds(bookIds)
if (!target || moving.length === 0) return groups
const movingSet = new Set(moving)
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !movingSet.has(id)),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, ...moving] } : group,
)
}
/** 批量把书籍移出所有分组(即变为「未分组」)。 */
export function detachBooksFromGroup(groups: BookGroup[], bookIds: string[]): BookGroup[] {
const moving = new Set(normalizeBookIds(bookIds))
if (moving.size === 0) return groups
return groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !moving.has(id)),
}))
}
/** 批量归类:groupName 为空表示移出所有分组。 */
export function assignBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
return groupName ? attachBooksToGroup(groups, bookIds, groupName) : detachBooksFromGroup(groups, bookIds)
}
/** 去掉空白与重复,保留首次出现的顺序。 */
export function normalizeBookIds(bookIds: unknown): string[] {
if (!Array.isArray(bookIds)) return []
const seen = new Set<string>()
const out: string[] = []
for (const raw of bookIds) {
const trimmed = String(raw ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
return out
}