Files
Starlight_Lancher/apps/app-frontend/src/helpers/content-identity.ts
T

251 lines
7.5 KiB
TypeScript

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<ContentIdentityProvider, ReadonlyMap<string, ContentIdentityProjectMetadata>>
>
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<string, ContentIdentityRecord[]>
curseforge: Record<string, ContentIdentityRecord[]>
}
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<string, { provider: ContentIdentityProvider; projectId: string }>()
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<ContentIdentity[]> {
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<ContentIdentityLookup>(
'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),
}
}