518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
import Fuse from 'fuse.js'
|
|
import type { ComputedRef, Ref } from 'vue'
|
|
import { computed, ref, shallowRef, watch } from 'vue'
|
|
|
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
|
import { commonProjectTypeCategoryMessages, normalizeProjectType } from '#ui/utils/common-messages'
|
|
|
|
import type { ContentItem } from '../types'
|
|
import { type ContentFilterSelections, pruneContentFilterSelections } from './content-filter-state'
|
|
import type { ContentFilterOption } from './content-filtering'
|
|
import {
|
|
getClientWarningType,
|
|
isDisabledContentItem,
|
|
isEnabledContentItem,
|
|
} from './content-filtering'
|
|
|
|
// Re-export utility functions and types for convenience
|
|
export type { ContentFilterOption } from './content-filtering'
|
|
export { getClientWarningType, isClientOnlyEnvironment } from './content-filtering'
|
|
|
|
// ---- window 级内存持久化(导航切换保留,关软件丢弃) ----
|
|
|
|
const memory: Record<string, Map<string, unknown>> = ((
|
|
window as unknown as { __ctMemory?: Record<string, Map<string, unknown>> }
|
|
).__ctMemory ??= {})
|
|
function getMap<K, V>(namespace: string): Map<K, V> {
|
|
if (!memory[namespace]) memory[namespace] = new Map<string, unknown>()
|
|
return memory[namespace] as Map<K, V>
|
|
}
|
|
|
|
// ---- types ----
|
|
|
|
export interface ContentPipelineConfig {
|
|
items: Ref<ContentItem[]>
|
|
modpackItems?: Ref<ContentItem[] | undefined>
|
|
duplicateItems?: Ref<ContentItem[] | undefined>
|
|
sortItems: (items: ContentItem[]) => ContentItem[]
|
|
getItemId: (item: ContentItem) => string
|
|
showTypeFilters?: boolean
|
|
showUpdateFilter?: boolean
|
|
showWarningsFilter?: boolean
|
|
isPackLocked?: Ref<boolean>
|
|
memoryKey?: string
|
|
searchKeys?: string[]
|
|
initialFilters?: ContentFilterSelections
|
|
filterOptionsReady?: Ref<boolean> | ComputedRef<boolean>
|
|
}
|
|
|
|
interface PipelineResult {
|
|
filteredItems: ContentItem[]
|
|
filteredModpackItems: ContentItem[]
|
|
filterCounts: Record<string, number>
|
|
row1FilterOptions: ContentFilterOption[]
|
|
row2FilterOptions: ContentFilterOption[]
|
|
totalCount: number
|
|
}
|
|
|
|
// ---- messages ----
|
|
|
|
const filterMessages = defineMessages({
|
|
updates: {
|
|
id: 'content.filter.updates',
|
|
defaultMessage: 'Update available',
|
|
},
|
|
clientOnly: {
|
|
id: 'content.filter.client-only',
|
|
defaultMessage: 'Client only',
|
|
},
|
|
clientOnlyDescription: {
|
|
id: 'content.filter.client-only-description',
|
|
defaultMessage:
|
|
'Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers.',
|
|
},
|
|
duplicates: {
|
|
id: 'content.filter.duplicates',
|
|
defaultMessage: 'Duplicates',
|
|
},
|
|
enabled: {
|
|
id: 'content.filter.enabled',
|
|
defaultMessage: 'Enabled',
|
|
},
|
|
disabled: {
|
|
id: 'content.filter.disabled',
|
|
defaultMessage: 'Disabled',
|
|
},
|
|
})
|
|
|
|
// ---- composable ----
|
|
|
|
export function useContentPipeline(config: ContentPipelineConfig) {
|
|
const { formatMessage } = useVIntl()
|
|
|
|
const {
|
|
items,
|
|
modpackItems,
|
|
duplicateItems,
|
|
sortItems,
|
|
getItemId,
|
|
showTypeFilters = false,
|
|
showUpdateFilter = false,
|
|
showWarningsFilter = false,
|
|
memoryKey = '',
|
|
searchKeys = ['project.title', 'owner.name', 'file_name'],
|
|
initialFilters,
|
|
filterOptionsReady,
|
|
} = config
|
|
|
|
// ---- filter state ----
|
|
|
|
function normalizeTypeFilters(value: string | string[] | null | undefined): string[] {
|
|
if (!value) return []
|
|
return Array.isArray(value) ? value : [value]
|
|
}
|
|
|
|
const filterMemory = getMap<string, { type: string[]; status: string[] }>('filter')
|
|
const savedFilters = memoryKey ? filterMemory.get(memoryKey) : undefined
|
|
const selectedTypeFilter = ref<string[]>(
|
|
normalizeTypeFilters(initialFilters?.typeFilters ?? savedFilters?.type),
|
|
)
|
|
const selectedStatusFilters = ref<string[]>(
|
|
initialFilters?.statusFilters ?? savedFilters?.status ?? [],
|
|
)
|
|
watch(
|
|
() => memoryKey,
|
|
(key) => {
|
|
if (initialFilters) return
|
|
if (key) {
|
|
const entry = filterMemory.get(key)
|
|
selectedTypeFilter.value = normalizeTypeFilters(entry?.type)
|
|
selectedStatusFilters.value = entry?.status ?? []
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch([selectedTypeFilter, selectedStatusFilters], () => {
|
|
if (memoryKey) {
|
|
filterMemory.set(memoryKey, {
|
|
type: [...selectedTypeFilter.value],
|
|
status: [...selectedStatusFilters.value],
|
|
})
|
|
}
|
|
})
|
|
|
|
// ---- search state ----
|
|
|
|
const searchMemory = getMap<string, string>('search')
|
|
const searchKey = memoryKey ? `${memoryKey}:search` : ''
|
|
const searchQuery = ref(searchKey ? (searchMemory.get(searchKey) ?? '') : '')
|
|
|
|
watch(searchQuery, (val) => {
|
|
if (searchKey) searchMemory.set(searchKey, val)
|
|
})
|
|
|
|
// ---- Fuse instance ----
|
|
|
|
const fuse = new Fuse<ContentItem>([], {
|
|
keys: searchKeys,
|
|
threshold: 0.4,
|
|
distance: 100,
|
|
})
|
|
|
|
// ---- sorted items (computed because they only depend on items + sortMode) ----
|
|
|
|
const sortedItems = computed(() => sortItems(items.value))
|
|
|
|
const modpackItemsNoUpdate = computed(() => {
|
|
const raw = modpackItems?.value ?? []
|
|
return sortItems(
|
|
raw.map((item) => ({
|
|
...item,
|
|
update: null,
|
|
})),
|
|
)
|
|
})
|
|
|
|
const filterValidationOptions = computed(() => {
|
|
const type = new Set<string>()
|
|
const status = new Set<string>()
|
|
const allItems = [...modpackItemsNoUpdate.value, ...sortedItems.value]
|
|
const duplicateItemIds = new Set((duplicateItems?.value ?? []).map((item) => getItemId(item)))
|
|
|
|
for (const item of allItems) {
|
|
type.add(normalizeProjectType(item.project_type))
|
|
if (showUpdateFilter && item.update != null) status.add('updates')
|
|
if (showWarningsFilter && getClientWarningType(item) !== null) status.add('warnings')
|
|
if (isEnabledContentItem(item)) status.add('enabled')
|
|
if (isDisabledContentItem(item)) status.add('disabled')
|
|
}
|
|
if (allItems.some((item) => duplicateItemIds.has(getItemId(item)))) {
|
|
type.add('duplicates')
|
|
}
|
|
|
|
return {
|
|
type: [...type],
|
|
status: [...status],
|
|
}
|
|
})
|
|
|
|
const modpackChildIdSet = computed(() => {
|
|
return new Set(
|
|
(modpackItems?.value ?? []).map((item) => getItemId(item).replace(/\.disabled$/, '')),
|
|
)
|
|
})
|
|
|
|
const searchableItemCount = computed(() => {
|
|
const modpackList = modpackItems?.value ?? []
|
|
const regularItems = items.value.filter((item) => !modpackChildIdSet.value.has(getItemId(item)))
|
|
return modpackList.length + regularItems.length
|
|
})
|
|
|
|
// ---- single-pass pipeline result ----
|
|
|
|
const result = shallowRef<PipelineResult>({
|
|
filteredItems: [],
|
|
filteredModpackItems: [],
|
|
filterCounts: {},
|
|
row1FilterOptions: [],
|
|
row2FilterOptions: [],
|
|
totalCount: 0,
|
|
})
|
|
|
|
let pipelineTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function runPipeline(): PipelineResult {
|
|
const query = searchQuery.value.trim()
|
|
const typeFilters = selectedTypeFilter.value
|
|
const statusFilters = selectedStatusFilters.value
|
|
|
|
// Step 1: Fuse search once (old code calls fuse.search 3 times, we call once)
|
|
let fuseResults: ContentItem[] | null = null
|
|
if (query) {
|
|
fuseResults = fuse.search(query).map((r) => r.item)
|
|
}
|
|
|
|
// Helper: replicate old search(source) behavior:
|
|
// - no query: return source as-is
|
|
// - with query: return all Fuse results (source parameter is ignored)
|
|
function search(source: ContentItem[]): ContentItem[] {
|
|
if (!query) return source
|
|
return fuseResults!
|
|
}
|
|
|
|
// Step 2: Compute searchedAllItems (for filter UI — counts, options, totalCount)
|
|
// Old code: [...modpackSearched.filter(modpackChildIdSet), ...regularSearched.filter(!modpackChildIdSet)]
|
|
const modpackChildIds = modpackChildIdSet.value
|
|
const modpackSearched = search(modpackItemsNoUpdate.value).filter((item) =>
|
|
modpackChildIds.has(getItemId(item).replace(/\.disabled$/, '')),
|
|
)
|
|
const regularSearched = search(sortedItems.value).filter(
|
|
(item) => !modpackChildIds.has(getItemId(item).replace(/\.disabled$/, '')),
|
|
)
|
|
const searchedAllItems = [...modpackSearched, ...regularSearched]
|
|
const duplicateItemIds = new Set((duplicateItems?.value ?? []).map((item) => getItemId(item)))
|
|
const matchesTypeFilter = (item: ContentItem) =>
|
|
typeFilters.includes(normalizeProjectType(item.project_type)) ||
|
|
(typeFilters.includes('duplicates') && duplicateItemIds.has(getItemId(item)))
|
|
|
|
// Step 3: Compute typeFilteredItems and statusFilteredItems from searchedAllItems
|
|
const typeFiltered: ContentItem[] =
|
|
typeFilters.length > 0 ? searchedAllItems.filter(matchesTypeFilter) : searchedAllItems
|
|
const hasEnabled = typeFiltered.some(isEnabledContentItem)
|
|
const hasDisabled = typeFiltered.some(isDisabledContentItem)
|
|
const availableStatusFilters = new Set<string>()
|
|
if (showUpdateFilter && typeFiltered.some((item) => item.update != null)) {
|
|
availableStatusFilters.add('updates')
|
|
}
|
|
if (showWarningsFilter && typeFiltered.some((item) => getClientWarningType(item) !== null)) {
|
|
availableStatusFilters.add('warnings')
|
|
}
|
|
if (hasEnabled) availableStatusFilters.add('enabled')
|
|
if (hasDisabled) availableStatusFilters.add('disabled')
|
|
const effectiveStatusFilters = statusFilters.filter((filter) =>
|
|
availableStatusFilters.has(filter),
|
|
)
|
|
|
|
let statusFiltered = searchedAllItems
|
|
if (effectiveStatusFilters.length > 0) {
|
|
statusFiltered = searchedAllItems.filter((item) => {
|
|
for (const f of effectiveStatusFilters) {
|
|
if (f === 'updates' && item.update == null) return false
|
|
if (f === 'enabled' && !isEnabledContentItem(item)) return false
|
|
if (f === 'disabled' && !isDisabledContentItem(item)) return false
|
|
if (f === 'warnings' && getClientWarningType(item) === null) return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// Step 4: Compute filterCounts (matching old semantics)
|
|
const counts: Record<string, number> = {}
|
|
|
|
// type counts: from statusFiltered (status-filtered items, NOT type-filtered)
|
|
for (const item of statusFiltered) {
|
|
const type = normalizeProjectType(item.project_type)
|
|
counts[type] = (counts[type] || 0) + 1
|
|
}
|
|
counts['duplicates'] = statusFiltered.filter((item) =>
|
|
duplicateItemIds.has(getItemId(item)),
|
|
).length
|
|
|
|
// status counts: from typeFiltered (type-filtered items, NOT status-filtered)
|
|
counts['updates'] = typeFiltered.filter((m) => m.update != null).length
|
|
counts['enabled'] = typeFiltered.filter(isEnabledContentItem).length
|
|
counts['disabled'] = typeFiltered.filter(isDisabledContentItem).length
|
|
counts['warnings'] = typeFiltered.filter((m) => getClientWarningType(m) !== null).length
|
|
|
|
// totalCount: from statusFiltered (same as old code)
|
|
const totalCount = statusFiltered.length
|
|
|
|
// Step 5: Build row1FilterOptions from searchedAllItems (ALL items, like old code)
|
|
const row1: ContentFilterOption[] = []
|
|
if (showTypeFilters) {
|
|
const frequency: Record<string, number> = {}
|
|
for (const item of searchedAllItems) {
|
|
const normalized = normalizeProjectType(item.project_type)
|
|
frequency[normalized] = (frequency[normalized] || 0) + 1
|
|
}
|
|
const types = Object.keys(frequency).sort((a, b) => frequency[b] - frequency[a])
|
|
for (const type of types) {
|
|
const msg =
|
|
commonProjectTypeCategoryMessages[type as keyof typeof commonProjectTypeCategoryMessages]
|
|
const label = msg ? formatMessage(msg) : type.charAt(0).toUpperCase() + type.slice(1) + 's'
|
|
row1.push({ id: type, label })
|
|
}
|
|
if (searchedAllItems.some((item) => duplicateItemIds.has(getItemId(item)))) {
|
|
row1.push({ id: 'duplicates', label: formatMessage(filterMessages.duplicates) })
|
|
}
|
|
}
|
|
|
|
// Step 6: Build row2FilterOptions from typeFiltered (same as old code)
|
|
const row2: ContentFilterOption[] = []
|
|
if (showUpdateFilter && typeFiltered.some((m) => m.update != null)) {
|
|
row2.push({ id: 'updates', label: formatMessage(filterMessages.updates) })
|
|
}
|
|
if (showWarningsFilter && typeFiltered.some((m) => getClientWarningType(m) !== null)) {
|
|
row2.push({
|
|
id: 'warnings',
|
|
label: formatMessage(filterMessages.clientOnly),
|
|
tooltip: formatMessage(filterMessages.clientOnlyDescription),
|
|
})
|
|
}
|
|
|
|
if (hasEnabled && hasDisabled) {
|
|
row2.push({ id: 'enabled', label: formatMessage(filterMessages.enabled) })
|
|
row2.push({ id: 'disabled', label: formatMessage(filterMessages.disabled) })
|
|
}
|
|
|
|
// Step 7: Compute filteredItems and filteredModpackItems (matching old layout.vue)
|
|
// Old filteredItems = applyFilters(search(sortedItems))
|
|
// Old filteredModpackItems = applyFilters(search(modpackItemsNoUpdate).filter(modpackIds))
|
|
function applyFilters(source: ContentItem[]): ContentItem[] {
|
|
let result = source
|
|
if (typeFilters.length > 0) {
|
|
result = result.filter(matchesTypeFilter)
|
|
}
|
|
if (effectiveStatusFilters.length > 0) {
|
|
result = result.filter((item) => {
|
|
for (const f of effectiveStatusFilters) {
|
|
if (f === 'updates' && item.update == null) return false
|
|
if (f === 'enabled' && !isEnabledContentItem(item)) return false
|
|
if (f === 'disabled' && !isDisabledContentItem(item)) return false
|
|
if (f === 'warnings' && getClientWarningType(item) === null) return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
const filteredItems = applyFilters(search(sortedItems.value))
|
|
|
|
const modpackIds = new Set(modpackItemsNoUpdate.value.map((item) => getItemId(item)))
|
|
const filteredModpackItems =
|
|
modpackItemsNoUpdate.value.length === 0
|
|
? []
|
|
: applyFilters(
|
|
search(modpackItemsNoUpdate.value).filter((item) => modpackIds.has(getItemId(item))),
|
|
)
|
|
|
|
return {
|
|
filteredItems,
|
|
filteredModpackItems,
|
|
filterCounts: counts,
|
|
row1FilterOptions: row1,
|
|
row2FilterOptions: row2,
|
|
totalCount,
|
|
}
|
|
}
|
|
|
|
// Trigger pipeline when any dependency changes (debounced)
|
|
watch(
|
|
[
|
|
sortedItems,
|
|
modpackItemsNoUpdate,
|
|
duplicateItems,
|
|
searchQuery,
|
|
selectedTypeFilter,
|
|
selectedStatusFilters,
|
|
],
|
|
() => {
|
|
if (pipelineTimer) clearTimeout(pipelineTimer)
|
|
pipelineTimer = setTimeout(() => {
|
|
result.value = runPipeline()
|
|
}, 100)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// Update Fuse index asynchronously when items change (separate from pipeline)
|
|
watch(
|
|
() => [sortedItems.value, modpackItemsNoUpdate.value] as const,
|
|
([sorted, modpack]) => {
|
|
const seenIds = new Set<string>()
|
|
const collection: ContentItem[] = []
|
|
|
|
for (const item of sorted) {
|
|
const id = getItemId(item)
|
|
if (!seenIds.has(id)) {
|
|
seenIds.add(id)
|
|
collection.push(item)
|
|
}
|
|
}
|
|
|
|
for (const item of modpack) {
|
|
const id = getItemId(item)
|
|
if (!seenIds.has(id)) {
|
|
seenIds.add(id)
|
|
collection.push(item)
|
|
}
|
|
}
|
|
|
|
// Use setTimeout to avoid blocking the main thread
|
|
setTimeout(() => {
|
|
fuse.setCollection(collection)
|
|
}, 0)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// ---- filter API (compatible with old interface) ----
|
|
|
|
const filteredItems = computed(() => result.value.filteredItems)
|
|
const filteredModpackItems = computed(() => result.value.filteredModpackItems)
|
|
const filterCounts = computed(() => result.value.filterCounts)
|
|
const row1FilterOptions = computed(() => result.value.row1FilterOptions)
|
|
const row2FilterOptions = computed(() => result.value.row2FilterOptions)
|
|
const totalCount = computed(() => result.value.totalCount)
|
|
|
|
// Clean up invalid selections when options change
|
|
watch(
|
|
[filterValidationOptions, () => filterOptionsReady?.value ?? true],
|
|
() => {
|
|
const pruned = pruneContentFilterSelections(
|
|
{
|
|
typeFilters: selectedTypeFilter.value,
|
|
statusFilters: selectedStatusFilters.value,
|
|
},
|
|
filterValidationOptions.value,
|
|
filterOptionsReady?.value ?? true,
|
|
)
|
|
if (pruned.typeFilters.length !== selectedTypeFilter.value.length) {
|
|
selectedTypeFilter.value = pruned.typeFilters
|
|
}
|
|
if (pruned.statusFilters.length !== selectedStatusFilters.value.length) {
|
|
selectedStatusFilters.value = pruned.statusFilters
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
function toggleTypeFilter(filterId: string, event?: MouseEvent | KeyboardEvent) {
|
|
if (event?.ctrlKey || event?.metaKey) {
|
|
selectedTypeFilter.value = selectedTypeFilter.value.includes(filterId)
|
|
? selectedTypeFilter.value.filter((id) => id !== filterId)
|
|
: [...selectedTypeFilter.value, filterId]
|
|
return
|
|
}
|
|
|
|
selectedTypeFilter.value = [filterId]
|
|
}
|
|
|
|
function toggleStatusFilter(filterId: string) {
|
|
const filters = selectedStatusFilters.value
|
|
// Replace the array so both the pipeline and saved-filter watchers are notified.
|
|
if (filters.includes(filterId)) {
|
|
selectedStatusFilters.value = filters.filter((id) => id !== filterId)
|
|
return
|
|
}
|
|
|
|
if (filterId === 'enabled' || filterId === 'disabled') {
|
|
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
|
|
selectedStatusFilters.value = [...filters.filter((id) => id !== otherStatusFilter), filterId]
|
|
return
|
|
}
|
|
|
|
selectedStatusFilters.value = [...filters, filterId]
|
|
}
|
|
|
|
return {
|
|
searchQuery,
|
|
searchableItemCount,
|
|
sortedItems,
|
|
modpackItemsNoUpdate,
|
|
modpackChildIdSet,
|
|
selectedTypeFilter,
|
|
selectedStatusFilters,
|
|
row1FilterOptions,
|
|
row2FilterOptions,
|
|
totalCount,
|
|
filterCounts,
|
|
filteredItems,
|
|
filteredModpackItems,
|
|
toggleTypeFilter,
|
|
toggleStatusFilter,
|
|
}
|
|
}
|