import { invoke } from '@tauri-apps/api/core' export type ContentIdentityProvider = 'modrinth' | 'curseforge' export type ContentIdentitySource = 'curated_mapping' | 'sha1' | 'heuristic' export type ContentIdentityConfidence = 'exact' | 'high' | 'possible' export interface ContentIdentityInput { provider: ContentIdentityProvider projectId: string contentType: string slug?: string | null title?: string | null fileName?: string | null sha1?: string | null } export interface ContentIdentitySnapshotItem { projectType: string provider: ContentIdentityProvider | null providerProjectId: string | null expectedRelativePath: string content: { id: string file_name: string project_type: string project?: { slug?: string | null; title?: string | null } | null provider_refs: Array<{ provider: ContentIdentityProvider project_id: string | number }> } | null } export interface ContentIdentityProjectMetadata { slug?: string | null title?: string | null } export type ContentIdentityProjectMetadataByProvider = Partial< Record> > export interface ContentIdentityCounterpart { provider: ContentIdentityProvider projectId: string slug?: string } export interface ContentIdentity { key?: string source?: ContentIdentitySource confidence?: ContentIdentityConfidence counterparts?: ContentIdentityCounterpart[] provider: ContentIdentityProvider projectId: string contentType: string slug?: string title?: string fileName?: string sha1?: string normalizedSlug?: string normalizedTitle?: string normalizedFileName?: string ambiguous?: boolean } interface ContentIdentityRecord { key: string counterparts: ContentIdentityCounterpart[] } interface ContentIdentityLookup { modrinth: Record curseforge: Record } export interface ContentIdentityMatch { source: ContentIdentitySource confidence: ContentIdentityConfidence identity: ContentIdentity } export function contentIdentityInputsFromSnapshot( items: ContentIdentitySnapshotItem[], projectMetadata: ContentIdentityProjectMetadataByProvider = {}, ): ContentIdentityInput[] { const inputs: ContentIdentityInput[] = [] for (const item of items) { const references = new Map() if (item.provider && item.providerProjectId) { const reference = { provider: item.provider, projectId: item.providerProjectId } references.set(`${reference.provider}:${reference.projectId}`, reference) } for (const providerReference of item.content?.provider_refs ?? []) { const reference = { provider: providerReference.provider, projectId: String(providerReference.project_id), } references.set(`${reference.provider}:${reference.projectId}`, reference) } for (const reference of references.values()) { const metadata = projectMetadata[reference.provider]?.get(reference.projectId) inputs.push({ ...reference, contentType: item.projectType || item.content?.project_type || 'mod', slug: metadata?.slug ?? item.content?.project?.slug, title: metadata?.title ?? item.content?.project?.title, fileName: item.content?.file_name ?? item.expectedRelativePath.split(/[\\/]/u).pop() ?? item.expectedRelativePath, sha1: item.content?.id, }) } } return inputs } function normalizeText(value: string | null | undefined) { return (value ?? '') .toLocaleLowerCase() .replace(/\.(?:jar|zip|mrpack|litemod)(?:\.disabled)?$/u, '') .replace(/(?:[-_. ]+)(?:v?\d[\w.-]*)$/u, '') .replace(/(?:[-_. ]+)(?:fabric|forge|quilt|neoforge|neo|liteloader)$/u, '') .replace(/[^a-z0-9]+/gu, '') } export function normalizeContentIdentityText(value: string | null | undefined) { return normalizeText(value) } function identitySlug(input: ContentIdentityInput) { return input.slug ? normalizeText(input.slug) : '' } function identityTitle(input: ContentIdentityInput) { return input.title ? normalizeText(input.title) : '' } function identityFileName(input: ContentIdentityInput) { return input.fileName ? normalizeText(input.fileName) : '' } export async function resolveContentIdentities( inputs: ContentIdentityInput[], ): Promise { const modrinthSlugs = [ ...new Set( inputs .filter((input) => input.provider === 'modrinth' && input.slug) .map((input) => input.slug as string), ), ] const curseforgeSlugs = [ ...new Set( inputs .filter((input) => input.provider === 'curseforge' && input.slug) .map((input) => input.slug as string), ), ] let lookup: ContentIdentityLookup = { modrinth: {}, curseforge: {} } if (modrinthSlugs.length || curseforgeSlugs.length) { lookup = await invoke( 'plugin:content-search|lookup_content_identities', { modrinthSlugs, curseforgeSlugs, }, ).catch(() => lookup) } return inputs.map((input) => { const records = input.slug ? ((input.provider === 'modrinth' ? (lookup.modrinth[input.slug] ?? lookup.modrinth[input.slug.toLowerCase()]) : (lookup.curseforge[input.slug] ?? lookup.curseforge[input.slug.toLowerCase()])) ?? []) : [] const uniqueKeys = [...new Set(records.map((record) => record.key))] const mapping = uniqueKeys.length === 1 ? records.find((record) => record.key === uniqueKeys[0]) : undefined return { ...input, slug: input.slug ?? undefined, title: input.title ?? undefined, fileName: input.fileName ?? undefined, sha1: input.sha1?.toLowerCase() || undefined, key: mapping?.key, source: mapping ? 'curated_mapping' : undefined, confidence: mapping ? 'exact' : undefined, counterparts: mapping?.counterparts, normalizedSlug: identitySlug(input), normalizedTitle: identityTitle(input), normalizedFileName: identityFileName(input), ambiguous: uniqueKeys.length > 1, } }) } function sameProvider(left: ContentIdentity, right: ContentIdentity) { return left.provider === right.provider } function sameContentType(left: ContentIdentity, right: ContentIdentity) { return left.contentType === right.contentType } export function compareContentIdentities( left: ContentIdentity, right: ContentIdentity, ): ContentIdentityMatch | null { if (sameProvider(left, right) || !sameContentType(left, right)) return null if (left.key && right.key && left.key === right.key && !left.ambiguous && !right.ambiguous) { return { source: 'curated_mapping', confidence: 'exact', identity: right } } if (left.sha1 && right.sha1 && left.sha1 === right.sha1) { return { source: 'sha1', confidence: 'exact', identity: right } } if (left.normalizedSlug && right.normalizedSlug && left.normalizedSlug === right.normalizedSlug) { return { source: 'heuristic', confidence: 'high', identity: right } } if ( left.normalizedTitle && right.normalizedTitle && left.normalizedTitle === right.normalizedTitle ) { return { source: 'heuristic', confidence: 'possible', identity: right } } if ( left.normalizedFileName && right.normalizedFileName && left.normalizedFileName === right.normalizedFileName ) { return { source: 'heuristic', confidence: 'possible', identity: right } } return null } export function contentIdentityFromInput(input: ContentIdentityInput): ContentIdentity { return { ...input, slug: input.slug ?? undefined, title: input.title ?? undefined, fileName: input.fileName ?? undefined, sha1: input.sha1?.toLowerCase() || undefined, normalizedSlug: identitySlug(input), normalizedTitle: identityTitle(input), normalizedFileName: identityFileName(input), } }