🛠 graphql changes
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -72,3 +72,7 @@ erl_crash.dump
|
|||||||
temp
|
temp
|
||||||
test
|
test
|
||||||
.nova
|
.nova
|
||||||
|
CANONICAL_METADATA.md
|
||||||
|
GRAPHQL_LEVERAGE_GUIDE.md
|
||||||
|
IMPORT_WITH_GRAPHQL.md
|
||||||
|
JOBQUEUE_GRAPHQL_INTEGRATION.md
|
||||||
|
|||||||
420
examples/import-comic-graphql.example.ts
Normal file
420
examples/import-comic-graphql.example.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
@@ -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({
|
const RawFileDetailsSchema = mongoose.Schema({
|
||||||
_id: false,
|
_id: false,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -49,6 +259,7 @@ const LOCGSchema = mongoose.Schema({
|
|||||||
pulls: Number,
|
pulls: Number,
|
||||||
potw: Number,
|
potw: Number,
|
||||||
});
|
});
|
||||||
|
|
||||||
const DirectConnectBundleSchema = mongoose.Schema({
|
const DirectConnectBundleSchema = mongoose.Schema({
|
||||||
bundleId: Number,
|
bundleId: Number,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -56,6 +267,7 @@ const DirectConnectBundleSchema = mongoose.Schema({
|
|||||||
type: {},
|
type: {},
|
||||||
_id: false,
|
_id: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const wantedSchema = mongoose.Schema(
|
const wantedSchema = mongoose.Schema(
|
||||||
{
|
{
|
||||||
source: { type: String, default: null },
|
source: { type: String, default: null },
|
||||||
@@ -63,7 +275,7 @@ const wantedSchema = mongoose.Schema(
|
|||||||
issues: {
|
issues: {
|
||||||
type: [
|
type: [
|
||||||
{
|
{
|
||||||
_id: false, // Disable automatic ObjectId creation for each issue
|
_id: false,
|
||||||
id: Number,
|
id: Number,
|
||||||
url: String,
|
url: String,
|
||||||
image: { type: Array, default: [] },
|
image: { type: Array, default: [] },
|
||||||
@@ -75,7 +287,7 @@ const wantedSchema = mongoose.Schema(
|
|||||||
},
|
},
|
||||||
volume: {
|
volume: {
|
||||||
type: {
|
type: {
|
||||||
_id: false, // Disable automatic ObjectId creation for volume
|
_id: false,
|
||||||
id: Number,
|
id: Number,
|
||||||
url: String,
|
url: String,
|
||||||
image: { type: Array, default: [] },
|
image: { type: Array, default: [] },
|
||||||
@@ -85,7 +297,7 @@ const wantedSchema = mongoose.Schema(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ _id: false }
|
{ _id: false }
|
||||||
); // Disable automatic ObjectId creation for the wanted object itself
|
);
|
||||||
|
|
||||||
const ComicSchema = mongoose.Schema(
|
const ComicSchema = mongoose.Schema(
|
||||||
{
|
{
|
||||||
@@ -99,15 +311,27 @@ const ComicSchema = mongoose.Schema(
|
|||||||
userAddedMetadata: {
|
userAddedMetadata: {
|
||||||
tags: [String],
|
tags: [String],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// NEW: Canonical metadata with provenance
|
||||||
|
canonicalMetadata: {
|
||||||
|
type: CanonicalMetadataSchema,
|
||||||
|
es_indexed: true,
|
||||||
|
default: {},
|
||||||
|
},
|
||||||
|
|
||||||
|
// LEGACY: Keep existing sourced metadata for backward compatibility
|
||||||
sourcedMetadata: {
|
sourcedMetadata: {
|
||||||
comicInfo: { type: mongoose.Schema.Types.Mixed, default: {} },
|
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: {
|
locg: {
|
||||||
type: LOCGSchema,
|
type: LOCGSchema,
|
||||||
es_indexed: true,
|
es_indexed: true,
|
||||||
default: {},
|
default: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
rawFileDetails: {
|
rawFileDetails: {
|
||||||
type: RawFileDetailsSchema,
|
type: RawFileDetailsSchema,
|
||||||
es_indexed: true,
|
es_indexed: true,
|
||||||
|
|||||||
813
models/graphql/resolvers.ts
Normal file
813
models/graphql/resolvers.ts
Normal file
@@ -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<string, any> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,24 +1,442 @@
|
|||||||
import { gql } from "graphql-tag";
|
import { gql } from "graphql-tag";
|
||||||
|
|
||||||
export const typeDefs = gql`
|
export const typeDefs = gql`
|
||||||
type Query {
|
# Metadata source enumeration
|
||||||
comic(id: ID!): Comic
|
enum MetadataSource {
|
||||||
comics(limit: Int = 10): [Comic]
|
COMICVINE
|
||||||
}
|
METRON
|
||||||
|
GRAND_COMICS_DATABASE
|
||||||
|
LOCG
|
||||||
|
COMICINFO_XML
|
||||||
|
MANUAL
|
||||||
|
}
|
||||||
|
|
||||||
type Comic {
|
# Conflict resolution strategy
|
||||||
id: ID!
|
enum ConflictResolutionStrategy {
|
||||||
title: String
|
PRIORITY
|
||||||
volume: Int
|
CONFIDENCE
|
||||||
issueNumber: String
|
RECENCY
|
||||||
publicationDate: String
|
MANUAL
|
||||||
coverUrl: String
|
HYBRID
|
||||||
creators: [Creator]
|
}
|
||||||
source: String
|
|
||||||
}
|
|
||||||
|
|
||||||
type Creator {
|
# Provenance information for metadata
|
||||||
name: String
|
type Provenance {
|
||||||
role: String
|
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!
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
164
models/userpreferences.model.ts
Normal file
164
models/userpreferences.model.ts
Normal file
@@ -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;
|
||||||
@@ -55,12 +55,120 @@ export default class ApiService extends Service {
|
|||||||
mappingPolicy: "all",
|
mappingPolicy: "all",
|
||||||
logging: true,
|
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(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>GraphQL Playground</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/css/index.css" />
|
||||||
|
<link rel="shortcut icon" href="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/favicon.png" />
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/graphql-playground-react/build/static/js/middleware.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', function (event) {
|
||||||
|
GraphQLPlayground.init(document.getElementById('root'), {
|
||||||
|
endpoint: '/graphql',
|
||||||
|
settings: {
|
||||||
|
'request.credentials': 'same-origin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mappingPolicy: "restrict",
|
||||||
|
bodyParsers: {
|
||||||
|
json: { strict: false, limit: "1MB" },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/userdata",
|
path: "/userdata",
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "OPTIONS"],
|
||||||
|
allowedHeaders: ["*"],
|
||||||
|
exposedHeaders: [],
|
||||||
|
credentials: false,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
use: [ApiGateway.serveStatic(path.resolve("./userdata"))],
|
use: [ApiGateway.serveStatic(path.resolve("./userdata"))],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/comics",
|
path: "/comics",
|
||||||
|
cors: {
|
||||||
|
origin: "*",
|
||||||
|
methods: ["GET", "OPTIONS"],
|
||||||
|
allowedHeaders: ["*"],
|
||||||
|
exposedHeaders: [],
|
||||||
|
credentials: false,
|
||||||
|
maxAge: 3600,
|
||||||
|
},
|
||||||
use: [ApiGateway.serveStatic(path.resolve("./comics"))],
|
use: [ApiGateway.serveStatic(path.resolve("./comics"))],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
213
services/graphql.service.ts
Normal file
213
services/graphql.service.ts
Normal file
@@ -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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,7 @@ import klaw from "klaw";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
|
import { COMICS_DIRECTORY, USERDATA_DIRECTORY } from "../constants/directories";
|
||||||
import AirDCPPSocket from "../shared/airdcpp.socket";
|
import AirDCPPSocket from "../shared/airdcpp.socket";
|
||||||
|
import { importComicViaGraphQL } from "../utils/import.graphql.utils";
|
||||||
|
|
||||||
console.log(`MONGO -> ${process.env.MONGO_URI}`);
|
console.log(`MONGO -> ${process.env.MONGO_URI}`);
|
||||||
export default class ImportService extends Service {
|
export default class ImportService extends Service {
|
||||||
@@ -256,61 +257,119 @@ export default class ImportService extends Service {
|
|||||||
sourcedMetadata: {
|
sourcedMetadata: {
|
||||||
comicvine?: any;
|
comicvine?: any;
|
||||||
locg?: {};
|
locg?: {};
|
||||||
|
comicInfo?: any;
|
||||||
|
metron?: any;
|
||||||
|
gcd?: any;
|
||||||
};
|
};
|
||||||
inferredMetadata: {
|
inferredMetadata: {
|
||||||
issue: Object;
|
issue: Object;
|
||||||
};
|
};
|
||||||
rawFileDetails: {
|
rawFileDetails: {
|
||||||
name: string;
|
name: string;
|
||||||
|
filePath: string;
|
||||||
|
fileSize?: number;
|
||||||
|
extension?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
containedIn?: string;
|
||||||
|
cover?: any;
|
||||||
};
|
};
|
||||||
wanted: {
|
wanted?: {
|
||||||
issues: [];
|
issues: [];
|
||||||
volume: { id: number };
|
volume: { id: number };
|
||||||
source: string;
|
source: string;
|
||||||
markEntireVolumeWanted: Boolean;
|
markEntireVolumeWanted: Boolean;
|
||||||
};
|
};
|
||||||
acquisition: {
|
acquisition?: {
|
||||||
directconnect: {
|
source?: {
|
||||||
|
wanted?: boolean;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
directconnect?: {
|
||||||
downloads: [];
|
downloads: [];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
importStatus?: {
|
||||||
|
isImported: boolean;
|
||||||
|
tagged: boolean;
|
||||||
|
matchedResult?: {
|
||||||
|
score: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}>
|
}>
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
console.log(
|
||||||
|
"[GraphQL Import] Processing import via GraphQL..."
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(ctx.params.payload, null, 4)
|
JSON.stringify(ctx.params.payload, null, 4)
|
||||||
);
|
);
|
||||||
const { payload } = ctx.params;
|
const { payload } = ctx.params;
|
||||||
const { wanted } = payload;
|
const { wanted } = payload;
|
||||||
|
|
||||||
console.log("Saving to Mongo...");
|
// Use GraphQL import for new comics
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!wanted ||
|
!wanted ||
|
||||||
!wanted.volume ||
|
!wanted.volume ||
|
||||||
!wanted.volume.id
|
!wanted.volume.id
|
||||||
) {
|
) {
|
||||||
console.log(
|
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();
|
// Import via GraphQL
|
||||||
return {
|
const result = await importComicViaGraphQL(
|
||||||
success: true,
|
this.broker,
|
||||||
message:
|
{
|
||||||
"New document created due to lack of valid identifiers.",
|
filePath: payload.rawFileDetails.filePath,
|
||||||
data: newDocument,
|
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 = {
|
let condition = {
|
||||||
"wanted.volume.id": wanted.volume.id,
|
"wanted.volume.id": wanted.volume.id,
|
||||||
};
|
};
|
||||||
|
|
||||||
let update: any = {
|
let update: any = {
|
||||||
// Using 'any' to bypass strict type checks; alternatively, define a more accurate type
|
|
||||||
$set: {
|
$set: {
|
||||||
rawFileDetails: payload.rawFileDetails,
|
rawFileDetails: payload.rawFileDetails,
|
||||||
inferredMetadata: payload.inferredMetadata,
|
inferredMetadata: payload.inferredMetadata,
|
||||||
@@ -340,18 +399,45 @@ export default class ImportService extends Service {
|
|||||||
update,
|
update,
|
||||||
options
|
options
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
"Operation completed. Document updated or inserted:",
|
"[GraphQL Import] Document upserted:",
|
||||||
result
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Document successfully upserted.",
|
message: "Document successfully upserted.",
|
||||||
data: result,
|
data: result,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.error("[GraphQL Import] Error:", error);
|
||||||
throw new Errors.MoleculerError(
|
throw new Errors.MoleculerError(
|
||||||
"Operation failed.",
|
"Operation failed.",
|
||||||
500
|
500
|
||||||
|
|||||||
391
utils/import.graphql.utils.ts
Normal file
391
utils/import.graphql.utils.ts
Normal file
@@ -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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any> {
|
||||||
|
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<any[]> {
|
||||||
|
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<any[]> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
436
utils/metadata.resolution.utils.ts
Normal file
436
utils/metadata.resolution.utils.ts
Normal file
@@ -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<string, number>;
|
||||||
|
}>;
|
||||||
|
conflictResolution: ConflictResolutionStrategy;
|
||||||
|
minConfidenceThreshold: number;
|
||||||
|
preferRecent: boolean;
|
||||||
|
fieldPreferences?: Map<string, MetadataSource>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user