diff --git a/.gitignore b/.gitignore index 03ca6ee..8568d14 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,7 @@ erl_crash.dump temp test .nova +CANONICAL_METADATA.md +GRAPHQL_LEVERAGE_GUIDE.md +IMPORT_WITH_GRAPHQL.md +JOBQUEUE_GRAPHQL_INTEGRATION.md diff --git a/examples/import-comic-graphql.example.ts b/examples/import-comic-graphql.example.ts new file mode 100644 index 0000000..13ffce5 --- /dev/null +++ b/examples/import-comic-graphql.example.ts @@ -0,0 +1,420 @@ +/** + * Example: Importing Comics with GraphQL and Canonical Metadata + * + * This example demonstrates how to import comics using the new GraphQL-based + * import system that automatically resolves canonical metadata from multiple sources. + */ + +import { ServiceBroker } from "moleculer"; +import { + importComicViaGraphQL, + updateSourcedMetadataViaGraphQL, + resolveMetadataViaGraphQL, + analyzeMetadataConflictsViaGraphQL, + getComicViaGraphQL, +} from "../utils/import.graphql.utils"; + +/** + * Example 1: Basic Comic Import + * Import a comic with ComicInfo.xml metadata + */ +async function example1_basicImport(broker: ServiceBroker) { + console.log("\n=== Example 1: Basic Comic Import ===\n"); + + const result = await importComicViaGraphQL(broker, { + filePath: "/comics/amazing-spider-man-001.cbz", + fileSize: 12345678, + + rawFileDetails: { + name: "Amazing Spider-Man 001", + filePath: "/comics/amazing-spider-man-001.cbz", + fileSize: 12345678, + extension: ".cbz", + mimeType: "application/x-cbz", + pageCount: 24, + }, + + inferredMetadata: { + issue: { + name: "Amazing Spider-Man", + number: 1, + year: "2023", + }, + }, + + sourcedMetadata: { + comicInfo: { + Title: "Amazing Spider-Man #1", + Series: "Amazing Spider-Man", + Number: "1", + Publisher: "Marvel Comics", + Summary: "Peter Parker's origin story begins...", + Year: "2023", + Month: "1", + }, + }, + }); + + console.log("Import Result:", { + success: result.success, + message: result.message, + canonicalMetadataResolved: result.canonicalMetadataResolved, + comicId: result.comic.id, + }); + + console.log("\nCanonical Metadata:"); + console.log(" Title:", result.comic.canonicalMetadata?.title?.value); + console.log(" Source:", result.comic.canonicalMetadata?.title?.provenance?.source); + console.log(" Series:", result.comic.canonicalMetadata?.series?.value); + console.log(" Publisher:", result.comic.canonicalMetadata?.publisher?.value); + + return result.comic.id; +} + +/** + * Example 2: Import with Multiple Sources + * Import a comic with metadata from ComicInfo.xml, ComicVine, and LOCG + */ +async function example2_multiSourceImport(broker: ServiceBroker) { + console.log("\n=== Example 2: Multi-Source Import ===\n"); + + const result = await importComicViaGraphQL(broker, { + filePath: "/comics/batman-001.cbz", + + rawFileDetails: { + name: "Batman 001", + filePath: "/comics/batman-001.cbz", + fileSize: 15000000, + extension: ".cbz", + pageCount: 32, + }, + + inferredMetadata: { + issue: { + name: "Batman", + number: 1, + year: "2023", + }, + }, + + sourcedMetadata: { + // From ComicInfo.xml + comicInfo: { + Title: "Batman #1", + Series: "Batman", + Number: "1", + Publisher: "DC Comics", + Summary: "The Dark Knight returns...", + }, + + // From ComicVine API + comicvine: { + name: "Batman #1: The Court of Owls", + issue_number: "1", + description: "A new era begins for the Dark Knight...", + cover_date: "2023-01-01", + volumeInformation: { + name: "Batman", + publisher: { + name: "DC Comics", + }, + }, + }, + + // From League of Comic Geeks + locg: { + name: "Batman #1", + publisher: "DC Comics", + description: "Batman faces a new threat...", + rating: 4.8, + pulls: 15000, + cover: "https://example.com/batman-001-cover.jpg", + }, + }, + }); + + console.log("Import Result:", { + success: result.success, + canonicalMetadataResolved: result.canonicalMetadataResolved, + comicId: result.comic.id, + }); + + console.log("\nCanonical Metadata (resolved from 3 sources):"); + console.log(" Title:", result.comic.canonicalMetadata?.title?.value); + console.log(" Source:", result.comic.canonicalMetadata?.title?.provenance?.source); + console.log(" Confidence:", result.comic.canonicalMetadata?.title?.provenance?.confidence); + console.log("\n Description:", result.comic.canonicalMetadata?.description?.value?.substring(0, 50) + "..."); + console.log(" Source:", result.comic.canonicalMetadata?.description?.provenance?.source); + + return result.comic.id; +} + +/** + * Example 3: Update Metadata After Import + * Import a comic, then fetch and add ComicVine metadata + */ +async function example3_updateMetadataAfterImport(broker: ServiceBroker) { + console.log("\n=== Example 3: Update Metadata After Import ===\n"); + + // Step 1: Import with basic metadata + console.log("Step 1: Initial import with ComicInfo.xml only"); + const importResult = await importComicViaGraphQL(broker, { + filePath: "/comics/x-men-001.cbz", + + rawFileDetails: { + name: "X-Men 001", + filePath: "/comics/x-men-001.cbz", + fileSize: 10000000, + extension: ".cbz", + }, + + sourcedMetadata: { + comicInfo: { + Title: "X-Men #1", + Series: "X-Men", + Number: "1", + Publisher: "Marvel Comics", + }, + }, + }); + + const comicId = importResult.comic.id; + console.log(" Comic imported:", comicId); + console.log(" Initial title:", importResult.comic.canonicalMetadata?.title?.value); + console.log(" Initial source:", importResult.comic.canonicalMetadata?.title?.provenance?.source); + + // Step 2: Fetch and add ComicVine metadata + console.log("\nStep 2: Adding ComicVine metadata"); + const comicVineData = { + name: "X-Men #1: Mutant Genesis", + issue_number: "1", + description: "The X-Men are reborn in this landmark issue...", + cover_date: "2023-01-01", + volumeInformation: { + name: "X-Men", + publisher: { + name: "Marvel Comics", + }, + }, + }; + + const updatedComic = await updateSourcedMetadataViaGraphQL( + broker, + comicId, + "comicvine", + comicVineData + ); + + console.log(" Updated title:", updatedComic.canonicalMetadata?.title?.value); + console.log(" Updated source:", updatedComic.canonicalMetadata?.title?.provenance?.source); + console.log(" Description added:", updatedComic.canonicalMetadata?.description?.value?.substring(0, 50) + "..."); + + return comicId; +} + +/** + * Example 4: Analyze Metadata Conflicts + * See how conflicts between sources are resolved + */ +async function example4_analyzeConflicts(broker: ServiceBroker) { + console.log("\n=== Example 4: Analyze Metadata Conflicts ===\n"); + + // Import with conflicting metadata + const result = await importComicViaGraphQL(broker, { + filePath: "/comics/superman-001.cbz", + + rawFileDetails: { + name: "Superman 001", + filePath: "/comics/superman-001.cbz", + fileSize: 14000000, + extension: ".cbz", + }, + + sourcedMetadata: { + comicInfo: { + Title: "Superman #1", + Series: "Superman", + Publisher: "DC Comics", + }, + comicvine: { + name: "Superman #1: Man of Steel", + volumeInformation: { + name: "Superman", + publisher: { + name: "DC Comics", + }, + }, + }, + locg: { + name: "Superman #1 (2023)", + publisher: "DC", + }, + }, + }); + + const comicId = result.comic.id; + console.log("Comic imported:", comicId); + + // Analyze conflicts + console.log("\nAnalyzing metadata conflicts..."); + const conflicts = await analyzeMetadataConflictsViaGraphQL(broker, comicId); + + console.log(`\nFound ${conflicts.length} field(s) with conflicts:\n`); + + for (const conflict of conflicts) { + console.log(`Field: ${conflict.field}`); + console.log(` Candidates:`); + for (const candidate of conflict.candidates) { + console.log(` - "${candidate.value}" from ${candidate.provenance.source} (confidence: ${candidate.provenance.confidence})`); + } + console.log(` Resolved: "${conflict.resolved.value}" from ${conflict.resolved.provenance.source}`); + console.log(` Reason: ${conflict.resolutionReason}`); + console.log(); + } + + return comicId; +} + +/** + * Example 5: Manual Metadata Resolution + * Manually trigger metadata resolution + */ +async function example5_manualResolution(broker: ServiceBroker) { + console.log("\n=== Example 5: Manual Metadata Resolution ===\n"); + + // Import without auto-resolution (if disabled) + const result = await importComicViaGraphQL(broker, { + filePath: "/comics/wonder-woman-001.cbz", + + rawFileDetails: { + name: "Wonder Woman 001", + filePath: "/comics/wonder-woman-001.cbz", + fileSize: 13000000, + extension: ".cbz", + }, + + sourcedMetadata: { + comicInfo: { + Title: "Wonder Woman #1", + Series: "Wonder Woman", + }, + }, + }); + + const comicId = result.comic.id; + console.log("Comic imported:", comicId); + console.log("Auto-resolved:", result.canonicalMetadataResolved); + + // Manually trigger resolution + console.log("\nManually resolving metadata..."); + const resolvedComic = await resolveMetadataViaGraphQL(broker, comicId); + + console.log("Resolved metadata:"); + console.log(" Title:", resolvedComic.canonicalMetadata?.title?.value); + console.log(" Series:", resolvedComic.canonicalMetadata?.series?.value); + + return comicId; +} + +/** + * Example 6: Get Comic with Full Canonical Metadata + * Retrieve a comic with all its canonical metadata + */ +async function example6_getComicWithMetadata(broker: ServiceBroker, comicId: string) { + console.log("\n=== Example 6: Get Comic with Full Metadata ===\n"); + + const comic = await getComicViaGraphQL(broker, comicId); + + console.log("Comic ID:", comic.id); + console.log("\nCanonical Metadata:"); + console.log(" Title:", comic.canonicalMetadata?.title?.value); + console.log(" Source:", comic.canonicalMetadata?.title?.provenance?.source); + console.log(" Confidence:", comic.canonicalMetadata?.title?.provenance?.confidence); + console.log(" Fetched:", comic.canonicalMetadata?.title?.provenance?.fetchedAt); + console.log(" User Override:", comic.canonicalMetadata?.title?.userOverride || false); + + console.log("\n Series:", comic.canonicalMetadata?.series?.value); + console.log(" Source:", comic.canonicalMetadata?.series?.provenance?.source); + + console.log("\n Publisher:", comic.canonicalMetadata?.publisher?.value); + console.log(" Source:", comic.canonicalMetadata?.publisher?.provenance?.source); + + if (comic.canonicalMetadata?.description) { + console.log("\n Description:", comic.canonicalMetadata.description.value?.substring(0, 100) + "..."); + console.log(" Source:", comic.canonicalMetadata.description.provenance?.source); + } + + if (comic.canonicalMetadata?.creators?.length > 0) { + console.log("\n Creators:"); + for (const creator of comic.canonicalMetadata.creators) { + console.log(` - ${creator.name} (${creator.role}) from ${creator.provenance.source}`); + } + } + + console.log("\nRaw File Details:"); + console.log(" Name:", comic.rawFileDetails?.name); + console.log(" Path:", comic.rawFileDetails?.filePath); + console.log(" Size:", comic.rawFileDetails?.fileSize); + console.log(" Pages:", comic.rawFileDetails?.pageCount); + + console.log("\nImport Status:"); + console.log(" Imported:", comic.importStatus?.isImported); + console.log(" Tagged:", comic.importStatus?.tagged); +} + +/** + * Run all examples + */ +async function runAllExamples(broker: ServiceBroker) { + console.log("╔════════════════════════════════════════════════════════════╗"); + console.log("║ Comic Import with GraphQL & Canonical Metadata Examples ║"); + console.log("╚════════════════════════════════════════════════════════════╝"); + + try { + // Example 1: Basic import + const comicId1 = await example1_basicImport(broker); + + // Example 2: Multi-source import + const comicId2 = await example2_multiSourceImport(broker); + + // Example 3: Update after import + const comicId3 = await example3_updateMetadataAfterImport(broker); + + // Example 4: Analyze conflicts + const comicId4 = await example4_analyzeConflicts(broker); + + // Example 5: Manual resolution + const comicId5 = await example5_manualResolution(broker); + + // Example 6: Get full metadata + await example6_getComicWithMetadata(broker, comicId2); + + console.log("\n╔════════════════════════════════════════════════════════════╗"); + console.log("║ All examples completed successfully! ║"); + console.log("╚════════════════════════════════════════════════════════════╝\n"); + } catch (error) { + console.error("\n❌ Error running examples:", error); + throw error; + } +} + +/** + * Usage in your service + */ +export { + example1_basicImport, + example2_multiSourceImport, + example3_updateMetadataAfterImport, + example4_analyzeConflicts, + example5_manualResolution, + example6_getComicWithMetadata, + runAllExamples, +}; + +// If running directly +if (require.main === module) { + console.log("Note: This is an example file. To run these examples:"); + console.log("1. Ensure your Moleculer broker is running"); + console.log("2. Import and call the example functions from your service"); + console.log("3. Or integrate the patterns into your library.service.ts"); +} diff --git a/models/comic.model.ts b/models/comic.model.ts index 595cedb..d10fd29 100644 --- a/models/comic.model.ts +++ b/models/comic.model.ts @@ -18,6 +18,216 @@ export const eSClient = new Client({ }, }); +// Metadata source enumeration +export enum MetadataSource { + COMICVINE = "comicvine", + METRON = "metron", + GRAND_COMICS_DATABASE = "gcd", + LOCG = "locg", + COMICINFO_XML = "comicinfo", + MANUAL = "manual", +} + +// Provenance schema - tracks where each piece of metadata came from +const ProvenanceSchema = new mongoose.Schema( + { + _id: false, + source: { + type: String, + enum: Object.values(MetadataSource), + required: true, + }, + sourceId: String, // External ID from the source (e.g., ComicVine ID) + confidence: { type: Number, min: 0, max: 1, default: 1 }, // 0-1 confidence score + fetchedAt: { type: Date, default: Date.now }, + url: String, // Source URL if applicable + }, + { _id: false } +); + +// Individual metadata field with provenance +const MetadataFieldSchema = new mongoose.Schema( + { + _id: false, + value: mongoose.Schema.Types.Mixed, // The actual value + provenance: ProvenanceSchema, // Where it came from + userOverride: { type: Boolean, default: false }, // User manually set this + }, + { _id: false } +); + +// Creator with provenance +const CreatorSchema = new mongoose.Schema( + { + _id: false, + name: String, + role: String, // writer, artist, colorist, letterer, etc. + id: String, // External ID from source (e.g., Metron creator ID) + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// Story Arc with provenance +const StoryArcSchema = new mongoose.Schema( + { + _id: false, + name: String, + number: Number, // Issue's position in the arc + id: String, // External ID from source + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// Universe schema for multiverse/alternate reality tracking +const UniverseSchema = new mongoose.Schema( + { + _id: false, + name: String, + designation: String, // e.g., "Earth-616", "Earth-25" + id: String, // External ID from source + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// Price information with country codes +const PriceSchema = new mongoose.Schema( + { + _id: false, + country: String, // ISO country code (e.g., "US", "GB") + amount: Number, + currency: String, // ISO currency code (e.g., "USD", "GBP") + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// External IDs from various sources +const ExternalIDSchema = new mongoose.Schema( + { + _id: false, + source: String, // e.g., "Metron", "Comic Vine", "Grand Comics Database", "MangaDex" + id: String, + primary: { type: Boolean, default: false }, + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// GTIN (Global Trade Item Number) - includes ISBN, UPC, etc. +const GTINSchema = new mongoose.Schema( + { + _id: false, + isbn: String, + upc: String, + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// Reprint information +const ReprintSchema = new mongoose.Schema( + { + _id: false, + description: String, // e.g., "Foo Bar #001 (2002)" + id: String, // External ID from source + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// URL with primary flag +const URLSchema = new mongoose.Schema( + { + _id: false, + url: String, + primary: { type: Boolean, default: false }, + provenance: ProvenanceSchema, + }, + { _id: false } +); + +// Canonical metadata - resolved from multiple sources +const CanonicalMetadataSchema = new mongoose.Schema( + { + _id: false, + // Core identifiers + title: MetadataFieldSchema, + series: MetadataFieldSchema, + volume: MetadataFieldSchema, + issueNumber: MetadataFieldSchema, + + // External IDs from various sources (Metron, ComicVine, GCD, MangaDex, etc.) + externalIDs: [ExternalIDSchema], + + // Publication info + publisher: MetadataFieldSchema, + imprint: MetadataFieldSchema, // Publisher imprint (e.g., Vertigo for DC Comics) + publicationDate: MetadataFieldSchema, // Store/release date + coverDate: MetadataFieldSchema, // Cover date (often different from store date) + + // Series information + seriesInfo: { + type: { + _id: false, + id: String, // External series ID + language: String, // ISO language code (e.g., "en", "de") + sortName: String, // Alternative sort name + startYear: Number, + issueCount: Number, // Total issues in series + volumeCount: Number, // Total volumes/collections + alternativeNames: [MetadataFieldSchema], // Alternative series names + provenance: ProvenanceSchema, + }, + default: null, + }, + + // Content + description: MetadataFieldSchema, // Summary/synopsis + notes: MetadataFieldSchema, // Additional notes about the issue + stories: [MetadataFieldSchema], // Story titles within the issue + storyArcs: [StoryArcSchema], // Story arcs with position tracking + characters: [MetadataFieldSchema], + teams: [MetadataFieldSchema], + locations: [MetadataFieldSchema], + universes: [UniverseSchema], // Multiverse/alternate reality information + + // Creators + creators: [CreatorSchema], + + // Classification + genres: [MetadataFieldSchema], + tags: [MetadataFieldSchema], + ageRating: MetadataFieldSchema, + + // Physical/Digital properties + pageCount: MetadataFieldSchema, + format: MetadataFieldSchema, // Single Issue, TPB, HC, etc. + + // Commercial information + prices: [PriceSchema], // Prices in different countries/currencies + gtin: GTINSchema, // ISBN, UPC, etc. + + // Reprints + reprints: [ReprintSchema], // Information about reprinted content + + // URLs + urls: [URLSchema], // External URLs (ComicVine, Metron, etc.) + + // Ratings and popularity + communityRating: MetadataFieldSchema, + + // Cover image + coverImage: MetadataFieldSchema, + + // Metadata tracking + lastModified: MetadataFieldSchema, // Last modification timestamp from source + }, + { _id: false } +); + const RawFileDetailsSchema = mongoose.Schema({ _id: false, name: String, @@ -49,6 +259,7 @@ const LOCGSchema = mongoose.Schema({ pulls: Number, potw: Number, }); + const DirectConnectBundleSchema = mongoose.Schema({ bundleId: Number, name: String, @@ -56,6 +267,7 @@ const DirectConnectBundleSchema = mongoose.Schema({ type: {}, _id: false, }); + const wantedSchema = mongoose.Schema( { source: { type: String, default: null }, @@ -63,7 +275,7 @@ const wantedSchema = mongoose.Schema( issues: { type: [ { - _id: false, // Disable automatic ObjectId creation for each issue + _id: false, id: Number, url: String, image: { type: Array, default: [] }, @@ -75,7 +287,7 @@ const wantedSchema = mongoose.Schema( }, volume: { type: { - _id: false, // Disable automatic ObjectId creation for volume + _id: false, id: Number, url: String, image: { type: Array, default: [] }, @@ -85,7 +297,7 @@ const wantedSchema = mongoose.Schema( }, }, { _id: false } -); // Disable automatic ObjectId creation for the wanted object itself +); const ComicSchema = mongoose.Schema( { @@ -99,15 +311,27 @@ const ComicSchema = mongoose.Schema( userAddedMetadata: { tags: [String], }, + + // NEW: Canonical metadata with provenance + canonicalMetadata: { + type: CanonicalMetadataSchema, + es_indexed: true, + default: {}, + }, + + // LEGACY: Keep existing sourced metadata for backward compatibility sourcedMetadata: { comicInfo: { type: mongoose.Schema.Types.Mixed, default: {} }, - comicvine: { type: mongoose.Schema.Types.Mixed, default: {} }, // Set as a freeform object + comicvine: { type: mongoose.Schema.Types.Mixed, default: {} }, + metron: { type: mongoose.Schema.Types.Mixed, default: {} }, + gcd: { type: mongoose.Schema.Types.Mixed, default: {} }, // Grand Comics Database locg: { type: LOCGSchema, es_indexed: true, default: {}, }, }, + rawFileDetails: { type: RawFileDetailsSchema, es_indexed: true, diff --git a/models/graphql/resolvers.ts b/models/graphql/resolvers.ts new file mode 100644 index 0000000..bba22ac --- /dev/null +++ b/models/graphql/resolvers.ts @@ -0,0 +1,813 @@ +import Comic, { MetadataSource } from "../comic.model"; +import UserPreferences, { + ConflictResolutionStrategy, +} from "../userpreferences.model"; +import { + resolveMetadataField, + buildCanonicalMetadata, + MetadataField, + ResolutionPreferences, +} from "../../utils/metadata.resolution.utils"; + +/** + * GraphQL Resolvers for canonical metadata queries and mutations + */ +export const resolvers = { + Query: { + /** + * Get a single comic by ID + */ + comic: async (_: any, { id }: { id: string }) => { + try { + const comic = await Comic.findById(id); + return comic; + } catch (error) { + console.error("Error fetching comic:", error); + throw new Error("Failed to fetch comic"); + } + }, + + /** + * List comics with pagination and filtering + */ + comics: async ( + _: any, + { + limit = 10, + page = 1, + search, + publisher, + series, + }: { + limit?: number; + page?: number; + search?: string; + publisher?: string; + series?: string; + } + ) => { + try { + const query: any = {}; + + // Build search query + if (search) { + query.$or = [ + { "canonicalMetadata.title.value": new RegExp(search, "i") }, + { "canonicalMetadata.series.value": new RegExp(search, "i") }, + { "rawFileDetails.name": new RegExp(search, "i") }, + ]; + } + + if (publisher) { + query["canonicalMetadata.publisher.value"] = new RegExp( + publisher, + "i" + ); + } + + if (series) { + query["canonicalMetadata.series.value"] = new RegExp(series, "i"); + } + + const options = { + page, + limit, + sort: { createdAt: -1 }, + }; + + const result = await Comic.paginate(query, options); + + return { + comics: result.docs, + totalCount: result.totalDocs, + pageInfo: { + hasNextPage: result.hasNextPage, + hasPreviousPage: result.hasPrevPage, + currentPage: result.page, + totalPages: result.totalPages, + }, + }; + } catch (error) { + console.error("Error fetching comics:", error); + throw new Error("Failed to fetch comics"); + } + }, + + /** + * Get user preferences + */ + userPreferences: async ( + _: any, + { userId = "default" }: { userId?: string } + ) => { + try { + let preferences = await UserPreferences.findOne({ userId }); + + // Create default preferences if none exist + if (!preferences) { + preferences = await UserPreferences.create({ userId }); + } + + return preferences; + } catch (error) { + console.error("Error fetching user preferences:", error); + throw new Error("Failed to fetch user preferences"); + } + }, + + /** + * Analyze metadata conflicts for a comic + */ + analyzeMetadataConflicts: async ( + _: any, + { comicId }: { comicId: string } + ) => { + try { + const comic = await Comic.findById(comicId); + if (!comic) { + throw new Error("Comic not found"); + } + + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + if (!preferences) { + throw new Error("User preferences not found"); + } + + const conflicts: any[] = []; + + // Analyze each field for conflicts + const fields = [ + "title", + "series", + "issueNumber", + "description", + "publisher", + ]; + + for (const field of fields) { + const candidates = extractCandidatesForField( + field, + comic.sourcedMetadata + ); + + if (candidates.length > 1) { + const resolved = resolveMetadataField( + field, + candidates, + convertPreferences(preferences) + ); + + conflicts.push({ + field, + candidates, + resolved, + resolutionReason: getResolutionReason( + resolved, + candidates, + preferences + ), + }); + } + } + + return conflicts; + } catch (error) { + console.error("Error analyzing metadata conflicts:", error); + throw new Error("Failed to analyze metadata conflicts"); + } + }, + + /** + * Preview canonical metadata resolution without saving + */ + previewCanonicalMetadata: async ( + _: any, + { + comicId, + preferences: preferencesInput, + }: { comicId: string; preferences?: any } + ) => { + try { + const comic = await Comic.findById(comicId); + if (!comic) { + throw new Error("Comic not found"); + } + + let preferences = await UserPreferences.findOne({ + userId: "default", + }); + + // Use provided preferences or default + if (preferencesInput) { + preferences = applyPreferencesInput(preferences, preferencesInput); + } + + if (!preferences) { + throw new Error("User preferences not found"); + } + + const canonical = buildCanonicalMetadata( + comic.sourcedMetadata, + convertPreferences(preferences) + ); + + return canonical; + } catch (error) { + console.error("Error previewing canonical metadata:", error); + throw new Error("Failed to preview canonical metadata"); + } + }, + }, + + Mutation: { + /** + * Update user preferences + */ + updateUserPreferences: async ( + _: any, + { + userId = "default", + preferences: preferencesInput, + }: { userId?: string; preferences: any } + ) => { + try { + let preferences = await UserPreferences.findOne({ userId }); + + if (!preferences) { + preferences = new UserPreferences({ userId }); + } + + // Update preferences + if (preferencesInput.sourcePriorities) { + preferences.sourcePriorities = preferencesInput.sourcePriorities.map( + (sp: any) => ({ + source: sp.source, + priority: sp.priority, + enabled: sp.enabled, + fieldOverrides: sp.fieldOverrides + ? new Map( + sp.fieldOverrides.map((fo: any) => [fo.field, fo.priority]) + ) + : new Map(), + }) + ); + } + + if (preferencesInput.conflictResolution) { + preferences.conflictResolution = preferencesInput.conflictResolution; + } + + if (preferencesInput.minConfidenceThreshold !== undefined) { + preferences.minConfidenceThreshold = + preferencesInput.minConfidenceThreshold; + } + + if (preferencesInput.preferRecent !== undefined) { + preferences.preferRecent = preferencesInput.preferRecent; + } + + if (preferencesInput.fieldPreferences) { + preferences.fieldPreferences = new Map( + preferencesInput.fieldPreferences.map((fp: any) => [ + fp.field, + fp.preferredSource, + ]) + ); + } + + if (preferencesInput.autoMerge) { + preferences.autoMerge = { + ...preferences.autoMerge, + ...preferencesInput.autoMerge, + }; + } + + await preferences.save(); + return preferences; + } catch (error) { + console.error("Error updating user preferences:", error); + throw new Error("Failed to update user preferences"); + } + }, + + /** + * Manually set a metadata field (creates user override) + */ + setMetadataField: async ( + _: any, + { comicId, field, value }: { comicId: string; field: string; value: any } + ) => { + try { + const comic = await Comic.findById(comicId); + if (!comic) { + throw new Error("Comic not found"); + } + + // Set the field with user override + const fieldPath = `canonicalMetadata.${field}`; + const update = { + [fieldPath]: { + value, + provenance: { + source: MetadataSource.MANUAL, + confidence: 1.0, + fetchedAt: new Date(), + }, + userOverride: true, + }, + }; + + const updatedComic = await Comic.findByIdAndUpdate( + comicId, + { $set: update }, + { new: true } + ); + + return updatedComic; + } catch (error) { + console.error("Error setting metadata field:", error); + throw new Error("Failed to set metadata field"); + } + }, + + /** + * Trigger metadata resolution for a comic + */ + resolveMetadata: async (_: any, { comicId }: { comicId: string }) => { + try { + const comic = await Comic.findById(comicId); + if (!comic) { + throw new Error("Comic not found"); + } + + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + if (!preferences) { + throw new Error("User preferences not found"); + } + + // Build canonical metadata + const canonical = buildCanonicalMetadata( + comic.sourcedMetadata, + convertPreferences(preferences) + ); + + // Update comic with canonical metadata + comic.canonicalMetadata = canonical; + await comic.save(); + + return comic; + } catch (error) { + console.error("Error resolving metadata:", error); + throw new Error("Failed to resolve metadata"); + } + }, + + /** + * Bulk resolve metadata for multiple comics + */ + bulkResolveMetadata: async ( + _: any, + { comicIds }: { comicIds: string[] } + ) => { + try { + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + if (!preferences) { + throw new Error("User preferences not found"); + } + + const resolvedComics = []; + + for (const comicId of comicIds) { + const comic = await Comic.findById(comicId); + if (comic) { + const canonical = buildCanonicalMetadata( + comic.sourcedMetadata, + convertPreferences(preferences) + ); + + comic.canonicalMetadata = canonical; + await comic.save(); + resolvedComics.push(comic); + } + } + + return resolvedComics; + } catch (error) { + console.error("Error bulk resolving metadata:", error); + throw new Error("Failed to bulk resolve metadata"); + } + }, + + /** + * Remove user override for a field + */ + removeMetadataOverride: async ( + _: any, + { comicId, field }: { comicId: string; field: string } + ) => { + try { + const comic = await Comic.findById(comicId); + if (!comic) { + throw new Error("Comic not found"); + } + + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + if (!preferences) { + throw new Error("User preferences not found"); + } + + // Re-resolve the field without user override + const candidates = extractCandidatesForField( + field, + comic.sourcedMetadata + ).filter((c) => !c.userOverride); + + const resolved = resolveMetadataField( + field, + candidates, + convertPreferences(preferences) + ); + + if (resolved) { + const fieldPath = `canonicalMetadata.${field}`; + await Comic.findByIdAndUpdate(comicId, { + $set: { [fieldPath]: resolved }, + }); + } + + const updatedComic = await Comic.findById(comicId); + return updatedComic; + } catch (error) { + console.error("Error removing metadata override:", error); + throw new Error("Failed to remove metadata override"); + } + }, + + /** + * Refresh metadata from a specific source + */ + refreshMetadataFromSource: async ( + _: any, + { comicId, source }: { comicId: string; source: MetadataSource } + ) => { + try { + // This would trigger a re-fetch from the external source + // Implementation depends on your existing metadata fetching services + throw new Error("Not implemented - requires integration with metadata services"); + } catch (error) { + console.error("Error refreshing metadata from source:", error); + throw new Error("Failed to refresh metadata from source"); + } + }, + + /** + * Import a new comic with automatic metadata resolution + */ + importComic: async (_: any, { input }: { input: any }) => { + try { + console.log("Importing comic via GraphQL:", input.filePath); + + // 1. Check if comic already exists + const existingComic = await Comic.findOne({ + "rawFileDetails.name": input.rawFileDetails?.name, + }); + + if (existingComic) { + return { + success: false, + comic: existingComic, + message: "Comic already exists in the library", + canonicalMetadataResolved: false, + }; + } + + // 2. Prepare comic data + const comicData: any = { + importStatus: { + isImported: true, + tagged: false, + }, + }; + + // Add raw file details + if (input.rawFileDetails) { + comicData.rawFileDetails = input.rawFileDetails; + } + + // Add inferred metadata + if (input.inferredMetadata) { + comicData.inferredMetadata = input.inferredMetadata; + } + + // Add sourced metadata + if (input.sourcedMetadata) { + comicData.sourcedMetadata = {}; + + if (input.sourcedMetadata.comicInfo) { + comicData.sourcedMetadata.comicInfo = JSON.parse( + input.sourcedMetadata.comicInfo + ); + } + if (input.sourcedMetadata.comicvine) { + comicData.sourcedMetadata.comicvine = JSON.parse( + input.sourcedMetadata.comicvine + ); + } + if (input.sourcedMetadata.metron) { + comicData.sourcedMetadata.metron = JSON.parse( + input.sourcedMetadata.metron + ); + } + if (input.sourcedMetadata.gcd) { + comicData.sourcedMetadata.gcd = JSON.parse( + input.sourcedMetadata.gcd + ); + } + if (input.sourcedMetadata.locg) { + comicData.sourcedMetadata.locg = input.sourcedMetadata.locg; + } + } + + // Add wanted information + if (input.wanted) { + comicData.wanted = input.wanted; + } + + // Add acquisition information + if (input.acquisition) { + comicData.acquisition = input.acquisition; + } + + // 3. Create the comic document + const comic = await Comic.create(comicData); + console.log(`Comic created with ID: ${comic._id}`); + + // 4. Check if auto-resolution is enabled + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + + let canonicalMetadataResolved = false; + + if ( + preferences?.autoMerge?.enabled && + preferences?.autoMerge?.onImport + ) { + console.log("Auto-resolving canonical metadata..."); + + // Build canonical metadata + const canonical = buildCanonicalMetadata( + comic.sourcedMetadata, + convertPreferences(preferences) + ); + + // Update comic with canonical metadata + comic.canonicalMetadata = canonical; + await comic.save(); + + canonicalMetadataResolved = true; + console.log("Canonical metadata resolved successfully"); + } + + return { + success: true, + comic, + message: "Comic imported successfully", + canonicalMetadataResolved, + }; + } catch (error) { + console.error("Error importing comic:", error); + throw new Error(`Failed to import comic: ${error.message}`); + } + }, + + /** + * Update sourced metadata and trigger resolution + */ + updateSourcedMetadata: async ( + _: any, + { + comicId, + source, + metadata, + }: { comicId: string; source: MetadataSource; metadata: string } + ) => { + try { + const comic = await Comic.findById(comicId); + if (!comic) { + throw new Error("Comic not found"); + } + + // Parse and update the sourced metadata + const parsedMetadata = JSON.parse(metadata); + const sourceKey = source.toLowerCase(); + + if (!comic.sourcedMetadata) { + comic.sourcedMetadata = {}; + } + + comic.sourcedMetadata[sourceKey] = parsedMetadata; + await comic.save(); + + console.log( + `Updated ${source} metadata for comic ${comicId}` + ); + + // Check if auto-resolution is enabled + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + + if ( + preferences?.autoMerge?.enabled && + preferences?.autoMerge?.onMetadataUpdate + ) { + console.log("Auto-resolving canonical metadata after update..."); + + // Build canonical metadata + const canonical = buildCanonicalMetadata( + comic.sourcedMetadata, + convertPreferences(preferences) + ); + + // Update comic with canonical metadata + comic.canonicalMetadata = canonical; + await comic.save(); + + console.log("Canonical metadata resolved after update"); + } + + return comic; + } catch (error) { + console.error("Error updating sourced metadata:", error); + throw new Error(`Failed to update sourced metadata: ${error.message}`); + } + }, + }, + + // Field resolvers + Comic: { + id: (comic: any) => comic._id.toString(), + sourcedMetadata: (comic: any) => ({ + comicInfo: JSON.stringify(comic.sourcedMetadata?.comicInfo || {}), + comicvine: JSON.stringify(comic.sourcedMetadata?.comicvine || {}), + metron: JSON.stringify(comic.sourcedMetadata?.metron || {}), + gcd: JSON.stringify(comic.sourcedMetadata?.gcd || {}), + locg: comic.sourcedMetadata?.locg || null, + }), + }, + + UserPreferences: { + id: (prefs: any) => prefs._id.toString(), + fieldPreferences: (prefs: any) => { + if (!prefs.fieldPreferences) return []; + return Array.from(prefs.fieldPreferences.entries()).map( + ([field, preferredSource]) => ({ + field, + preferredSource, + }) + ); + }, + sourcePriorities: (prefs: any) => { + return prefs.sourcePriorities.map((sp: any) => ({ + ...sp, + fieldOverrides: sp.fieldOverrides + ? Array.from(sp.fieldOverrides.entries()).map(([field, priority]) => ({ + field, + priority, + })) + : [], + })); + }, + }, +}; + +/** + * Helper: Extract candidates for a field from sourced metadata + */ +function extractCandidatesForField( + field: string, + sourcedMetadata: any +): MetadataField[] { + const candidates: MetadataField[] = []; + + // Map field names to source paths + const mappings: Record = { + title: [ + { source: MetadataSource.COMICVINE, path: "name", data: sourcedMetadata.comicvine }, + { source: MetadataSource.COMICINFO_XML, path: "Title", data: sourcedMetadata.comicInfo }, + { source: MetadataSource.LOCG, path: "name", data: sourcedMetadata.locg }, + ], + series: [ + { source: MetadataSource.COMICVINE, path: "volumeInformation.name", data: sourcedMetadata.comicvine }, + { source: MetadataSource.COMICINFO_XML, path: "Series", data: sourcedMetadata.comicInfo }, + ], + issueNumber: [ + { source: MetadataSource.COMICVINE, path: "issue_number", data: sourcedMetadata.comicvine }, + { source: MetadataSource.COMICINFO_XML, path: "Number", data: sourcedMetadata.comicInfo }, + ], + description: [ + { source: MetadataSource.COMICVINE, path: "description", data: sourcedMetadata.comicvine }, + { source: MetadataSource.LOCG, path: "description", data: sourcedMetadata.locg }, + { source: MetadataSource.COMICINFO_XML, path: "Summary", data: sourcedMetadata.comicInfo }, + ], + publisher: [ + { source: MetadataSource.COMICVINE, path: "volumeInformation.publisher.name", data: sourcedMetadata.comicvine }, + { source: MetadataSource.LOCG, path: "publisher", data: sourcedMetadata.locg }, + { source: MetadataSource.COMICINFO_XML, path: "Publisher", data: sourcedMetadata.comicInfo }, + ], + }; + + const fieldMappings = mappings[field] || []; + + for (const mapping of fieldMappings) { + if (!mapping.data) continue; + + const value = getNestedValue(mapping.data, mapping.path); + if (value !== null && value !== undefined) { + candidates.push({ + value, + provenance: { + source: mapping.source, + confidence: 0.9, + fetchedAt: new Date(), + }, + }); + } + } + + return candidates; +} + +/** + * Helper: Get nested value from object + */ +function getNestedValue(obj: any, path: string): any { + return path.split(".").reduce((current, key) => current?.[key], obj); +} + +/** + * Helper: Convert UserPreferences model to ResolutionPreferences + */ +function convertPreferences(prefs: any): ResolutionPreferences { + return { + sourcePriorities: prefs.sourcePriorities.map((sp: any) => ({ + source: sp.source, + priority: sp.priority, + enabled: sp.enabled, + fieldOverrides: sp.fieldOverrides, + })), + conflictResolution: prefs.conflictResolution, + minConfidenceThreshold: prefs.minConfidenceThreshold, + preferRecent: prefs.preferRecent, + fieldPreferences: prefs.fieldPreferences, + }; +} + +/** + * Helper: Get resolution reason for display + */ +function getResolutionReason( + resolved: MetadataField | null, + candidates: MetadataField[], + preferences: any +): string { + if (!resolved) return "No valid candidates"; + + if (resolved.userOverride) { + return "User override"; + } + + const priority = preferences.getSourcePriority(resolved.provenance.source); + return `Resolved using ${resolved.provenance.source} (priority: ${priority}, confidence: ${resolved.provenance.confidence})`; +} + +/** + * Helper: Apply preferences input to existing preferences + */ +function applyPreferencesInput(prefs: any, input: any): any { + const updated = { ...prefs.toObject() }; + + if (input.sourcePriorities) { + updated.sourcePriorities = input.sourcePriorities; + } + if (input.conflictResolution) { + updated.conflictResolution = input.conflictResolution; + } + if (input.minConfidenceThreshold !== undefined) { + updated.minConfidenceThreshold = input.minConfidenceThreshold; + } + if (input.preferRecent !== undefined) { + updated.preferRecent = input.preferRecent; + } + + return updated; +} diff --git a/models/graphql/typedef.ts b/models/graphql/typedef.ts index da0ba7d..b6a2bfc 100644 --- a/models/graphql/typedef.ts +++ b/models/graphql/typedef.ts @@ -1,24 +1,442 @@ import { gql } from "graphql-tag"; export const typeDefs = gql` - type Query { - comic(id: ID!): Comic - comics(limit: Int = 10): [Comic] - } + # Metadata source enumeration + enum MetadataSource { + COMICVINE + METRON + GRAND_COMICS_DATABASE + LOCG + COMICINFO_XML + MANUAL + } - type Comic { - id: ID! - title: String - volume: Int - issueNumber: String - publicationDate: String - coverUrl: String - creators: [Creator] - source: String - } + # Conflict resolution strategy + enum ConflictResolutionStrategy { + PRIORITY + CONFIDENCE + RECENCY + MANUAL + HYBRID + } - type Creator { - name: String - role: String - } + # Provenance information for metadata + type Provenance { + source: MetadataSource! + sourceId: String + confidence: Float! + fetchedAt: String! + url: String + } + + # Metadata field with provenance + type MetadataField { + value: String + provenance: Provenance! + userOverride: Boolean + } + + # Array metadata field with provenance + type MetadataArrayField { + values: [String!]! + provenance: Provenance! + userOverride: Boolean + } + + # Creator with role and provenance + type Creator { + name: String! + role: String! + provenance: Provenance! + } + + # Canonical metadata - resolved from multiple sources + type CanonicalMetadata { + # Core identifiers + title: MetadataField + series: MetadataField + volume: MetadataField + issueNumber: MetadataField + + # Publication info + publisher: MetadataField + publicationDate: MetadataField + coverDate: MetadataField + + # Content + description: MetadataField + storyArcs: [MetadataField!] + characters: [MetadataField!] + teams: [MetadataField!] + locations: [MetadataField!] + + # Creators + creators: [Creator!] + + # Classification + genres: [MetadataField!] + tags: [MetadataField!] + ageRating: MetadataField + + # Physical/Digital properties + pageCount: MetadataField + format: MetadataField + + # Ratings + communityRating: MetadataField + + # Cover + coverImage: MetadataField + } + + # Raw file details + type RawFileDetails { + name: String + filePath: String + fileSize: Int + extension: String + mimeType: String + containedIn: String + pageCount: Int + archive: Archive + cover: Cover + } + + type Archive { + uncompressed: Boolean + expandedPath: String + } + + type Cover { + filePath: String + stats: String + } + + # Import status + type ImportStatus { + isImported: Boolean + tagged: Boolean + matchedResult: MatchedResult + } + + type MatchedResult { + score: String + } + + # Main Comic type with canonical metadata + type Comic { + id: ID! + + # Canonical metadata (resolved from all sources) + canonicalMetadata: CanonicalMetadata + + # Raw sourced metadata (for transparency) + sourcedMetadata: SourcedMetadata + + # File information + rawFileDetails: RawFileDetails + + # Import status + importStatus: ImportStatus + + # Timestamps + createdAt: String + updatedAt: String + } + + # Sourced metadata (raw data from each source) + type SourcedMetadata { + comicInfo: String # JSON string + comicvine: String # JSON string + metron: String # JSON string + gcd: String # JSON string + locg: LOCGMetadata + } + + type LOCGMetadata { + name: String + publisher: String + url: String + cover: String + description: String + price: String + rating: Float + pulls: Int + potw: Int + } + + # Source priority configuration + type SourcePriority { + source: MetadataSource! + priority: Int! + enabled: Boolean! + fieldOverrides: [FieldOverride!] + } + + type FieldOverride { + field: String! + priority: Int! + } + + # User preferences for metadata resolution + type UserPreferences { + id: ID! + userId: String! + sourcePriorities: [SourcePriority!]! + conflictResolution: ConflictResolutionStrategy! + minConfidenceThreshold: Float! + preferRecent: Boolean! + fieldPreferences: [FieldPreference!] + autoMerge: AutoMergeSettings! + createdAt: String + updatedAt: String + } + + type FieldPreference { + field: String! + preferredSource: MetadataSource! + } + + type AutoMergeSettings { + enabled: Boolean! + onImport: Boolean! + onMetadataUpdate: Boolean! + } + + # Pagination + type ComicConnection { + comics: [Comic!]! + totalCount: Int! + pageInfo: PageInfo! + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + currentPage: Int! + totalPages: Int! + } + + # Metadata conflict information + type MetadataConflict { + field: String! + candidates: [MetadataField!]! + resolved: MetadataField + resolutionReason: String! + } + + # Queries + type Query { + # Get a single comic by ID + comic(id: ID!): Comic + + # List comics with pagination and filtering + comics( + limit: Int = 10 + page: Int = 1 + search: String + publisher: String + series: String + ): ComicConnection! + + # Get user preferences + userPreferences(userId: String = "default"): UserPreferences + + # Analyze metadata conflicts for a comic + analyzeMetadataConflicts(comicId: ID!): [MetadataConflict!]! + + # Preview canonical metadata resolution without saving + previewCanonicalMetadata( + comicId: ID! + preferences: UserPreferencesInput + ): CanonicalMetadata + } + + # Mutations + type Mutation { + # Update user preferences + updateUserPreferences( + userId: String = "default" + preferences: UserPreferencesInput! + ): UserPreferences! + + # Manually set a metadata field (creates user override) + setMetadataField( + comicId: ID! + field: String! + value: String! + ): Comic! + + # Trigger metadata resolution for a comic + resolveMetadata(comicId: ID!): Comic! + + # Bulk resolve metadata for multiple comics + bulkResolveMetadata(comicIds: [ID!]!): [Comic!]! + + # Remove user override for a field + removeMetadataOverride(comicId: ID!, field: String!): Comic! + + # Refresh metadata from a specific source + refreshMetadataFromSource( + comicId: ID! + source: MetadataSource! + ): Comic! + + # Import a new comic with automatic metadata resolution + importComic(input: ImportComicInput!): ImportComicResult! + + # Update sourced metadata and trigger resolution + updateSourcedMetadata( + comicId: ID! + source: MetadataSource! + metadata: String! + ): Comic! + } + + # Input types + input UserPreferencesInput { + sourcePriorities: [SourcePriorityInput!] + conflictResolution: ConflictResolutionStrategy + minConfidenceThreshold: Float + preferRecent: Boolean + fieldPreferences: [FieldPreferenceInput!] + autoMerge: AutoMergeSettingsInput + } + + input SourcePriorityInput { + source: MetadataSource! + priority: Int! + enabled: Boolean! + fieldOverrides: [FieldOverrideInput!] + } + + input FieldOverrideInput { + field: String! + priority: Int! + } + + input FieldPreferenceInput { + field: String! + preferredSource: MetadataSource! + } + + input AutoMergeSettingsInput { + enabled: Boolean + onImport: Boolean + onMetadataUpdate: Boolean + } + + # Import comic input + input ImportComicInput { + filePath: String! + fileSize: Int + sourcedMetadata: SourcedMetadataInput + inferredMetadata: InferredMetadataInput + rawFileDetails: RawFileDetailsInput + wanted: WantedInput + acquisition: AcquisitionInput + } + + input SourcedMetadataInput { + comicInfo: String + comicvine: String + metron: String + gcd: String + locg: LOCGMetadataInput + } + + input LOCGMetadataInput { + name: String + publisher: String + url: String + cover: String + description: String + price: String + rating: Float + pulls: Int + potw: Int + } + + input InferredMetadataInput { + issue: IssueInput + } + + input IssueInput { + name: String + number: Int + year: String + subtitle: String + } + + input RawFileDetailsInput { + name: String! + filePath: String! + fileSize: Int + extension: String + mimeType: String + containedIn: String + pageCount: Int + archive: ArchiveInput + cover: CoverInput + } + + input ArchiveInput { + uncompressed: Boolean + expandedPath: String + } + + input CoverInput { + filePath: String + stats: String + } + + input WantedInput { + source: String + markEntireVolumeWanted: Boolean + issues: [WantedIssueInput!] + volume: WantedVolumeInput + } + + input WantedIssueInput { + id: Int + url: String + image: [String!] + coverDate: String + issueNumber: String + } + + input WantedVolumeInput { + id: Int + url: String + image: [String!] + name: String + } + + input AcquisitionInput { + source: AcquisitionSourceInput + directconnect: DirectConnectInput + } + + input AcquisitionSourceInput { + wanted: Boolean + name: String + } + + input DirectConnectInput { + downloads: [DirectConnectBundleInput!] + } + + input DirectConnectBundleInput { + bundleId: Int + name: String + size: String + } + + # Import result + type ImportComicResult { + success: Boolean! + comic: Comic + message: String + canonicalMetadataResolved: Boolean! + } `; diff --git a/models/userpreferences.model.ts b/models/userpreferences.model.ts new file mode 100644 index 0000000..d4d1dd9 --- /dev/null +++ b/models/userpreferences.model.ts @@ -0,0 +1,164 @@ +const mongoose = require("mongoose"); +import { MetadataSource } from "./comic.model"; + +// Source priority configuration +const SourcePrioritySchema = new mongoose.Schema( + { + _id: false, + source: { + type: String, + enum: Object.values(MetadataSource), + required: true, + }, + priority: { + type: Number, + required: true, + min: 1, + }, // Lower number = higher priority (1 is highest) + enabled: { + type: Boolean, + default: true, + }, + // Field-specific overrides + fieldOverrides: { + type: Map, + of: Number, // field name -> priority for that specific field + default: new Map(), + }, + }, + { _id: false } +); + +// Conflict resolution strategy +export enum ConflictResolutionStrategy { + PRIORITY = "priority", // Use source priority + CONFIDENCE = "confidence", // Use confidence score + RECENCY = "recency", // Use most recently fetched + MANUAL = "manual", // Always prefer manual entries + HYBRID = "hybrid", // Combine priority and confidence +} + +// User preferences for metadata resolution +const UserPreferencesSchema = new mongoose.Schema( + { + userId: { + type: String, + required: true, + unique: true, + default: "default", + }, // Support for multi-user in future + + // Source priority configuration + sourcePriorities: { + type: [SourcePrioritySchema], + default: [ + { + source: MetadataSource.MANUAL, + priority: 1, + enabled: true, + }, + { + source: MetadataSource.COMICVINE, + priority: 2, + enabled: true, + }, + { + source: MetadataSource.METRON, + priority: 3, + enabled: true, + }, + { + source: MetadataSource.GRAND_COMICS_DATABASE, + priority: 4, + enabled: true, + }, + { + source: MetadataSource.LOCG, + priority: 5, + enabled: true, + }, + { + source: MetadataSource.COMICINFO_XML, + priority: 6, + enabled: true, + }, + ], + }, + + // Global conflict resolution strategy + conflictResolution: { + type: String, + enum: Object.values(ConflictResolutionStrategy), + default: ConflictResolutionStrategy.HYBRID, + }, + + // Minimum confidence threshold (0-1) + minConfidenceThreshold: { + type: Number, + min: 0, + max: 1, + default: 0.5, + }, + + // Prefer newer data when confidence/priority are equal + preferRecent: { + type: Boolean, + default: true, + }, + + // Field-specific preferences + fieldPreferences: { + // Always prefer certain sources for specific fields + // e.g., { "description": "comicvine", "coverImage": "locg" } + type: Map, + of: String, + default: new Map(), + }, + + // Auto-merge settings + autoMerge: { + enabled: { type: Boolean, default: true }, + onImport: { type: Boolean, default: true }, + onMetadataUpdate: { type: Boolean, default: true }, + }, + }, + { timestamps: true } +); + +// Helper method to get priority for a source +UserPreferencesSchema.methods.getSourcePriority = function ( + source: MetadataSource, + field?: string +): number { + const sourcePriority = this.sourcePriorities.find( + (sp: any) => sp.source === source && sp.enabled + ); + + if (!sourcePriority) { + return Infinity; // Disabled or not configured + } + + // Check for field-specific override + if (field && sourcePriority.fieldOverrides.has(field)) { + return sourcePriority.fieldOverrides.get(field); + } + + return sourcePriority.priority; +}; + +// Helper method to check if source is enabled +UserPreferencesSchema.methods.isSourceEnabled = function ( + source: MetadataSource +): boolean { + const sourcePriority = this.sourcePriorities.find( + (sp: any) => sp.source === source + ); + return sourcePriority ? sourcePriority.enabled : false; +}; + +const UserPreferences = mongoose.model( + "UserPreferences", + UserPreferencesSchema +); + +export default UserPreferences; diff --git a/services/api.service.ts b/services/api.service.ts index 698d1d5..84eb856 100644 --- a/services/api.service.ts +++ b/services/api.service.ts @@ -55,12 +55,120 @@ export default class ApiService extends Service { mappingPolicy: "all", logging: true, }, + { + path: "/graphql", + cors: { + origin: "*", + methods: ["GET", "OPTIONS", "POST"], + allowedHeaders: ["*"], + exposedHeaders: [], + credentials: false, + maxAge: 3600, + }, + aliases: { + "POST /": async (req: any, res: any) => { + try { + const { query, variables, operationName } = req.body; + const result = await req.$service.broker.call("graphql.query", { + query, + variables, + operationName, + }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(result)); + } catch (error: any) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + errors: [{ message: error.message }], + }) + ); + } + }, + "GET /": async (req: any, res: any) => { + // Support GraphQL Playground or introspection queries via GET + const query = req.$params.query; + const variables = req.$params.variables + ? JSON.parse(req.$params.variables) + : undefined; + const operationName = req.$params.operationName; + + if (query) { + try { + const result = await req.$service.broker.call("graphql.query", { + query, + variables, + operationName, + }); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(result)); + } catch (error: any) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + errors: [{ message: error.message }], + }) + ); + } + } else { + // Return GraphQL Playground HTML + res.setHeader("Content-Type", "text/html"); + res.end(` + + + + GraphQL Playground + + + + + +
+ + + + `); + } + }, + }, + mappingPolicy: "restrict", + bodyParsers: { + json: { strict: false, limit: "1MB" }, + }, + }, { path: "/userdata", + cors: { + origin: "*", + methods: ["GET", "OPTIONS"], + allowedHeaders: ["*"], + exposedHeaders: [], + credentials: false, + maxAge: 3600, + }, use: [ApiGateway.serveStatic(path.resolve("./userdata"))], }, { path: "/comics", + cors: { + origin: "*", + methods: ["GET", "OPTIONS"], + allowedHeaders: ["*"], + exposedHeaders: [], + credentials: false, + maxAge: 3600, + }, use: [ApiGateway.serveStatic(path.resolve("./comics"))], }, { diff --git a/services/graphql.service.ts b/services/graphql.service.ts new file mode 100644 index 0000000..c9fc853 --- /dev/null +++ b/services/graphql.service.ts @@ -0,0 +1,213 @@ +import { Service, ServiceBroker } from "moleculer"; +import { ApolloServer } from "@apollo/server"; +import { typeDefs } from "../models/graphql/typedef"; +import { resolvers } from "../models/graphql/resolvers"; + +/** + * GraphQL Service + * Provides a GraphQL API for canonical metadata queries and mutations + * Integrates Apollo Server with Moleculer + */ +export default class GraphQLService extends Service { + private apolloServer?: ApolloServer; + + public constructor(broker: ServiceBroker) { + super(broker); + + this.parseServiceSchema({ + name: "graphql", + + settings: { + // GraphQL endpoint path + path: "/graphql", + }, + + actions: { + /** + * Execute a GraphQL query + */ + query: { + params: { + query: "string", + variables: { type: "object", optional: true }, + operationName: { type: "string", optional: true }, + }, + async handler(ctx: any) { + try { + if (!this.apolloServer) { + throw new Error("Apollo Server not initialized"); + } + + const { query, variables, operationName } = ctx.params; + + const response = await this.apolloServer.executeOperation( + { + query, + variables, + operationName, + }, + { + contextValue: { + broker: this.broker, + ctx, + }, + } + ); + + if (response.body.kind === "single") { + return response.body.singleResult; + } + + return response; + } catch (error) { + this.logger.error("GraphQL query error:", error); + throw error; + } + }, + }, + + /** + * Get GraphQL schema + */ + getSchema: { + async handler() { + return { + typeDefs: typeDefs.loc?.source.body || "", + }; + }, + }, + }, + + methods: { + /** + * Initialize Apollo Server + */ + async initApolloServer() { + this.logger.info("Initializing Apollo Server..."); + + this.apolloServer = new ApolloServer({ + typeDefs, + resolvers, + introspection: true, // Enable GraphQL Playground in development + formatError: (error) => { + this.logger.error("GraphQL Error:", error); + return { + message: error.message, + locations: error.locations, + path: error.path, + extensions: { + code: error.extensions?.code, + }, + }; + }, + }); + + await this.apolloServer.start(); + this.logger.info("Apollo Server started successfully"); + }, + + /** + * Stop Apollo Server + */ + async stopApolloServer() { + if (this.apolloServer) { + this.logger.info("Stopping Apollo Server..."); + await this.apolloServer.stop(); + this.apolloServer = undefined; + this.logger.info("Apollo Server stopped"); + } + }, + }, + + events: { + /** + * Trigger metadata resolution when new metadata is imported + */ + "metadata.imported": { + async handler(ctx: any) { + const { comicId, source } = ctx.params; + this.logger.info( + `Metadata imported for comic ${comicId} from ${source}` + ); + + // Optionally trigger auto-resolution if enabled + try { + const UserPreferences = require("../models/userpreferences.model").default; + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + + if ( + preferences?.autoMerge?.enabled && + preferences?.autoMerge?.onMetadataUpdate + ) { + this.logger.info( + `Auto-resolving metadata for comic ${comicId}` + ); + await this.broker.call("graphql.query", { + query: ` + mutation ResolveMetadata($comicId: ID!) { + resolveMetadata(comicId: $comicId) { + id + } + } + `, + variables: { comicId }, + }); + } + } catch (error) { + this.logger.error("Error in auto-resolution:", error); + } + }, + }, + + /** + * Trigger metadata resolution when comic is imported + */ + "comic.imported": { + async handler(ctx: any) { + const { comicId } = ctx.params; + this.logger.info(`Comic imported: ${comicId}`); + + // Optionally trigger auto-resolution if enabled + try { + const UserPreferences = require("../models/userpreferences.model").default; + const preferences = await UserPreferences.findOne({ + userId: "default", + }); + + if ( + preferences?.autoMerge?.enabled && + preferences?.autoMerge?.onImport + ) { + this.logger.info( + `Auto-resolving metadata for newly imported comic ${comicId}` + ); + await this.broker.call("graphql.query", { + query: ` + mutation ResolveMetadata($comicId: ID!) { + resolveMetadata(comicId: $comicId) { + id + } + } + `, + variables: { comicId }, + }); + } + } catch (error) { + this.logger.error("Error in auto-resolution on import:", error); + } + }, + }, + }, + + started: async function (this: any) { + await this.initApolloServer(); + }, + + stopped: async function (this: any) { + await this.stopApolloServer(); + }, + }); + } +} diff --git a/services/library.service.ts b/services/library.service.ts index b918960..1910080 100644 --- a/services/library.service.ts +++ b/services/library.service.ts @@ -58,6 +58,7 @@ import klaw from "klaw"; import path from "path"; import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories"; import AirDCPPSocket from "../shared/airdcpp.socket"; +import { importComicViaGraphQL } from "../utils/import.graphql.utils"; console.log(`MONGO -> ${process.env.MONGO_URI}`); export default class ImportService extends Service { @@ -256,61 +257,119 @@ export default class ImportService extends Service { sourcedMetadata: { comicvine?: any; locg?: {}; + comicInfo?: any; + metron?: any; + gcd?: any; }; inferredMetadata: { issue: Object; }; rawFileDetails: { name: string; + filePath: string; + fileSize?: number; + extension?: string; + mimeType?: string; + containedIn?: string; + cover?: any; }; - wanted: { + wanted?: { issues: []; volume: { id: number }; source: string; markEntireVolumeWanted: Boolean; }; - acquisition: { - directconnect: { + acquisition?: { + source?: { + wanted?: boolean; + name?: string; + }; + directconnect?: { downloads: []; }; }; + importStatus?: { + isImported: boolean; + tagged: boolean; + matchedResult?: { + score: string; + }; + }; }; }> ) { try { + console.log( + "[GraphQL Import] Processing import via GraphQL..." + ); console.log( JSON.stringify(ctx.params.payload, null, 4) ); const { payload } = ctx.params; const { wanted } = payload; - console.log("Saving to Mongo..."); - + // Use GraphQL import for new comics if ( !wanted || !wanted.volume || !wanted.volume.id ) { console.log( - "No valid identifier for upsert. Attempting to create a new document with minimal data..." + "[GraphQL Import] No valid identifier - creating new comic via GraphQL" ); - const newDocument = new Comic(payload); // Using the entire payload for the new document - await newDocument.save(); - return { - success: true, - message: - "New document created due to lack of valid identifiers.", - data: newDocument, - }; + // Import via GraphQL + const result = await importComicViaGraphQL( + this.broker, + { + filePath: payload.rawFileDetails.filePath, + fileSize: payload.rawFileDetails.fileSize, + rawFileDetails: payload.rawFileDetails, + inferredMetadata: payload.inferredMetadata, + sourcedMetadata: payload.sourcedMetadata, + wanted: payload.wanted ? { + ...payload.wanted, + markEntireVolumeWanted: Boolean(payload.wanted.markEntireVolumeWanted) + } : undefined, + acquisition: payload.acquisition, + } + ); + + if (result.success) { + console.log( + `[GraphQL Import] Comic imported successfully: ${result.comic.id}` + ); + console.log( + `[GraphQL Import] Canonical metadata resolved: ${result.canonicalMetadataResolved}` + ); + + return { + success: true, + message: result.message, + data: result.comic, + }; + } else { + console.log( + `[GraphQL Import] Import returned success=false: ${result.message}` + ); + return { + success: false, + message: result.message, + data: result.comic, + }; + } } + // For comics with wanted.volume.id, use upsert logic + console.log( + "[GraphQL Import] Comic has wanted.volume.id - using upsert logic" + ); + let condition = { "wanted.volume.id": wanted.volume.id, }; let update: any = { - // Using 'any' to bypass strict type checks; alternatively, define a more accurate type $set: { rawFileDetails: payload.rawFileDetails, inferredMetadata: payload.inferredMetadata, @@ -340,18 +399,45 @@ export default class ImportService extends Service { update, options ); + console.log( - "Operation completed. Document updated or inserted:", - result + "[GraphQL Import] Document upserted:", + result._id ); + // Trigger canonical metadata resolution via GraphQL + try { + console.log( + "[GraphQL Import] Triggering metadata resolution..." + ); + await this.broker.call("graphql.query", { + query: ` + mutation ResolveMetadata($comicId: ID!) { + resolveMetadata(comicId: $comicId) { + id + } + } + `, + variables: { comicId: result._id.toString() }, + }); + console.log( + "[GraphQL Import] Metadata resolution triggered" + ); + } catch (resolveError) { + console.error( + "[GraphQL Import] Error resolving metadata:", + resolveError + ); + // Don't fail the import if resolution fails + } + return { success: true, message: "Document successfully upserted.", data: result, }; } catch (error) { - console.log(error); + console.error("[GraphQL Import] Error:", error); throw new Errors.MoleculerError( "Operation failed.", 500 diff --git a/utils/import.graphql.utils.ts b/utils/import.graphql.utils.ts new file mode 100644 index 0000000..215ff59 --- /dev/null +++ b/utils/import.graphql.utils.ts @@ -0,0 +1,391 @@ +/** + * GraphQL Import Utilities + * Helper functions for importing comics using GraphQL mutations + */ + +import { ServiceBroker } from "moleculer"; + +/** + * Import a comic using GraphQL mutation + */ +export async function importComicViaGraphQL( + broker: ServiceBroker, + importData: { + filePath: string; + fileSize?: number; + sourcedMetadata?: { + comicInfo?: any; + comicvine?: any; + metron?: any; + gcd?: any; + locg?: any; + }; + inferredMetadata?: { + issue: { + name?: string; + number?: number; + year?: string; + subtitle?: string; + }; + }; + rawFileDetails: { + name: string; + filePath: string; + fileSize?: number; + extension?: string; + mimeType?: string; + containedIn?: string; + pageCount?: number; + }; + wanted?: { + source?: string; + markEntireVolumeWanted?: boolean; + issues?: any[]; + volume?: any; + }; + acquisition?: { + source?: { + wanted?: boolean; + name?: string; + }; + directconnect?: { + downloads?: any[]; + }; + }; + } +): Promise<{ + success: boolean; + comic: any; + message: string; + canonicalMetadataResolved: boolean; +}> { + const mutation = ` + mutation ImportComic($input: ImportComicInput!) { + importComic(input: $input) { + success + message + canonicalMetadataResolved + comic { + id + canonicalMetadata { + title { value, provenance { source, confidence } } + series { value, provenance { source } } + issueNumber { value, provenance { source } } + publisher { value, provenance { source } } + description { value, provenance { source } } + } + rawFileDetails { + name + filePath + fileSize + } + } + } + } + `; + + // Prepare input + const input: any = { + filePath: importData.filePath, + rawFileDetails: importData.rawFileDetails, + }; + + if (importData.fileSize) { + input.fileSize = importData.fileSize; + } + + if (importData.inferredMetadata) { + input.inferredMetadata = importData.inferredMetadata; + } + + if (importData.sourcedMetadata) { + input.sourcedMetadata = {}; + + if (importData.sourcedMetadata.comicInfo) { + input.sourcedMetadata.comicInfo = JSON.stringify( + importData.sourcedMetadata.comicInfo + ); + } + if (importData.sourcedMetadata.comicvine) { + input.sourcedMetadata.comicvine = JSON.stringify( + importData.sourcedMetadata.comicvine + ); + } + if (importData.sourcedMetadata.metron) { + input.sourcedMetadata.metron = JSON.stringify( + importData.sourcedMetadata.metron + ); + } + if (importData.sourcedMetadata.gcd) { + input.sourcedMetadata.gcd = JSON.stringify( + importData.sourcedMetadata.gcd + ); + } + if (importData.sourcedMetadata.locg) { + input.sourcedMetadata.locg = importData.sourcedMetadata.locg; + } + } + + if (importData.wanted) { + input.wanted = importData.wanted; + } + + if (importData.acquisition) { + input.acquisition = importData.acquisition; + } + + try { + const result: any = await broker.call("graphql.query", { + query: mutation, + variables: { input }, + }); + + if (result.errors) { + console.error("GraphQL errors:", result.errors); + throw new Error(result.errors[0].message); + } + + return result.data.importComic; + } catch (error) { + console.error("Error importing comic via GraphQL:", error); + throw error; + } +} + +/** + * Update sourced metadata for a comic using GraphQL + */ +export async function updateSourcedMetadataViaGraphQL( + broker: ServiceBroker, + comicId: string, + source: string, + metadata: any +): Promise { + const mutation = ` + mutation UpdateSourcedMetadata( + $comicId: ID! + $source: MetadataSource! + $metadata: String! + ) { + updateSourcedMetadata( + comicId: $comicId + source: $source + metadata: $metadata + ) { + id + canonicalMetadata { + title { value, provenance { source } } + series { value, provenance { source } } + publisher { value, provenance { source } } + } + } + } + `; + + try { + const result: any = await broker.call("graphql.query", { + query: mutation, + variables: { + comicId, + source: source.toUpperCase(), + metadata: JSON.stringify(metadata), + }, + }); + + if (result.errors) { + console.error("GraphQL errors:", result.errors); + throw new Error(result.errors[0].message); + } + + return result.data.updateSourcedMetadata; + } catch (error) { + console.error("Error updating sourced metadata via GraphQL:", error); + throw error; + } +} + +/** + * Resolve canonical metadata for a comic using GraphQL + */ +export async function resolveMetadataViaGraphQL( + broker: ServiceBroker, + comicId: string +): Promise { + const mutation = ` + mutation ResolveMetadata($comicId: ID!) { + resolveMetadata(comicId: $comicId) { + id + canonicalMetadata { + title { value, provenance { source, confidence } } + series { value, provenance { source, confidence } } + issueNumber { value, provenance { source } } + publisher { value, provenance { source } } + description { value, provenance { source } } + coverDate { value, provenance { source } } + pageCount { value, provenance { source } } + } + } + } + `; + + try { + const result: any = await broker.call("graphql.query", { + query: mutation, + variables: { comicId }, + }); + + if (result.errors) { + console.error("GraphQL errors:", result.errors); + throw new Error(result.errors[0].message); + } + + return result.data.resolveMetadata; + } catch (error) { + console.error("Error resolving metadata via GraphQL:", error); + throw error; + } +} + +/** + * Get comic with canonical metadata using GraphQL + */ +export async function getComicViaGraphQL( + broker: ServiceBroker, + comicId: string +): Promise { + const query = ` + query GetComic($id: ID!) { + comic(id: $id) { + id + canonicalMetadata { + title { value, provenance { source, confidence, fetchedAt } } + series { value, provenance { source, confidence } } + issueNumber { value, provenance { source } } + publisher { value, provenance { source } } + description { value, provenance { source } } + coverDate { value, provenance { source } } + pageCount { value, provenance { source } } + creators { + name + role + provenance { source, confidence } + } + } + rawFileDetails { + name + filePath + fileSize + extension + pageCount + } + importStatus { + isImported + tagged + } + } + } + `; + + try { + const result: any = await broker.call("graphql.query", { + query, + variables: { id: comicId }, + }); + + if (result.errors) { + console.error("GraphQL errors:", result.errors); + throw new Error(result.errors[0].message); + } + + return result.data.comic; + } catch (error) { + console.error("Error getting comic via GraphQL:", error); + throw error; + } +} + +/** + * Analyze metadata conflicts for a comic + */ +export async function analyzeMetadataConflictsViaGraphQL( + broker: ServiceBroker, + comicId: string +): Promise { + const query = ` + query AnalyzeConflicts($comicId: ID!) { + analyzeMetadataConflicts(comicId: $comicId) { + field + candidates { + value + provenance { + source + confidence + fetchedAt + } + } + resolved { + value + provenance { + source + confidence + } + } + resolutionReason + } + } + `; + + try { + const result: any = await broker.call("graphql.query", { + query, + variables: { comicId }, + }); + + if (result.errors) { + console.error("GraphQL errors:", result.errors); + throw new Error(result.errors[0].message); + } + + return result.data.analyzeMetadataConflicts; + } catch (error) { + console.error("Error analyzing conflicts via GraphQL:", error); + throw error; + } +} + +/** + * Bulk resolve metadata for multiple comics + */ +export async function bulkResolveMetadataViaGraphQL( + broker: ServiceBroker, + comicIds: string[] +): Promise { + const mutation = ` + mutation BulkResolve($comicIds: [ID!]!) { + bulkResolveMetadata(comicIds: $comicIds) { + id + canonicalMetadata { + title { value } + series { value } + } + } + } + `; + + try { + const result: any = await broker.call("graphql.query", { + query: mutation, + variables: { comicIds }, + }); + + if (result.errors) { + console.error("GraphQL errors:", result.errors); + throw new Error(result.errors[0].message); + } + + return result.data.bulkResolveMetadata; + } catch (error) { + console.error("Error bulk resolving metadata via GraphQL:", error); + throw error; + } +} diff --git a/utils/metadata.resolution.utils.ts b/utils/metadata.resolution.utils.ts new file mode 100644 index 0000000..68b9b07 --- /dev/null +++ b/utils/metadata.resolution.utils.ts @@ -0,0 +1,436 @@ +import { MetadataSource } from "../models/comic.model"; +import { ConflictResolutionStrategy } from "../models/userpreferences.model"; + +/** + * Metadata field with provenance information + */ +export interface MetadataField { + value: any; + provenance: { + source: MetadataSource; + sourceId?: string; + confidence: number; + fetchedAt: Date; + url?: string; + }; + userOverride?: boolean; +} + +/** + * User preferences for metadata resolution + */ +export interface ResolutionPreferences { + sourcePriorities: Array<{ + source: MetadataSource; + priority: number; + enabled: boolean; + fieldOverrides?: Map; + }>; + conflictResolution: ConflictResolutionStrategy; + minConfidenceThreshold: number; + preferRecent: boolean; + fieldPreferences?: Map; +} + +/** + * Resolve a single metadata field from multiple sources + */ +export function resolveMetadataField( + fieldName: string, + candidates: MetadataField[], + preferences: ResolutionPreferences +): MetadataField | null { + // Filter out invalid candidates + const validCandidates = candidates.filter( + (c) => + c && + c.value !== null && + c.value !== undefined && + c.provenance && + c.provenance.confidence >= preferences.minConfidenceThreshold + ); + + if (validCandidates.length === 0) { + return null; + } + + // Always prefer user overrides + const userOverride = validCandidates.find((c) => c.userOverride); + if (userOverride) { + return userOverride; + } + + // Check for field-specific preference + if (preferences.fieldPreferences?.has(fieldName)) { + const preferredSource = preferences.fieldPreferences.get(fieldName); + const preferred = validCandidates.find( + (c) => c.provenance.source === preferredSource + ); + if (preferred) { + return preferred; + } + } + + // Apply resolution strategy + switch (preferences.conflictResolution) { + case ConflictResolutionStrategy.PRIORITY: + return resolveByPriority(fieldName, validCandidates, preferences); + + case ConflictResolutionStrategy.CONFIDENCE: + return resolveByConfidence(validCandidates, preferences); + + case ConflictResolutionStrategy.RECENCY: + return resolveByRecency(validCandidates); + + case ConflictResolutionStrategy.MANUAL: + // Already handled user overrides above + return resolveByPriority(fieldName, validCandidates, preferences); + + case ConflictResolutionStrategy.HYBRID: + default: + return resolveHybrid(fieldName, validCandidates, preferences); + } +} + +/** + * Resolve by source priority + */ +function resolveByPriority( + fieldName: string, + candidates: MetadataField[], + preferences: ResolutionPreferences +): MetadataField { + const sorted = [...candidates].sort((a, b) => { + const priorityA = getSourcePriority( + a.provenance.source, + fieldName, + preferences + ); + const priorityB = getSourcePriority( + b.provenance.source, + fieldName, + preferences + ); + return priorityA - priorityB; + }); + + return sorted[0]; +} + +/** + * Resolve by confidence score + */ +function resolveByConfidence( + candidates: MetadataField[], + preferences: ResolutionPreferences +): MetadataField { + const sorted = [...candidates].sort((a, b) => { + const diff = b.provenance.confidence - a.provenance.confidence; + // If confidence is equal and preferRecent is true, use recency + if (diff === 0 && preferences.preferRecent) { + return ( + b.provenance.fetchedAt.getTime() - a.provenance.fetchedAt.getTime() + ); + } + return diff; + }); + + return sorted[0]; +} + +/** + * Resolve by recency (most recently fetched) + */ +function resolveByRecency(candidates: MetadataField[]): MetadataField { + const sorted = [...candidates].sort( + (a, b) => + b.provenance.fetchedAt.getTime() - a.provenance.fetchedAt.getTime() + ); + + return sorted[0]; +} + +/** + * Hybrid resolution: combines priority and confidence + */ +function resolveHybrid( + fieldName: string, + candidates: MetadataField[], + preferences: ResolutionPreferences +): MetadataField { + // Calculate a weighted score for each candidate + const scored = candidates.map((candidate) => { + const priority = getSourcePriority( + candidate.provenance.source, + fieldName, + preferences + ); + const confidence = candidate.provenance.confidence; + + // Normalize priority (lower is better, so invert) + const maxPriority = Math.max( + ...preferences.sourcePriorities.map((sp) => sp.priority) + ); + const normalizedPriority = 1 - (priority - 1) / maxPriority; + + // Weighted score: 60% priority, 40% confidence + const score = normalizedPriority * 0.6 + confidence * 0.4; + + // Add recency bonus if enabled + let recencyBonus = 0; + if (preferences.preferRecent) { + const now = Date.now(); + const age = now - candidate.provenance.fetchedAt.getTime(); + const maxAge = 365 * 24 * 60 * 60 * 1000; // 1 year in ms + recencyBonus = Math.max(0, 1 - age / maxAge) * 0.1; // Up to 10% bonus + } + + return { + candidate, + score: score + recencyBonus, + }; + }); + + // Sort by score (highest first) + scored.sort((a, b) => b.score - a.score); + + return scored[0].candidate; +} + +/** + * Get priority for a source, considering field-specific overrides + */ +function getSourcePriority( + source: MetadataSource, + fieldName: string, + preferences: ResolutionPreferences +): number { + const sourcePriority = preferences.sourcePriorities.find( + (sp) => sp.source === source && sp.enabled + ); + + if (!sourcePriority) { + return Infinity; // Disabled or not configured + } + + // Check for field-specific override + if (sourcePriority.fieldOverrides?.has(fieldName)) { + return sourcePriority.fieldOverrides.get(fieldName)!; + } + + return sourcePriority.priority; +} + +/** + * Merge array fields (e.g., creators, tags) from multiple sources + */ +export function mergeArrayField( + fieldName: string, + sources: Array<{ source: MetadataSource; values: any[]; confidence: number }>, + preferences: ResolutionPreferences +): any[] { + const allValues: any[] = []; + const seen = new Set(); + + // Sort sources by priority + const sortedSources = [...sources].sort((a, b) => { + const priorityA = getSourcePriority(a.source, fieldName, preferences); + const priorityB = getSourcePriority(b.source, fieldName, preferences); + return priorityA - priorityB; + }); + + // Merge values, avoiding duplicates + for (const source of sortedSources) { + for (const value of source.values) { + const key = + typeof value === "string" + ? value.toLowerCase() + : JSON.stringify(value); + + if (!seen.has(key)) { + seen.add(key); + allValues.push(value); + } + } + } + + return allValues; +} + +/** + * Build canonical metadata from multiple sources + */ +export function buildCanonicalMetadata( + sourcedMetadata: { + comicInfo?: any; + comicvine?: any; + metron?: any; + gcd?: any; + locg?: any; + }, + preferences: ResolutionPreferences +): any { + const canonical: any = {}; + + // Define field mappings from each source + const fieldMappings = { + title: [ + { + source: MetadataSource.COMICVINE, + path: "name", + data: sourcedMetadata.comicvine, + }, + { + source: MetadataSource.METRON, + path: "name", + data: sourcedMetadata.metron, + }, + { + source: MetadataSource.COMICINFO_XML, + path: "Title", + data: sourcedMetadata.comicInfo, + }, + { + source: MetadataSource.LOCG, + path: "name", + data: sourcedMetadata.locg, + }, + ], + series: [ + { + source: MetadataSource.COMICVINE, + path: "volumeInformation.name", + data: sourcedMetadata.comicvine, + }, + { + source: MetadataSource.COMICINFO_XML, + path: "Series", + data: sourcedMetadata.comicInfo, + }, + ], + issueNumber: [ + { + source: MetadataSource.COMICVINE, + path: "issue_number", + data: sourcedMetadata.comicvine, + }, + { + source: MetadataSource.COMICINFO_XML, + path: "Number", + data: sourcedMetadata.comicInfo, + }, + ], + description: [ + { + source: MetadataSource.COMICVINE, + path: "description", + data: sourcedMetadata.comicvine, + }, + { + source: MetadataSource.LOCG, + path: "description", + data: sourcedMetadata.locg, + }, + { + source: MetadataSource.COMICINFO_XML, + path: "Summary", + data: sourcedMetadata.comicInfo, + }, + ], + publisher: [ + { + source: MetadataSource.COMICVINE, + path: "volumeInformation.publisher.name", + data: sourcedMetadata.comicvine, + }, + { + source: MetadataSource.LOCG, + path: "publisher", + data: sourcedMetadata.locg, + }, + { + source: MetadataSource.COMICINFO_XML, + path: "Publisher", + data: sourcedMetadata.comicInfo, + }, + ], + coverDate: [ + { + source: MetadataSource.COMICVINE, + path: "cover_date", + data: sourcedMetadata.comicvine, + }, + { + source: MetadataSource.COMICINFO_XML, + path: "CoverDate", + data: sourcedMetadata.comicInfo, + }, + ], + pageCount: [ + { + source: MetadataSource.COMICINFO_XML, + path: "PageCount", + data: sourcedMetadata.comicInfo, + }, + ], + }; + + // Resolve each field + for (const [fieldName, mappings] of Object.entries(fieldMappings)) { + const candidates: MetadataField[] = []; + + for (const mapping of mappings) { + if (!mapping.data) continue; + + const value = getNestedValue(mapping.data, mapping.path); + if (value !== null && value !== undefined) { + candidates.push({ + value, + provenance: { + source: mapping.source, + confidence: 0.9, // Default confidence + fetchedAt: new Date(), + }, + }); + } + } + + if (candidates.length > 0) { + const resolved = resolveMetadataField(fieldName, candidates, preferences); + if (resolved) { + canonical[fieldName] = resolved; + } + } + } + + return canonical; +} + +/** + * Get nested value from object using dot notation path + */ +function getNestedValue(obj: any, path: string): any { + return path.split(".").reduce((current, key) => current?.[key], obj); +} + +/** + * Compare two metadata values for equality + */ +export function metadataValuesEqual(a: any, b: any): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((val, idx) => metadataValuesEqual(val, b[idx])); + } + + if (typeof a === "object" && a !== null && b !== null) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every((key) => metadataValuesEqual(a[key], b[key])); + } + + return false; +}