🛠 graphql changes
This commit is contained in:
@@ -18,6 +18,216 @@ export const eSClient = new Client({
|
||||
},
|
||||
});
|
||||
|
||||
// Metadata source enumeration
|
||||
export enum MetadataSource {
|
||||
COMICVINE = "comicvine",
|
||||
METRON = "metron",
|
||||
GRAND_COMICS_DATABASE = "gcd",
|
||||
LOCG = "locg",
|
||||
COMICINFO_XML = "comicinfo",
|
||||
MANUAL = "manual",
|
||||
}
|
||||
|
||||
// Provenance schema - tracks where each piece of metadata came from
|
||||
const ProvenanceSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
source: {
|
||||
type: String,
|
||||
enum: Object.values(MetadataSource),
|
||||
required: true,
|
||||
},
|
||||
sourceId: String, // External ID from the source (e.g., ComicVine ID)
|
||||
confidence: { type: Number, min: 0, max: 1, default: 1 }, // 0-1 confidence score
|
||||
fetchedAt: { type: Date, default: Date.now },
|
||||
url: String, // Source URL if applicable
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Individual metadata field with provenance
|
||||
const MetadataFieldSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
value: mongoose.Schema.Types.Mixed, // The actual value
|
||||
provenance: ProvenanceSchema, // Where it came from
|
||||
userOverride: { type: Boolean, default: false }, // User manually set this
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Creator with provenance
|
||||
const CreatorSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
name: String,
|
||||
role: String, // writer, artist, colorist, letterer, etc.
|
||||
id: String, // External ID from source (e.g., Metron creator ID)
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Story Arc with provenance
|
||||
const StoryArcSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
name: String,
|
||||
number: Number, // Issue's position in the arc
|
||||
id: String, // External ID from source
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Universe schema for multiverse/alternate reality tracking
|
||||
const UniverseSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
name: String,
|
||||
designation: String, // e.g., "Earth-616", "Earth-25"
|
||||
id: String, // External ID from source
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Price information with country codes
|
||||
const PriceSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
country: String, // ISO country code (e.g., "US", "GB")
|
||||
amount: Number,
|
||||
currency: String, // ISO currency code (e.g., "USD", "GBP")
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// External IDs from various sources
|
||||
const ExternalIDSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
source: String, // e.g., "Metron", "Comic Vine", "Grand Comics Database", "MangaDex"
|
||||
id: String,
|
||||
primary: { type: Boolean, default: false },
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// GTIN (Global Trade Item Number) - includes ISBN, UPC, etc.
|
||||
const GTINSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
isbn: String,
|
||||
upc: String,
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Reprint information
|
||||
const ReprintSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
description: String, // e.g., "Foo Bar #001 (2002)"
|
||||
id: String, // External ID from source
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// URL with primary flag
|
||||
const URLSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
url: String,
|
||||
primary: { type: Boolean, default: false },
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
// Canonical metadata - resolved from multiple sources
|
||||
const CanonicalMetadataSchema = new mongoose.Schema(
|
||||
{
|
||||
_id: false,
|
||||
// Core identifiers
|
||||
title: MetadataFieldSchema,
|
||||
series: MetadataFieldSchema,
|
||||
volume: MetadataFieldSchema,
|
||||
issueNumber: MetadataFieldSchema,
|
||||
|
||||
// External IDs from various sources (Metron, ComicVine, GCD, MangaDex, etc.)
|
||||
externalIDs: [ExternalIDSchema],
|
||||
|
||||
// Publication info
|
||||
publisher: MetadataFieldSchema,
|
||||
imprint: MetadataFieldSchema, // Publisher imprint (e.g., Vertigo for DC Comics)
|
||||
publicationDate: MetadataFieldSchema, // Store/release date
|
||||
coverDate: MetadataFieldSchema, // Cover date (often different from store date)
|
||||
|
||||
// Series information
|
||||
seriesInfo: {
|
||||
type: {
|
||||
_id: false,
|
||||
id: String, // External series ID
|
||||
language: String, // ISO language code (e.g., "en", "de")
|
||||
sortName: String, // Alternative sort name
|
||||
startYear: Number,
|
||||
issueCount: Number, // Total issues in series
|
||||
volumeCount: Number, // Total volumes/collections
|
||||
alternativeNames: [MetadataFieldSchema], // Alternative series names
|
||||
provenance: ProvenanceSchema,
|
||||
},
|
||||
default: null,
|
||||
},
|
||||
|
||||
// Content
|
||||
description: MetadataFieldSchema, // Summary/synopsis
|
||||
notes: MetadataFieldSchema, // Additional notes about the issue
|
||||
stories: [MetadataFieldSchema], // Story titles within the issue
|
||||
storyArcs: [StoryArcSchema], // Story arcs with position tracking
|
||||
characters: [MetadataFieldSchema],
|
||||
teams: [MetadataFieldSchema],
|
||||
locations: [MetadataFieldSchema],
|
||||
universes: [UniverseSchema], // Multiverse/alternate reality information
|
||||
|
||||
// Creators
|
||||
creators: [CreatorSchema],
|
||||
|
||||
// Classification
|
||||
genres: [MetadataFieldSchema],
|
||||
tags: [MetadataFieldSchema],
|
||||
ageRating: MetadataFieldSchema,
|
||||
|
||||
// Physical/Digital properties
|
||||
pageCount: MetadataFieldSchema,
|
||||
format: MetadataFieldSchema, // Single Issue, TPB, HC, etc.
|
||||
|
||||
// Commercial information
|
||||
prices: [PriceSchema], // Prices in different countries/currencies
|
||||
gtin: GTINSchema, // ISBN, UPC, etc.
|
||||
|
||||
// Reprints
|
||||
reprints: [ReprintSchema], // Information about reprinted content
|
||||
|
||||
// URLs
|
||||
urls: [URLSchema], // External URLs (ComicVine, Metron, etc.)
|
||||
|
||||
// Ratings and popularity
|
||||
communityRating: MetadataFieldSchema,
|
||||
|
||||
// Cover image
|
||||
coverImage: MetadataFieldSchema,
|
||||
|
||||
// Metadata tracking
|
||||
lastModified: MetadataFieldSchema, // Last modification timestamp from source
|
||||
},
|
||||
{ _id: false }
|
||||
);
|
||||
|
||||
const RawFileDetailsSchema = mongoose.Schema({
|
||||
_id: false,
|
||||
name: String,
|
||||
@@ -49,6 +259,7 @@ const LOCGSchema = mongoose.Schema({
|
||||
pulls: Number,
|
||||
potw: Number,
|
||||
});
|
||||
|
||||
const DirectConnectBundleSchema = mongoose.Schema({
|
||||
bundleId: Number,
|
||||
name: String,
|
||||
@@ -56,6 +267,7 @@ const DirectConnectBundleSchema = mongoose.Schema({
|
||||
type: {},
|
||||
_id: false,
|
||||
});
|
||||
|
||||
const wantedSchema = mongoose.Schema(
|
||||
{
|
||||
source: { type: String, default: null },
|
||||
@@ -63,7 +275,7 @@ const wantedSchema = mongoose.Schema(
|
||||
issues: {
|
||||
type: [
|
||||
{
|
||||
_id: false, // Disable automatic ObjectId creation for each issue
|
||||
_id: false,
|
||||
id: Number,
|
||||
url: String,
|
||||
image: { type: Array, default: [] },
|
||||
@@ -75,7 +287,7 @@ const wantedSchema = mongoose.Schema(
|
||||
},
|
||||
volume: {
|
||||
type: {
|
||||
_id: false, // Disable automatic ObjectId creation for volume
|
||||
_id: false,
|
||||
id: Number,
|
||||
url: String,
|
||||
image: { type: Array, default: [] },
|
||||
@@ -85,7 +297,7 @@ const wantedSchema = mongoose.Schema(
|
||||
},
|
||||
},
|
||||
{ _id: false }
|
||||
); // Disable automatic ObjectId creation for the wanted object itself
|
||||
);
|
||||
|
||||
const ComicSchema = mongoose.Schema(
|
||||
{
|
||||
@@ -99,15 +311,27 @@ const ComicSchema = mongoose.Schema(
|
||||
userAddedMetadata: {
|
||||
tags: [String],
|
||||
},
|
||||
|
||||
// NEW: Canonical metadata with provenance
|
||||
canonicalMetadata: {
|
||||
type: CanonicalMetadataSchema,
|
||||
es_indexed: true,
|
||||
default: {},
|
||||
},
|
||||
|
||||
// LEGACY: Keep existing sourced metadata for backward compatibility
|
||||
sourcedMetadata: {
|
||||
comicInfo: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
comicvine: { type: mongoose.Schema.Types.Mixed, default: {} }, // Set as a freeform object
|
||||
comicvine: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
metron: { type: mongoose.Schema.Types.Mixed, default: {} },
|
||||
gcd: { type: mongoose.Schema.Types.Mixed, default: {} }, // Grand Comics Database
|
||||
locg: {
|
||||
type: LOCGSchema,
|
||||
es_indexed: true,
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
|
||||
rawFileDetails: {
|
||||
type: RawFileDetailsSchema,
|
||||
es_indexed: true,
|
||||
|
||||
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";
|
||||
|
||||
export const typeDefs = gql`
|
||||
type Query {
|
||||
comic(id: ID!): Comic
|
||||
comics(limit: Int = 10): [Comic]
|
||||
}
|
||||
# Metadata source enumeration
|
||||
enum MetadataSource {
|
||||
COMICVINE
|
||||
METRON
|
||||
GRAND_COMICS_DATABASE
|
||||
LOCG
|
||||
COMICINFO_XML
|
||||
MANUAL
|
||||
}
|
||||
|
||||
type Comic {
|
||||
id: ID!
|
||||
title: String
|
||||
volume: Int
|
||||
issueNumber: String
|
||||
publicationDate: String
|
||||
coverUrl: String
|
||||
creators: [Creator]
|
||||
source: String
|
||||
}
|
||||
# Conflict resolution strategy
|
||||
enum ConflictResolutionStrategy {
|
||||
PRIORITY
|
||||
CONFIDENCE
|
||||
RECENCY
|
||||
MANUAL
|
||||
HYBRID
|
||||
}
|
||||
|
||||
type Creator {
|
||||
name: String
|
||||
role: String
|
||||
}
|
||||
# Provenance information for metadata
|
||||
type Provenance {
|
||||
source: MetadataSource!
|
||||
sourceId: String
|
||||
confidence: Float!
|
||||
fetchedAt: String!
|
||||
url: String
|
||||
}
|
||||
|
||||
# Metadata field with provenance
|
||||
type MetadataField {
|
||||
value: String
|
||||
provenance: Provenance!
|
||||
userOverride: Boolean
|
||||
}
|
||||
|
||||
# Array metadata field with provenance
|
||||
type MetadataArrayField {
|
||||
values: [String!]!
|
||||
provenance: Provenance!
|
||||
userOverride: Boolean
|
||||
}
|
||||
|
||||
# Creator with role and provenance
|
||||
type Creator {
|
||||
name: String!
|
||||
role: String!
|
||||
provenance: Provenance!
|
||||
}
|
||||
|
||||
# Canonical metadata - resolved from multiple sources
|
||||
type CanonicalMetadata {
|
||||
# Core identifiers
|
||||
title: MetadataField
|
||||
series: MetadataField
|
||||
volume: MetadataField
|
||||
issueNumber: MetadataField
|
||||
|
||||
# Publication info
|
||||
publisher: MetadataField
|
||||
publicationDate: MetadataField
|
||||
coverDate: MetadataField
|
||||
|
||||
# Content
|
||||
description: MetadataField
|
||||
storyArcs: [MetadataField!]
|
||||
characters: [MetadataField!]
|
||||
teams: [MetadataField!]
|
||||
locations: [MetadataField!]
|
||||
|
||||
# Creators
|
||||
creators: [Creator!]
|
||||
|
||||
# Classification
|
||||
genres: [MetadataField!]
|
||||
tags: [MetadataField!]
|
||||
ageRating: MetadataField
|
||||
|
||||
# Physical/Digital properties
|
||||
pageCount: MetadataField
|
||||
format: MetadataField
|
||||
|
||||
# Ratings
|
||||
communityRating: MetadataField
|
||||
|
||||
# Cover
|
||||
coverImage: MetadataField
|
||||
}
|
||||
|
||||
# Raw file details
|
||||
type RawFileDetails {
|
||||
name: String
|
||||
filePath: String
|
||||
fileSize: Int
|
||||
extension: String
|
||||
mimeType: String
|
||||
containedIn: String
|
||||
pageCount: Int
|
||||
archive: Archive
|
||||
cover: Cover
|
||||
}
|
||||
|
||||
type Archive {
|
||||
uncompressed: Boolean
|
||||
expandedPath: String
|
||||
}
|
||||
|
||||
type Cover {
|
||||
filePath: String
|
||||
stats: String
|
||||
}
|
||||
|
||||
# Import status
|
||||
type ImportStatus {
|
||||
isImported: Boolean
|
||||
tagged: Boolean
|
||||
matchedResult: MatchedResult
|
||||
}
|
||||
|
||||
type MatchedResult {
|
||||
score: String
|
||||
}
|
||||
|
||||
# Main Comic type with canonical metadata
|
||||
type Comic {
|
||||
id: ID!
|
||||
|
||||
# Canonical metadata (resolved from all sources)
|
||||
canonicalMetadata: CanonicalMetadata
|
||||
|
||||
# Raw sourced metadata (for transparency)
|
||||
sourcedMetadata: SourcedMetadata
|
||||
|
||||
# File information
|
||||
rawFileDetails: RawFileDetails
|
||||
|
||||
# Import status
|
||||
importStatus: ImportStatus
|
||||
|
||||
# Timestamps
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
}
|
||||
|
||||
# Sourced metadata (raw data from each source)
|
||||
type SourcedMetadata {
|
||||
comicInfo: String # JSON string
|
||||
comicvine: String # JSON string
|
||||
metron: String # JSON string
|
||||
gcd: String # JSON string
|
||||
locg: LOCGMetadata
|
||||
}
|
||||
|
||||
type LOCGMetadata {
|
||||
name: String
|
||||
publisher: String
|
||||
url: String
|
||||
cover: String
|
||||
description: String
|
||||
price: String
|
||||
rating: Float
|
||||
pulls: Int
|
||||
potw: Int
|
||||
}
|
||||
|
||||
# Source priority configuration
|
||||
type SourcePriority {
|
||||
source: MetadataSource!
|
||||
priority: Int!
|
||||
enabled: Boolean!
|
||||
fieldOverrides: [FieldOverride!]
|
||||
}
|
||||
|
||||
type FieldOverride {
|
||||
field: String!
|
||||
priority: Int!
|
||||
}
|
||||
|
||||
# User preferences for metadata resolution
|
||||
type UserPreferences {
|
||||
id: ID!
|
||||
userId: String!
|
||||
sourcePriorities: [SourcePriority!]!
|
||||
conflictResolution: ConflictResolutionStrategy!
|
||||
minConfidenceThreshold: Float!
|
||||
preferRecent: Boolean!
|
||||
fieldPreferences: [FieldPreference!]
|
||||
autoMerge: AutoMergeSettings!
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
}
|
||||
|
||||
type FieldPreference {
|
||||
field: String!
|
||||
preferredSource: MetadataSource!
|
||||
}
|
||||
|
||||
type AutoMergeSettings {
|
||||
enabled: Boolean!
|
||||
onImport: Boolean!
|
||||
onMetadataUpdate: Boolean!
|
||||
}
|
||||
|
||||
# Pagination
|
||||
type ComicConnection {
|
||||
comics: [Comic!]!
|
||||
totalCount: Int!
|
||||
pageInfo: PageInfo!
|
||||
}
|
||||
|
||||
type PageInfo {
|
||||
hasNextPage: Boolean!
|
||||
hasPreviousPage: Boolean!
|
||||
currentPage: Int!
|
||||
totalPages: Int!
|
||||
}
|
||||
|
||||
# Metadata conflict information
|
||||
type MetadataConflict {
|
||||
field: String!
|
||||
candidates: [MetadataField!]!
|
||||
resolved: MetadataField
|
||||
resolutionReason: String!
|
||||
}
|
||||
|
||||
# Queries
|
||||
type Query {
|
||||
# Get a single comic by ID
|
||||
comic(id: ID!): Comic
|
||||
|
||||
# List comics with pagination and filtering
|
||||
comics(
|
||||
limit: Int = 10
|
||||
page: Int = 1
|
||||
search: String
|
||||
publisher: String
|
||||
series: String
|
||||
): ComicConnection!
|
||||
|
||||
# Get user preferences
|
||||
userPreferences(userId: String = "default"): UserPreferences
|
||||
|
||||
# Analyze metadata conflicts for a comic
|
||||
analyzeMetadataConflicts(comicId: ID!): [MetadataConflict!]!
|
||||
|
||||
# Preview canonical metadata resolution without saving
|
||||
previewCanonicalMetadata(
|
||||
comicId: ID!
|
||||
preferences: UserPreferencesInput
|
||||
): CanonicalMetadata
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
# Update user preferences
|
||||
updateUserPreferences(
|
||||
userId: String = "default"
|
||||
preferences: UserPreferencesInput!
|
||||
): UserPreferences!
|
||||
|
||||
# Manually set a metadata field (creates user override)
|
||||
setMetadataField(
|
||||
comicId: ID!
|
||||
field: String!
|
||||
value: String!
|
||||
): Comic!
|
||||
|
||||
# Trigger metadata resolution for a comic
|
||||
resolveMetadata(comicId: ID!): Comic!
|
||||
|
||||
# Bulk resolve metadata for multiple comics
|
||||
bulkResolveMetadata(comicIds: [ID!]!): [Comic!]!
|
||||
|
||||
# Remove user override for a field
|
||||
removeMetadataOverride(comicId: ID!, field: String!): Comic!
|
||||
|
||||
# Refresh metadata from a specific source
|
||||
refreshMetadataFromSource(
|
||||
comicId: ID!
|
||||
source: MetadataSource!
|
||||
): Comic!
|
||||
|
||||
# Import a new comic with automatic metadata resolution
|
||||
importComic(input: ImportComicInput!): ImportComicResult!
|
||||
|
||||
# Update sourced metadata and trigger resolution
|
||||
updateSourcedMetadata(
|
||||
comicId: ID!
|
||||
source: MetadataSource!
|
||||
metadata: String!
|
||||
): Comic!
|
||||
}
|
||||
|
||||
# Input types
|
||||
input UserPreferencesInput {
|
||||
sourcePriorities: [SourcePriorityInput!]
|
||||
conflictResolution: ConflictResolutionStrategy
|
||||
minConfidenceThreshold: Float
|
||||
preferRecent: Boolean
|
||||
fieldPreferences: [FieldPreferenceInput!]
|
||||
autoMerge: AutoMergeSettingsInput
|
||||
}
|
||||
|
||||
input SourcePriorityInput {
|
||||
source: MetadataSource!
|
||||
priority: Int!
|
||||
enabled: Boolean!
|
||||
fieldOverrides: [FieldOverrideInput!]
|
||||
}
|
||||
|
||||
input FieldOverrideInput {
|
||||
field: String!
|
||||
priority: Int!
|
||||
}
|
||||
|
||||
input FieldPreferenceInput {
|
||||
field: String!
|
||||
preferredSource: MetadataSource!
|
||||
}
|
||||
|
||||
input AutoMergeSettingsInput {
|
||||
enabled: Boolean
|
||||
onImport: Boolean
|
||||
onMetadataUpdate: Boolean
|
||||
}
|
||||
|
||||
# Import comic input
|
||||
input ImportComicInput {
|
||||
filePath: String!
|
||||
fileSize: Int
|
||||
sourcedMetadata: SourcedMetadataInput
|
||||
inferredMetadata: InferredMetadataInput
|
||||
rawFileDetails: RawFileDetailsInput
|
||||
wanted: WantedInput
|
||||
acquisition: AcquisitionInput
|
||||
}
|
||||
|
||||
input SourcedMetadataInput {
|
||||
comicInfo: String
|
||||
comicvine: String
|
||||
metron: String
|
||||
gcd: String
|
||||
locg: LOCGMetadataInput
|
||||
}
|
||||
|
||||
input LOCGMetadataInput {
|
||||
name: String
|
||||
publisher: String
|
||||
url: String
|
||||
cover: String
|
||||
description: String
|
||||
price: String
|
||||
rating: Float
|
||||
pulls: Int
|
||||
potw: Int
|
||||
}
|
||||
|
||||
input InferredMetadataInput {
|
||||
issue: IssueInput
|
||||
}
|
||||
|
||||
input IssueInput {
|
||||
name: String
|
||||
number: Int
|
||||
year: String
|
||||
subtitle: String
|
||||
}
|
||||
|
||||
input RawFileDetailsInput {
|
||||
name: String!
|
||||
filePath: String!
|
||||
fileSize: Int
|
||||
extension: String
|
||||
mimeType: String
|
||||
containedIn: String
|
||||
pageCount: Int
|
||||
archive: ArchiveInput
|
||||
cover: CoverInput
|
||||
}
|
||||
|
||||
input ArchiveInput {
|
||||
uncompressed: Boolean
|
||||
expandedPath: String
|
||||
}
|
||||
|
||||
input CoverInput {
|
||||
filePath: String
|
||||
stats: String
|
||||
}
|
||||
|
||||
input WantedInput {
|
||||
source: String
|
||||
markEntireVolumeWanted: Boolean
|
||||
issues: [WantedIssueInput!]
|
||||
volume: WantedVolumeInput
|
||||
}
|
||||
|
||||
input WantedIssueInput {
|
||||
id: Int
|
||||
url: String
|
||||
image: [String!]
|
||||
coverDate: String
|
||||
issueNumber: String
|
||||
}
|
||||
|
||||
input WantedVolumeInput {
|
||||
id: Int
|
||||
url: String
|
||||
image: [String!]
|
||||
name: String
|
||||
}
|
||||
|
||||
input AcquisitionInput {
|
||||
source: AcquisitionSourceInput
|
||||
directconnect: DirectConnectInput
|
||||
}
|
||||
|
||||
input AcquisitionSourceInput {
|
||||
wanted: Boolean
|
||||
name: String
|
||||
}
|
||||
|
||||
input DirectConnectInput {
|
||||
downloads: [DirectConnectBundleInput!]
|
||||
}
|
||||
|
||||
input DirectConnectBundleInput {
|
||||
bundleId: Int
|
||||
name: String
|
||||
size: String
|
||||
}
|
||||
|
||||
# Import result
|
||||
type ImportComicResult {
|
||||
success: Boolean!
|
||||
comic: Comic
|
||||
message: String
|
||||
canonicalMetadataResolved: Boolean!
|
||||
}
|
||||
`;
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user