mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(server): add unified entity manager with duplicate detection
- Remove author insights and duplicate suggestions panels, composables, DTOs, and all backend support - Add genre and tag duplicate detection, merge, and dismiss with server-side paginated scan results - Consolidate per-entity-type tools into a single entity-manager module backed by the Strategy pattern - Support browse, merge, rename, delete, split, and dismiss across all entity types (author, genre, tag, narrator, publisher, language, series) - Replace old tools module with unified entity-manager controller, service, and repository; add 85+ unit tests
This commit is contained in:
@@ -64,6 +64,13 @@ export enum AuditAction {
|
||||
AuthorEnrichmentResume = "author.enrichment.resume",
|
||||
AuthorEnrichmentCancel = "author.enrichment.cancel",
|
||||
|
||||
EntityManagerMerge = "entity_manager.merge",
|
||||
EntityManagerRename = "entity_manager.rename",
|
||||
EntityManagerDelete = "entity_manager.delete",
|
||||
EntityManagerSplit = "entity_manager.split",
|
||||
EntityManagerDismiss = "entity_manager.dismiss",
|
||||
EntityManagerUndismiss = "entity_manager.undismiss",
|
||||
|
||||
AppSettingsUpdate = "app_settings.update",
|
||||
|
||||
KoboDeviceRegister = "kobo.device.register",
|
||||
@@ -102,11 +109,17 @@ export enum AuditResource {
|
||||
BookBucketFile = "book_bucket_file",
|
||||
Author = "author",
|
||||
AppSettings = "app_settings",
|
||||
Genre = "genre",
|
||||
Tag = "tag",
|
||||
KoboDevice = "kobo_device",
|
||||
EmailProvider = "email_provider",
|
||||
EmailTemplate = "email_template",
|
||||
EmailRecipient = "email_recipient",
|
||||
EmailRecipientGroup = "email_recipient_group",
|
||||
Narrator = "narrator",
|
||||
Publisher = "publisher",
|
||||
Language = "language",
|
||||
Series = "series",
|
||||
OidcIdentity = "oidc_identity",
|
||||
}
|
||||
|
||||
|
||||
@@ -29,30 +29,6 @@ export type MergeAuthorsResult = {
|
||||
affectedBookCount: number;
|
||||
};
|
||||
|
||||
export type AuthorDuplicateSuggestion = {
|
||||
left: AuthorSummary;
|
||||
right: AuthorSummary;
|
||||
confidence: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export type AuthorInsightsRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
lastAddedAt: string | null;
|
||||
metric: number;
|
||||
secondaryMetric?: number | null;
|
||||
};
|
||||
|
||||
export type AuthorInsights = {
|
||||
generatedAt: string;
|
||||
windowDays: number;
|
||||
newAuthors: AuthorInsightsRow[];
|
||||
mostRead: AuthorInsightsRow[];
|
||||
unreadBacklog: AuthorInsightsRow[];
|
||||
};
|
||||
|
||||
export const AuthorMetadataProviderKey = {
|
||||
AUDNEXUS: "audnexus",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
export type EntityType = "author" | "genre" | "tag" | "narrator" | "publisher" | "language" | "series";
|
||||
export type FirstClassEntityType = "author" | "genre" | "tag" | "narrator";
|
||||
export type InlineEntityType = "publisher" | "language" | "series";
|
||||
|
||||
export const FIRST_CLASS_ENTITY_TYPES: readonly FirstClassEntityType[] = ["author", "genre", "tag", "narrator"] as const;
|
||||
export const INLINE_ENTITY_TYPES: readonly InlineEntityType[] = ["publisher", "language", "series"] as const;
|
||||
export const ALL_ENTITY_TYPES: readonly EntityType[] = [...FIRST_CLASS_ENTITY_TYPES, ...INLINE_ENTITY_TYPES] as const;
|
||||
|
||||
export interface EntityTypeCapabilities {
|
||||
canSplit: boolean;
|
||||
hasSoftDelete: boolean;
|
||||
hasPhoto: boolean;
|
||||
hasSortName: boolean;
|
||||
}
|
||||
|
||||
export const ENTITY_CAPABILITIES: Record<EntityType, EntityTypeCapabilities> = {
|
||||
author: { canSplit: true, hasSoftDelete: true, hasPhoto: true, hasSortName: true },
|
||||
genre: { canSplit: true, hasSoftDelete: true, hasPhoto: false, hasSortName: false },
|
||||
tag: { canSplit: true, hasSoftDelete: true, hasPhoto: false, hasSortName: false },
|
||||
narrator: { canSplit: true, hasSoftDelete: true, hasPhoto: false, hasSortName: true },
|
||||
publisher: { canSplit: false, hasSoftDelete: false, hasPhoto: false, hasSortName: false },
|
||||
language: { canSplit: false, hasSoftDelete: false, hasPhoto: false, hasSortName: false },
|
||||
series: { canSplit: false, hasSoftDelete: false, hasPhoto: false, hasSortName: false },
|
||||
};
|
||||
|
||||
// Browse
|
||||
export interface BrowseEntityItem {
|
||||
id: number | string;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
sortName?: string | null;
|
||||
hasPhoto?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseEntitiesResponse {
|
||||
items: BrowseEntityItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// Duplicate cluster
|
||||
export interface DuplicateCluster {
|
||||
clusterId: string;
|
||||
entities: ClusterEntity[];
|
||||
averageSimilarity: number;
|
||||
suggestedTargetId: number | string;
|
||||
pairDetails: PairDetail[];
|
||||
}
|
||||
|
||||
export interface ClusterEntity {
|
||||
id: number | string;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
bookTitles: string[];
|
||||
sortName?: string | null;
|
||||
hasPhoto?: boolean;
|
||||
}
|
||||
|
||||
export interface PairDetail {
|
||||
idA: number | string;
|
||||
idB: number | string;
|
||||
similarity: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface DuplicateScanResponse {
|
||||
entityType: EntityType;
|
||||
clusters: DuplicateCluster[];
|
||||
totalEntities: number;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
// Operation results
|
||||
export interface MergeResult {
|
||||
targetId: number | string;
|
||||
mergedIds: (number | string)[];
|
||||
affectedBookCount: number;
|
||||
imagePromoted?: boolean;
|
||||
fieldsResolved?: string[];
|
||||
}
|
||||
|
||||
export interface RenameResult {
|
||||
entityId: number | string;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
affectedBookCount: number;
|
||||
wasImplicitMerge: boolean;
|
||||
mergedEntityId?: number | string;
|
||||
}
|
||||
|
||||
export interface DeleteResult {
|
||||
entityId: number | string;
|
||||
name: string;
|
||||
affectedBookCount: number;
|
||||
mode: "soft" | "hard" | "inline";
|
||||
}
|
||||
|
||||
export interface SplitResult {
|
||||
originalId: number;
|
||||
originalName: string;
|
||||
newEntities: { id: number; name: string }[];
|
||||
affectedBookCount: number;
|
||||
}
|
||||
|
||||
export interface BulkDeleteResult {
|
||||
results: DeleteResult[];
|
||||
errors: { entityId: number | string; error: string }[];
|
||||
}
|
||||
|
||||
// Dismissed pair
|
||||
export interface DismissedPairInfo {
|
||||
id: number;
|
||||
entityType: EntityType;
|
||||
nameA: string;
|
||||
nameB: string;
|
||||
idA: number | string;
|
||||
idB: number | string;
|
||||
reason?: string | null;
|
||||
dismissedAt: string;
|
||||
}
|
||||
|
||||
// Entity info (for modals)
|
||||
export interface EntityInfo {
|
||||
id: number | string;
|
||||
name: string;
|
||||
bookCount: number;
|
||||
bookTitles: string[];
|
||||
}
|
||||
@@ -29,3 +29,4 @@ export * from "./migration";
|
||||
export * from "./notification";
|
||||
export * from "./series";
|
||||
export * from "./series-collapse";
|
||||
export * from "./entity-manager";
|
||||
|
||||
Reference in New Issue
Block a user