🛠 graphql changes

This commit is contained in:
2026-02-24 16:29:48 -05:00
parent cd446a9ca3
commit f7804ee3f0
11 changed files with 3317 additions and 40 deletions

View 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;
}
}

View 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;
}