mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 09:04:55 +00:00
Moderation Changes Phase 2.-1 (#6833)
* better default state handling
* make next stage button work for going to next project
* fix skill issue
* actually fix initial values this time,
make single line inputs with varying lengths (text input and dropdown) use their widest possible length instead of whatever the hell they used to be
* loaders in progress
* loaders and versions test (probably dont use them as the messages suck and idrk what they should do quick fix/message wise)
* WIP the wip
* good enough for now slug taken improvements
* trim all those extra newlines
* viewedness
* batch prefetch queue + technically slightly faster checklist init
* show all valid alternative project types regardless of current project type
* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues
* Hide moderation permissions stage when it should be, and I'm sure this won't cause any issues
* gimme them versions
* better moderation permission stage shown check?
* betterer moderation permission stage shown check?
* bettererer moderation permission stage shown check? + also began deleting legacy checklist types
* fix certain stages being able to be considered viewed before being viewed
* more removals + i forgot to remove an import last commit
* changed nothing™️
* remove severities + bandaid post-approval message priorities + dependencies
* fix message preview tooltips
* next stage = next project when done
* insufficient description custom fix
* fix that thing coolbot told me was broken I forgot what it was oops
* oh there was a second bug there
* make alternate versions and incorrect project type required cuz they are
* re-navigate button also damn that's a lot of other stuff that pnpm fix... fixed?
* you cant just quick fix a slug into a taken one
* maybe lets not just try and set a slug to something entirely invalid?
* what if we just didn't drop ur queue :smart:
* more queue upgrades
* tweaks
* I love unbreaking things
* un-nuked renavigate button
* prepr
* prepr worked this time i think
* undo more mistakes
* i love intellij refactoring
* ok i think we're good
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import type { NodeState, StageFn, StageNodeBuilder } from '../types/node'
|
||||
import { group, STAGES_KEY } from '../types/node'
|
||||
import type { NodeState, StageNode } from '../types/node'
|
||||
import useCategoriesStage from './stages/categories'
|
||||
import useDescriptionStage from './stages/description'
|
||||
import useGalleryStage from './stages/gallery'
|
||||
@@ -13,7 +11,7 @@ import usePermissionsStage from './stages/permissions'
|
||||
import usePostApprovalStage from './stages/post-approval'
|
||||
import useReReviewStage from './stages/re-review'
|
||||
import useReuploadsStage from './stages/reupload'
|
||||
import useOtherRulesStage from './stages/other-rules'
|
||||
import useRulesStage from './stages/rules'
|
||||
import useStatusAlertsStage from './stages/status-alerts'
|
||||
import useSummaryStage from './stages/summary'
|
||||
import useTitleSlugStage from './stages/title-slug'
|
||||
@@ -22,8 +20,8 @@ import useVersionsStage from './stages/versions'
|
||||
|
||||
export function useStages(
|
||||
globalState: Ref<Record<string, Record<string, NodeState>>>,
|
||||
): StageNodeBuilder[] {
|
||||
const mainStages: StageNodeBuilder[] = [
|
||||
): StageNode[] {
|
||||
const mainStages: StageNode[] = [
|
||||
usePostApprovalStage(),
|
||||
useUndefinedProjectStage(),
|
||||
useReReviewStage(),
|
||||
@@ -38,12 +36,7 @@ export function useStages(
|
||||
useVersionsStage(),
|
||||
useReuploadsStage(),
|
||||
usePermissionsStage(),
|
||||
useOtherRulesStage(),
|
||||
useRulesStage(),
|
||||
]
|
||||
provide(STAGES_KEY, ref(mainStages))
|
||||
return [...mainStages, useStatusAlertsStage(mainStages, globalState)]
|
||||
}
|
||||
|
||||
export const stages: ReadonlyArray<StageFn> = []
|
||||
|
||||
export default group()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { KeybindListener } from '../types/keybinds'
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { KeybindListener } from '../types/keybinds'
|
||||
|
||||
const copyProjectLink = async (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
permalink: boolean,
|
||||
@@ -32,7 +33,6 @@ const keybinds: { [id: string]: KeybindListener } = {
|
||||
keybind: 'ArrowRight',
|
||||
description: 'Go to next stage',
|
||||
scope: 'checklist',
|
||||
enabled: (ctx) => !ctx.state.isDone,
|
||||
action: (ctx) => ctx.actions.tryGoNext(),
|
||||
},
|
||||
'previous-stage': {
|
||||
|
||||
-2
@@ -2,5 +2,3 @@
|
||||
|
||||
Per section 2.1 of %RULES%, your %PROJECT_DESCRIPTION_FLINK% should clearly inform the reader of the content, purpose, and appeal of your %PROJECT_TYPE%.</br>
|
||||
Currently, it looks like there are some missing details.
|
||||
|
||||
%CUSTOM_ADVICE%
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
## Missing Dependencies
|
||||
|
||||
Per section 5.6 of %RULES%, it is important that relevant dependencies be listed in the dependencies section of your project.
|
||||
Please ensure that all relevant dependencies are included in the Dependencies section of each version of your project.
|
||||
+1
@@ -0,0 +1 @@
|
||||
This project should have the following game version(s) selected: %GAME_VERSIONS%.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
## Game Version Metadata
|
||||
|
||||
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which Minecraft versions are selected.
|
||||
|
||||
%CORRECT%
|
||||
@@ -0,0 +1 @@
|
||||
This project should have the following loader(s) selected: %LOADERS%.
|
||||
@@ -0,0 +1,5 @@
|
||||
## Loader Metadata
|
||||
|
||||
Per section 5.1 of %RULES%, it is important that the metadata of your project is accurate, including which loaders are selected.
|
||||
|
||||
%CORRECT%
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineMessage, formatProjectTypeSentence, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { Nag, NagContext } from '../../types/nags'
|
||||
import { licenseRequiresSource, licensesRequiringSource, notSourceAsDistributed } from '../../utils'
|
||||
import license from '../stages/license'
|
||||
import { licenseRequiresSource, notSourceAsDistributed } from '../../utils'
|
||||
|
||||
export const commonLinkDomains = {
|
||||
source: [
|
||||
|
||||
@@ -8,4 +8,5 @@ export const Priorities = new (class extends Priority {
|
||||
rejected = this.before()
|
||||
withheld = this.before()
|
||||
note = this.after()
|
||||
tempLast = this.after()
|
||||
})()
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function () {
|
||||
),
|
||||
|
||||
group().children(
|
||||
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').severity('low').message(),
|
||||
toggle('inaccurate', 'Inaccurate').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('optimization-misused', 'Optimization')
|
||||
.shown(
|
||||
@@ -47,7 +47,6 @@ export default function () {
|
||||
),
|
||||
)
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(optimizationMsg)
|
||||
.fix(
|
||||
fix().project((patch) => {
|
||||
@@ -61,7 +60,6 @@ export default function () {
|
||||
toggle('resolutions-misused', 'Resolutions')
|
||||
.shown(computed(() => project.value.project_types.includes('resourcepack')))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(resolutionsMsg)
|
||||
.fix(
|
||||
fix().project((patch) => {
|
||||
|
||||
@@ -15,24 +15,23 @@ export default function () {
|
||||
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e15ee711bf080508042e70089dd787e',
|
||||
)
|
||||
.icon(LibraryIcon)
|
||||
.navigate('/')
|
||||
.navigate()
|
||||
.children(
|
||||
group()
|
||||
.title('Description Issues?')
|
||||
.children(
|
||||
toggle('insufficient', 'Insufficient')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message('insufficient/header', (s) => ({ CUSTOM_ADVICE: s.custom?.explainer }))
|
||||
.message('insufficient/header')
|
||||
.children(
|
||||
group()
|
||||
.title('Why is this Description Insufficient?')
|
||||
.multiSelect('reason')
|
||||
.children(
|
||||
toggle('custom', 'Custom').children(
|
||||
markdown('explainer')
|
||||
.title('How can the author improve their description?')
|
||||
.required(),
|
||||
.required()
|
||||
.rawMessage((state) => `${state.value}\n\n`),
|
||||
),
|
||||
toggle('fork', 'Fork').message('piece/fork'),
|
||||
toggle('unfinished', 'Unfinished').message('piece/unfinished'),
|
||||
@@ -44,15 +43,13 @@ export default function () {
|
||||
`insufficient/default/${project.value?.minecraft_java_server ? 'servers' : project.value?.project_types?.includes('modpack') ? 'packs' : 'projects'}`,
|
||||
)
|
||||
.rawMessage(async (state) => {
|
||||
const reasons = state?.reason instanceof Set ? state.reason : new Set<string>()
|
||||
return SHOW_SPOILER_ADVICE.some((reason) => reasons.has(reason))
|
||||
return SHOW_SPOILER_ADVICE.some((reason) => state?.[reason] === true)
|
||||
? await md('checklist/messages/description/insufficient/piece/spoiler-guide')(state)
|
||||
: ''
|
||||
}),
|
||||
|
||||
toggle('non-english', 'Non-english')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(() => `non-english${project.value.minecraft_java_server ? '-server' : ''}`)
|
||||
.shown(
|
||||
computed(() => {
|
||||
@@ -63,25 +60,13 @@ export default function () {
|
||||
}),
|
||||
),
|
||||
|
||||
toggle('headers-as-body', 'Headers as body text')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
toggle('headers-as-body', 'Headers as body text').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('image-only', 'Image-only')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
toggle('image-only', 'Image-only').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('non-standard-text', 'Non-standard text')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
toggle('non-standard-text', 'Non-standard text').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('clarity', 'Unclear / Misleading')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('clarity', 'Unclear / Misleading').suggestedStatus('rejected').message(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,18 +16,14 @@ export default function () {
|
||||
.navigate('/gallery')
|
||||
.children(
|
||||
group().children(
|
||||
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').severity('low').message(),
|
||||
toggle('insufficient', 'Insufficient').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('not-relevant', 'Not relevant')
|
||||
.shown(computed(() => project.value.gallery.length > 0))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('showcase-clarity', 'Showcase Clarity')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('showcase-clarity', 'Showcase Clarity').suggestedStatus('rejected').message(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ export default function () {
|
||||
toggle('invalid-link', 'Invalid Link')
|
||||
.shown(computed(() => !!project.value.license?.url))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.children(check('custom-license', 'Invalid Link: Custom License').message())
|
||||
.collect(),
|
||||
@@ -61,12 +60,11 @@ export default function () {
|
||||
toggle('no-source', 'No Source')
|
||||
.shown(needSource)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.rawMessage(async (state) => {
|
||||
if (state.fork) return noSourceForkMsg(state)
|
||||
return noSourceMsg(state)
|
||||
})
|
||||
.children(check('fork', 'No Source: Fork').severity('high')),
|
||||
.children(check('fork', 'No Source: Fork')),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, md, mdOptional, stage, toggle } from '../../types/node'
|
||||
import type { ChildEntry, GroupNodeBuilder } from '../../types/node'
|
||||
import { promptSourceRequired } from '../..'
|
||||
import type { ChildEntry, GroupNode } from '../../types/node'
|
||||
import { group, md, mdOptional, stage, toggle } from '../../types/node'
|
||||
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
const linkNames: Record<string, string> = {}
|
||||
|
||||
type LinkSectionBuilder = GroupNodeBuilder & {
|
||||
type LinkSectionBuilder = GroupNode & {
|
||||
children(...extras: ChildEntry[]): LinkSectionBuilder
|
||||
label(badge: Ref<boolean>): LinkSectionBuilder
|
||||
}
|
||||
@@ -75,7 +75,6 @@ export default function () {
|
||||
.navigate('/settings/links')
|
||||
.shown(computed(() => Object.keys(project.value.link_urls).length > 0))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(async (state) => {
|
||||
const sections = Object.entries(state).filter(
|
||||
([, s]) => s && typeof s === 'object' && !(s instanceof Set),
|
||||
|
||||
@@ -3,7 +3,16 @@ import { DatabaseIcon } from '@modrinth/assets'
|
||||
import { ENVIRONMENTS_COPY, injectProjectPageContext, injectTags } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { dropdown, fix, group, md, option, stage, toggle } from '../../types/node'
|
||||
import {
|
||||
appComponent as _appComponent,
|
||||
dropdown,
|
||||
fix,
|
||||
group,
|
||||
md,
|
||||
option,
|
||||
stage,
|
||||
toggle,
|
||||
} from '../../types/node'
|
||||
import { requiresEnvironmentInfo } from '../../utils'
|
||||
|
||||
const loaderLabels: Record<string, string> = {
|
||||
@@ -13,7 +22,7 @@ const loaderLabels: Record<string, string> = {
|
||||
resourcepack: 'Resource Pack',
|
||||
}
|
||||
|
||||
function formatLoaderLabel(id: string): string {
|
||||
function _formatLoaderLabel(id: string): string {
|
||||
return (
|
||||
loaderLabels[id] ??
|
||||
id
|
||||
@@ -25,7 +34,11 @@ function formatLoaderLabel(id: string): string {
|
||||
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
const { loaders } = injectTags()
|
||||
const { loaders: _loaders, gameVersions: _gameVersions } = injectTags()
|
||||
|
||||
const _currentGameVersions = computed(
|
||||
() => (project.value.game_versions as string[] | undefined) ?? [],
|
||||
)
|
||||
|
||||
return (
|
||||
stage('metadata', 'Metadata')
|
||||
@@ -64,7 +77,6 @@ export default function () {
|
||||
toggle('environment', 'Environment')
|
||||
.shown(computed(() => requiresEnvironmentInfo(project.value.project_types)))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.rawMessage(async (state) => {
|
||||
const correctEnvironment = state?.['correct-environment'] as string | undefined
|
||||
|
||||
@@ -94,7 +106,7 @@ export default function () {
|
||||
.title('Correct Environment')
|
||||
.children(
|
||||
dropdown('correct-environment')
|
||||
.children(
|
||||
.options(
|
||||
...(Object.keys(ENVIRONMENTS_COPY) as Labrinth.Projects.v3.Environment[])
|
||||
.filter((id) => id !== 'unknown')
|
||||
.map((id) => option(id, ENVIRONMENTS_COPY[id].title.defaultMessage ?? id)),
|
||||
@@ -103,67 +115,96 @@ export default function () {
|
||||
.none('Unknown'),
|
||||
),
|
||||
),
|
||||
// TODO: chyz, fix pls (make into single set of buttons where current loaders start selected and non current start non selected
|
||||
// toggle('loader', `Loader${project.value.loaders.length > 1 ? 's' : ''}`).children(
|
||||
// group()
|
||||
// .title('Loader Issues?')
|
||||
// .action(
|
||||
// action()
|
||||
// .suggestedStatus('flagged')
|
||||
// .severity('medium')
|
||||
// .message(async (state) => {
|
||||
// //TODO: chyz
|
||||
// //TODO: coolbot this one is a bit of a doozy
|
||||
// const header = await md('checklist/messages/metadata/loader/incorrect')(state)
|
||||
// const selected = state.loaders
|
||||
// if (selected instanceof Set && selected.size > 0) {
|
||||
// const list = [...selected]
|
||||
// .map((id) => `- ${formatLoaderLabel(id)}`)
|
||||
// .join('\n')
|
||||
// return `${header}\n${list}`
|
||||
// }
|
||||
// return header
|
||||
// }),
|
||||
// )
|
||||
// .children(
|
||||
// toggle('incorrect', 'Incorrect').children(
|
||||
// group()
|
||||
// .title('Incorrect Loaders')
|
||||
// .multiSelect('loaders')
|
||||
// .children(
|
||||
// ...project.value.loaders.map((id) => option(id, formatLoaderLabel(id))),
|
||||
// ),
|
||||
// ),
|
||||
// TODO: chyz, this should be the same interface as incorrect, as a corrections scheme, with selected loaders default on.
|
||||
// toggle('missing', 'Missing').children(
|
||||
// group()
|
||||
// .title('Missing Loaders')
|
||||
// .multiSelect('loaders')
|
||||
// .children(
|
||||
// ...(() => {
|
||||
// //TODO: chyz maybe this can be done better
|
||||
// // (plugin loaders and datapack are marked as valid for mods which makes this suck)
|
||||
// const existingTypes = new Set(
|
||||
// loaders.value
|
||||
// .filter((l) => project.value.loaders.includes(l.name))
|
||||
// .flatMap((l) => l.supported_project_types),
|
||||
// )
|
||||
// const referenceTypes =
|
||||
// existingTypes.size > 0
|
||||
// ? existingTypes
|
||||
// : new Set(project.value.project_types)
|
||||
// return loaders.value
|
||||
// .filter(
|
||||
// (loader) =>
|
||||
// loader.supported_project_types.every((t) => referenceTypes.has(t)) &&
|
||||
// !project.value.loaders.includes(loader.name),
|
||||
// )
|
||||
// .map((loader) => option(loader.name, formatLoaderLabel(loader.name)))
|
||||
// })(),
|
||||
// )/
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
|
||||
toggle('dependencies', 'Dependencies').suggestedStatus('flagged').message(),
|
||||
|
||||
// toggle('loader', 'Loaders (WIP)')
|
||||
// .suggestedStatus('flagged')
|
||||
// .rawMessage(async (state) => {
|
||||
// const selected =
|
||||
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
|
||||
// const current = new Set(project.value.loaders)
|
||||
// const isCorrected =
|
||||
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
|
||||
//
|
||||
// let correct = ''
|
||||
// if (isCorrected) {
|
||||
// const list = [...selected].map((id) => formatLoaderLabel(id)).join(', ')
|
||||
// correct = await md('checklist/messages/metadata/loader/correction', () => ({
|
||||
// LOADERS: list || 'none',
|
||||
// }))(state)
|
||||
// }
|
||||
//
|
||||
// return md('checklist/messages/metadata/loader/inaccurate', () => ({
|
||||
// CORRECT: correct,
|
||||
// }))(state)
|
||||
// })
|
||||
// .fix(
|
||||
// fix().project((patch, state) => {
|
||||
// const selected =
|
||||
// state.loaders instanceof Set ? state.loaders : new Set(project.value.loaders)
|
||||
// const next = [...selected]
|
||||
// const current = project.value.loaders
|
||||
// if (next.length === current.length && next.every((id) => current.includes(id)))
|
||||
// return
|
||||
// patch.loaders = next
|
||||
// }),
|
||||
// )
|
||||
// .children(
|
||||
// appComponent('loaders', 'loader-picker')
|
||||
// .valueKind('set')
|
||||
// .initial(() => new Set(project.value.loaders))
|
||||
// .props((ctx) => ({
|
||||
// loaders: loaders.value,
|
||||
// toggleLoader: ctx.toggleSetValue,
|
||||
// })),
|
||||
// ),
|
||||
//
|
||||
// toggle('game-version', 'Game Versions (WIP)')
|
||||
// .suggestedStatus('flagged')
|
||||
// .rawMessage(async (state) => {
|
||||
// const selected =
|
||||
// state['game-versions'] instanceof Set
|
||||
// ? state['game-versions']
|
||||
// : new Set(currentGameVersions.value)
|
||||
// const current = new Set(currentGameVersions.value)
|
||||
// const isCorrected =
|
||||
// selected.size !== current.size || [...selected].some((id) => !current.has(id))
|
||||
//
|
||||
// let correct = ''
|
||||
// if (isCorrected) {
|
||||
// const list = [...selected].join(', ')
|
||||
// correct = await md('checklist/messages/metadata/game-version/correction', () => ({
|
||||
// GAME_VERSIONS: list || 'none',
|
||||
// }))(state)
|
||||
// }
|
||||
//
|
||||
// return md('checklist/messages/metadata/game-version/inaccurate', () => ({
|
||||
// CORRECT: correct,
|
||||
// }))(state)
|
||||
// })
|
||||
// .fix(
|
||||
// fix().project((patch, state) => {
|
||||
// const selected =
|
||||
// state['game-versions'] instanceof Set
|
||||
// ? state['game-versions']
|
||||
// : new Set(currentGameVersions.value)
|
||||
// const next = [...selected]
|
||||
// const current = currentGameVersions.value
|
||||
// if (next.length === current.length && next.every((id) => current.includes(id)))
|
||||
// return
|
||||
// patch.game_versions = next
|
||||
// }),
|
||||
// )
|
||||
// .children(
|
||||
// appComponent('game-versions', 'game-version-picker')
|
||||
// .valueKind('set')
|
||||
// .initial(() => new Set(currentGameVersions.value))
|
||||
// .props(() => ({
|
||||
// gameVersions: gameVersions.value,
|
||||
// noHeader: true,
|
||||
// })),
|
||||
// ),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SignatureIcon } from '@modrinth/assets'
|
||||
import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import { injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, stage, toggle } from '../../types/node'
|
||||
|
||||
function isResolved(attributionGroup: Labrinth.Attribution.Internal.AttributionGroup): boolean {
|
||||
const attribution = attributionGroup.attribution
|
||||
if (!attribution) return false
|
||||
if (attribution.kind === 'globally_allowed') return true
|
||||
return attribution.moderation_status?.kind === 'approved'
|
||||
}
|
||||
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const { data: attributionData } = useQuery({
|
||||
queryKey: ['project-attribution', project.value.id],
|
||||
queryFn: () => labrinth.attribution_internal.listProjectAttribution(project.value.id),
|
||||
})
|
||||
|
||||
const unresolvedCount = computed(
|
||||
() => (attributionData.value ?? []).filter((g) => !isResolved(g)).length,
|
||||
)
|
||||
|
||||
return stage('permissions', 'Modpack Permissions')
|
||||
.hint("Does this project's external content have any issues?")
|
||||
@@ -16,30 +35,24 @@ export default function () {
|
||||
computed(
|
||||
() =>
|
||||
(project.value.project_types?.includes('modpack') ?? false) &&
|
||||
!project.value.minecraft_server,
|
||||
!project.value.minecraft_server &&
|
||||
unresolvedCount.value > 0,
|
||||
),
|
||||
)
|
||||
.sticky()
|
||||
.children(
|
||||
group().children(
|
||||
toggle('invalid-permissions', 'Invalid permissions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('invalid-permissions', 'Invalid permissions').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('prohibited-external-content', 'Prohibited externals')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('missing-permissions', 'Missing permissions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('missing-permissions', 'Missing permissions').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('non-commercial-external-content', 'Non-commercial externals')
|
||||
.shown(computed(() => project.value.monetization_status === 'monetized'))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, stage, text, toggle } from '../../types/node'
|
||||
import { Priorities } from '../priorities.ts'
|
||||
|
||||
//TODO chyz
|
||||
//TODO coolbot needs discussion
|
||||
@@ -20,15 +21,15 @@ export default function () {
|
||||
group().children(
|
||||
toggle('issue-warning', 'Issue warning')
|
||||
.suggestedStatus('approved')
|
||||
.severity('low')
|
||||
.message(),
|
||||
.message()
|
||||
.priority(Priorities.tempLast),
|
||||
|
||||
toggle('missed-deadline', 'Missed due date')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
STATUS: state.status,
|
||||
}))
|
||||
.priority(Priorities.tempLast)
|
||||
.children(
|
||||
//TODO: chyz this shouldn't need to be provided by moderator
|
||||
text('status').title('What status is the project being set to?').required(),
|
||||
@@ -36,35 +37,31 @@ export default function () {
|
||||
|
||||
toggle('metadata-issue', 'Incorrect metadata')
|
||||
.suggestedStatus('approved')
|
||||
.severity('low')
|
||||
.message()
|
||||
.children(
|
||||
toggle('dependencies', 'Missing Dependencies')
|
||||
.severity('low')
|
||||
.message(undefined, (state) => ({
|
||||
DEPENDENCY_NAME: state['dependency-name'],
|
||||
DEPENDENCY_LINK: state['dependency-link'],
|
||||
.message((state) => ({
|
||||
DEPENDENCY_NAME: state['name'],
|
||||
DEPENDENCY_LINK: state['link'],
|
||||
}))
|
||||
.children(
|
||||
text('dependency-name').title('Dependency name').required(),
|
||||
text('dependency-link').title('Dependency link').required(),
|
||||
text('name').title('Dependency name').required(),
|
||||
text('link').title('Dependency link').required(),
|
||||
),
|
||||
|
||||
toggle('mc-versions', 'Game versions')
|
||||
.severity('low')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
SPECIFICS: state.specifics,
|
||||
}))
|
||||
.children(text('specifics').title('More details about the game versions issue?')),
|
||||
|
||||
toggle('loaders', 'Loaders')
|
||||
.severity('low')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
SPECIFICS: state.specifics,
|
||||
}))
|
||||
.children(text('specifics').title('More details about the loaders issue?')),
|
||||
|
||||
toggle('license', 'Inconsistent Licensing').severity('low').message(),
|
||||
toggle('license', 'Inconsistent Licensing').message(),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
|
||||
@@ -32,13 +32,9 @@ export default function () {
|
||||
group().children(
|
||||
toggle('ignored', 'Yes')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.children(
|
||||
toggle('warning', 'Multiple times in a row')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('warning', 'Multiple times in a row').suggestedStatus('rejected').message(),
|
||||
)
|
||||
.collect(),
|
||||
),
|
||||
|
||||
@@ -20,14 +20,13 @@ export default function () {
|
||||
'https://www.notion.so/2e15ee711bf080e4a41df61bbab49892#2e35ee711bf080d1a0a2cda3ff2ce997',
|
||||
)
|
||||
.icon(CopyrightIcon)
|
||||
.navigate('/')
|
||||
.navigate()
|
||||
.children(
|
||||
group().children(
|
||||
toggle('reupload', 'Re-upload')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
ORIGINAL_PROJECT: state['original-project'],
|
||||
ORIGINAL_AUTHOR: state['original-author'],
|
||||
}))
|
||||
@@ -39,25 +38,19 @@ export default function () {
|
||||
toggle('unclear-fork', 'Unclear Fork')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('insufficient-fork', 'Insufficient Fork')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('request-proof', 'Proof of permissions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('request-proof', 'Proof of permissions').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('identity-verification', 'Verify Identity')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
PLATFORM: state.platform,
|
||||
}))
|
||||
.children(text('platform').title('Where else can the project be found?').required()),
|
||||
@@ -65,8 +58,7 @@ export default function () {
|
||||
toggle('identity-verification-server', 'Verify Identity')
|
||||
.shown(computed(() => !!project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
CONTACT: state.contact,
|
||||
}))
|
||||
.children(text('contact').title('Known public contact method').required()),
|
||||
@@ -74,17 +66,15 @@ export default function () {
|
||||
toggle('request-proof-server', 'Reuploaded pack')
|
||||
.shown(isServerModpack)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(),
|
||||
|
||||
toggle('custom-pack-verification', 'Override verification')
|
||||
.shown(isServerModpack)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message()
|
||||
.children(
|
||||
check('list', 'List overrides?')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
OVERRIDES: state.overrides,
|
||||
}))
|
||||
.children(markdown('overrides').title('Add list of overrides.')),
|
||||
@@ -94,8 +84,7 @@ export default function () {
|
||||
toggle('custom-pack-prohibited', 'Forbidden Overrides')
|
||||
.shown(isServerModpack)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
OVERRIDES: state.overrides,
|
||||
}))
|
||||
.children(markdown('overrides').title('Forbidden overrides list').required()),
|
||||
|
||||
+22
-38
@@ -2,9 +2,8 @@ import { ListBulletedIcon } from '@modrinth/assets'
|
||||
import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { group, markdown, option, stage, toggle } from '../../types/node'
|
||||
import { group, markdown, stage, toggle } from '../../types/node'
|
||||
|
||||
//TODO: coolbot have fun :3
|
||||
export default function () {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
|
||||
@@ -20,61 +19,49 @@ export default function () {
|
||||
toggle('paid-access-server', 'Paid access server')
|
||||
.shown(computed(() => !!project.value.minecraft_server))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message(),
|
||||
|
||||
// TODO: chyz, the lists built by these message have empty line gaps.
|
||||
toggle('prohibited-content', 'Prohibited Content')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message('prohibited-content-header')
|
||||
.collect()
|
||||
.children(
|
||||
group()
|
||||
.multiSelect('options')
|
||||
.title('Which Prohibited Content rules does this project violate?')
|
||||
.children(
|
||||
option('objectionable', 'Objectionable').message(),
|
||||
option('discriminatory', 'Discriminatory or Explicit').message(),
|
||||
option('ip-infringement', 'IP Infringement').message(),
|
||||
option('legal-rights', 'Rights Violation').message(),
|
||||
option('illegal-activity', 'Illegal Activity').message(),
|
||||
option('harmful', 'Harmful or Deceptive').message(),
|
||||
option('misleading', 'Misleading claims').message(),
|
||||
option('impersonation', 'Impersonation').message(),
|
||||
option('false-endorsement', 'False Endorsement').message(),
|
||||
option('profanity', 'Profanity').message(),
|
||||
option('undisclosed-upload', 'Undisclosed Data Upload').message(),
|
||||
option('mojang-bypass', 'Mojang Bypass').message(),
|
||||
toggle('objectionable', 'Objectionable').message(),
|
||||
toggle('discriminatory', 'Discriminatory or Explicit').message(),
|
||||
toggle('ip-infringement', 'IP Infringement').message(),
|
||||
toggle('legal-rights', 'Rights Violation').message(),
|
||||
toggle('illegal-activity', 'Illegal Activity').message(),
|
||||
toggle('harmful', 'Harmful or Deceptive').message(),
|
||||
toggle('misleading', 'Misleading claims').message(),
|
||||
toggle('impersonation', 'Impersonation').message(),
|
||||
toggle('false-endorsement', 'False Endorsement').message(),
|
||||
toggle('profanity', 'Profanity').message(),
|
||||
toggle('undisclosed-upload', 'Undisclosed Data Upload').message(),
|
||||
toggle('mojang-bypass', 'Mojang Bypass').message(),
|
||||
),
|
||||
),
|
||||
|
||||
toggle('cheat-or-hack-advertising', 'Hacks')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message(),
|
||||
toggle('cheat-or-hack-advertising', 'Hacks').suggestedStatus('rejected').message(),
|
||||
|
||||
toggle('server-side-opt-out', 'Opt-out')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('high')
|
||||
.message(),
|
||||
toggle('server-side-opt-out', 'Opt-out').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('server-side-opt-in', 'Opt-in')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('high')
|
||||
.message('server-side-opt-in-header')
|
||||
.collect()
|
||||
.children(
|
||||
group()
|
||||
.multiSelect('options')
|
||||
.title('Which features require a Server-side Opt-in?')
|
||||
.children(
|
||||
option('x-ray', 'X-ray').message(),
|
||||
option('aim-bot', 'Aim Assist').message(),
|
||||
option('movement', 'Movement').message(),
|
||||
option('pvp', 'PvP').message(),
|
||||
option('hiding-mods', 'Anti 3.x').message(),
|
||||
option('item-duplication', 'Dupe').message(),
|
||||
toggle('x-ray', 'X-ray').message(),
|
||||
toggle('aim-bot', 'Aim Assist').message(),
|
||||
toggle('movement', 'Movement').message(),
|
||||
toggle('pvp', 'PvP').message(),
|
||||
toggle('hiding-mods', 'Anti 3.x').message(),
|
||||
toggle('item-duplication', 'Dupe').message(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -88,14 +75,11 @@ export default function () {
|
||||
),
|
||||
)
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('rule-breaking-other', 'Other')
|
||||
// TODO: chyz, the required asterisk is on a separate line
|
||||
.suggestedStatus('rejected')
|
||||
.severity('critical')
|
||||
.message(undefined, (state) => ({ MESSAGE: state.message }))
|
||||
.message((state) => ({ MESSAGE: state.message }))
|
||||
.children(
|
||||
markdown('message').title('Explain how it infringes on content rules.').required(),
|
||||
),
|
||||
@@ -3,9 +3,9 @@ import { injectProjectPageContext } from '@modrinth/ui'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { NodeState, StageNodeBuilder } from '../../types/node'
|
||||
import type { AnyNode, ChildNode, NodeState, StageNode } from '../../types/node'
|
||||
import {
|
||||
NodeBuilder,
|
||||
externalGroup,
|
||||
getBooleanChildState,
|
||||
group,
|
||||
isNodeActive,
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { Priorities } from '../priorities.ts'
|
||||
|
||||
export default function (
|
||||
mainStages: StageNodeBuilder[],
|
||||
mainStages: StageNode[],
|
||||
globalState: Ref<Record<string, Record<string, NodeState>>>,
|
||||
) {
|
||||
const { projectV3: project } = injectProjectPageContext()
|
||||
@@ -47,23 +47,26 @@ export default function (
|
||||
.priority(Priorities.alerts)
|
||||
.applyFixes()
|
||||
.children(
|
||||
computed<NodeBuilder | null>(() => {
|
||||
const fixNodes: NodeBuilder[] = []
|
||||
computed<AnyNode | null>(() => {
|
||||
const fixGroups: ChildNode[] = []
|
||||
walkNodes(
|
||||
[group().children(...mainStages)],
|
||||
(globalState.value ?? {}) as unknown as Record<string, NodeState>,
|
||||
(node, nodeState) => {
|
||||
if (!node._fixes.length) return
|
||||
(node, nodeState, _localState, path) => {
|
||||
if (!('_fixes' in node) || !(node as { _fixes: unknown[] })._fixes.length) return
|
||||
if (!isNodeActive(node, nodeState)) return
|
||||
const childState = getBooleanChildState(nodeState)
|
||||
fixNodes.push(
|
||||
...resolveChildren(node, childState).filter(
|
||||
(c): c is NodeBuilder => c instanceof NodeBuilder,
|
||||
),
|
||||
const children = resolveChildren(
|
||||
node as never,
|
||||
getBooleanChildState(nodeState),
|
||||
).filter(
|
||||
(c): c is Exclude<ChildNode, string | (() => unknown)> =>
|
||||
typeof c === 'object' && c !== null,
|
||||
)
|
||||
if (children.length === 0) return
|
||||
fixGroups.push(externalGroup(path).children(...children))
|
||||
},
|
||||
)
|
||||
return fixNodes.length > 0 ? group().children(...fixNodes) : null
|
||||
return fixGroups.length > 0 ? group().children(...fixGroups) : null
|
||||
}),
|
||||
),
|
||||
|
||||
|
||||
@@ -31,20 +31,17 @@ export default function () {
|
||||
toggle('insufficient', 'Insufficient')
|
||||
.enabled((state) => !state['repeat-title'])
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('repeat-title', 'Repeat of Title')
|
||||
.enabled((state) => !state.insufficient)
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
|
||||
toggle('formatting', 'Formatting').suggestedStatus('flagged').severity('low').message(),
|
||||
toggle('formatting', 'Formatting').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('non-english', 'Non-english')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.shown(
|
||||
computed(() => {
|
||||
@@ -62,7 +59,6 @@ export default function () {
|
||||
toggle('repeat-ip', 'Repeat of IP')
|
||||
.shown(computed(() => !!project.value?.minecraft_server))
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -5,18 +5,23 @@ import {
|
||||
TagCategoryRefreshCcwIcon,
|
||||
TagCategoryWandSparklesIcon,
|
||||
UserPlusIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Alert, injectModrinthClient, injectProjectPageContext } from '@modrinth/ui'
|
||||
import {
|
||||
Alert,
|
||||
injectModrinthClient,
|
||||
injectProjectPageContext,
|
||||
ProjectStatusLink,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { md, type NodeState } from '../../types/node'
|
||||
import { button, fix, group, option, stage, text, toggle } from '../../types/node'
|
||||
import { check, fix, group, md, stage, text, toggle } from '../../types/node'
|
||||
|
||||
const STALE_TIME = 1000 * 60 * 5
|
||||
|
||||
type AutoSlugStatus = 'loading' | 'available' | 'unavailable'
|
||||
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | null
|
||||
type SlugValidation = 'checking' | 'available' | 'unchanged' | 'taken' | 'empty' | 'invalid' | null
|
||||
|
||||
//TODO: make this not a copy of frontend/src/utils/slugs.generateUrlSlug
|
||||
// (as in move the other one so we can use it here)
|
||||
@@ -46,12 +51,6 @@ export default function () {
|
||||
const slugValidation = ref<SlugValidation>(null)
|
||||
let slugDebounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function currentSlug(state: Record<string, NodeState>) {
|
||||
return (
|
||||
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value ?? project.value.slug
|
||||
)
|
||||
}
|
||||
|
||||
async function checkSlugTaken(slug: string): Promise<Labrinth.Projects.v3.Project | null> {
|
||||
try {
|
||||
return await queryClient.fetchQuery({
|
||||
@@ -66,8 +65,13 @@ export default function () {
|
||||
}
|
||||
|
||||
const SlugStatus = () => {
|
||||
const v = slugValidation.value
|
||||
if (v === null) return null
|
||||
const v =
|
||||
slugValidation.value ??
|
||||
(autoSlugStatus.value === 'loading'
|
||||
? 'checking'
|
||||
: autoSlugStatus.value === 'unavailable'
|
||||
? 'taken'
|
||||
: 'available')
|
||||
if (v === 'checking')
|
||||
return (
|
||||
<Alert type="checking" class="w-full">
|
||||
@@ -86,19 +90,25 @@ export default function () {
|
||||
Slug is available
|
||||
</Alert>
|
||||
)
|
||||
if (v === 'empty')
|
||||
return (
|
||||
<Alert type="error" class="w-full">
|
||||
Slug cannot be empty
|
||||
</Alert>
|
||||
)
|
||||
if (v === 'invalid')
|
||||
return (
|
||||
<Alert type="error" class="w-full">
|
||||
Invalid Slug
|
||||
</Alert>
|
||||
)
|
||||
const by = correctSlugConflict.value
|
||||
return (
|
||||
<Alert type="error" class="w-full">
|
||||
Slug taken
|
||||
{by ? (
|
||||
<>
|
||||
{' by '}
|
||||
<a href={`/project/${by.slug}`} target="_blank" class="underline">
|
||||
{by.name}
|
||||
</a>
|
||||
{` (${by.status})`}
|
||||
</>
|
||||
) : null}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
Slug taken
|
||||
{by ? <ProjectStatusLink project={by} newTab /> : null}
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -157,7 +167,9 @@ export default function () {
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// fall through to 'unavailable' below
|
||||
}
|
||||
|
||||
autoSlugStatus.value = 'unavailable'
|
||||
},
|
||||
@@ -190,12 +202,7 @@ export default function () {
|
||||
{autoSlugStatus.value === 'loading' ? (
|
||||
'...'
|
||||
) : by ? (
|
||||
<>
|
||||
<a href={`/project/${by.slug}`} target="_blank" class="underline">
|
||||
{by.name}
|
||||
</a>
|
||||
{` (${by.status})`}
|
||||
</>
|
||||
<ProjectStatusLink project={by} newTab />
|
||||
) : (
|
||||
'No'
|
||||
)}
|
||||
@@ -206,30 +213,22 @@ export default function () {
|
||||
group('title')
|
||||
.title('Title Issues?')
|
||||
.children(
|
||||
toggle('useless-info', 'Contains Useless Info')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('low')
|
||||
.message(),
|
||||
toggle('useless-info', 'Contains Useless Info').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('minecraft-branding', 'Minecraft Title')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
toggle('minecraft-branding', 'Minecraft Title').suggestedStatus('flagged').message(),
|
||||
|
||||
toggle('similarities', 'Title Similarities')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message()
|
||||
.children(
|
||||
group()
|
||||
.title('Similarities Additional Info')
|
||||
.multiSelect('options')
|
||||
.children(
|
||||
option('modpack', 'Modpack Named After Mod')
|
||||
check('modpack', 'Modpack Named After Mod')
|
||||
.shown(computed(() => project.value.project_types.includes('modpack')))
|
||||
.message(),
|
||||
|
||||
option('fork', 'Forked Project')
|
||||
check('fork', 'Forked Project')
|
||||
.shown(computed(() => !project.value?.minecraft_server))
|
||||
.message(),
|
||||
),
|
||||
@@ -241,100 +240,76 @@ export default function () {
|
||||
.title('Slug Issues')
|
||||
.shown(computed(() => hasCustomSlug(project.value)))
|
||||
.children(
|
||||
group()
|
||||
.multiSelect('issues')
|
||||
.children(
|
||||
toggle('misused', 'Misused')
|
||||
.children(
|
||||
group()
|
||||
.title('Correct Slug')
|
||||
.children(
|
||||
text('correct-slug')
|
||||
.initial(() => resolvedAutoSlug.value ?? project.value.slug)
|
||||
.onChange((value, { override }) => {
|
||||
if (!value) return override(project.value.slug ?? '')
|
||||
clearTimeout(slugDebounceTimer)
|
||||
if (value === project.value.slug) {
|
||||
slugValidation.value = 'unchanged'
|
||||
return
|
||||
group().children(
|
||||
toggle('misused', 'Misused')
|
||||
.children(
|
||||
group()
|
||||
.title('Correct Slug')
|
||||
.children(
|
||||
text('correct-slug')
|
||||
.initial(() => resolvedAutoSlug.value ?? project.value.slug ?? '')
|
||||
.onChange((value) => {
|
||||
clearTimeout(slugDebounceTimer)
|
||||
if (value === project.value.slug) {
|
||||
slugValidation.value = 'unchanged'
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
slugValidation.value = 'empty'
|
||||
correctSlugConflict.value = null
|
||||
return
|
||||
}
|
||||
if (generateUrlSlug(value) !== value) {
|
||||
slugValidation.value = 'invalid'
|
||||
correctSlugConflict.value = null
|
||||
return
|
||||
}
|
||||
slugValidation.value = 'checking'
|
||||
slugDebounceTimer = setTimeout(async () => {
|
||||
const conflict = await checkSlugTaken(value).catch(() => null)
|
||||
if (conflict !== null && conflict.id !== project.value.id) {
|
||||
correctSlugConflict.value = conflict
|
||||
slugValidation.value = 'taken'
|
||||
} else {
|
||||
correctSlugConflict.value = null
|
||||
slugValidation.value = 'available'
|
||||
}
|
||||
slugValidation.value = 'checking'
|
||||
slugDebounceTimer = setTimeout(async () => {
|
||||
const conflict = await checkSlugTaken(value).catch(() => null)
|
||||
if (conflict !== null && conflict.id !== project.value.id) {
|
||||
correctSlugConflict.value = conflict
|
||||
slugValidation.value = 'taken'
|
||||
} else {
|
||||
correctSlugConflict.value = null
|
||||
slugValidation.value = 'available'
|
||||
}
|
||||
}, 400)
|
||||
}),
|
||||
}, 400)
|
||||
})
|
||||
.tweak(TagCategoryWandSparklesIcon, () =>
|
||||
autoSlugStatus.value === 'available' ? resolvedAutoSlug.value : null,
|
||||
)
|
||||
.tweak(UserPlusIcon, (current) =>
|
||||
ownerUsername.value && !current?.includes(ownerUsername.value)
|
||||
? `${current}-${ownerUsername.value}`
|
||||
: null,
|
||||
)
|
||||
.tweak(WrenchIcon, (current) => generateUrlSlug(current ?? ''))
|
||||
.tweak(TagCategoryRefreshCcwIcon, () => project.value.slug),
|
||||
|
||||
button()
|
||||
.icon(TagCategoryWandSparklesIcon)
|
||||
.tooltip(computed(() => resolvedAutoSlug.value ?? ''))
|
||||
.enabled(
|
||||
(state) =>
|
||||
autoSlugStatus.value === 'available' &&
|
||||
resolvedAutoSlug.value !== null &&
|
||||
resolvedAutoSlug.value !== currentSlug(state),
|
||||
)
|
||||
.onClick((state) => {
|
||||
if (resolvedAutoSlug.value) state['correct-slug'] = resolvedAutoSlug.value
|
||||
}),
|
||||
|
||||
button()
|
||||
.icon(UserPlusIcon)
|
||||
.tooltip((state) => {
|
||||
const current = currentSlug(state)
|
||||
if (!ownerUsername.value || current?.includes(ownerUsername.value))
|
||||
return current ?? ''
|
||||
return `${current}-${ownerUsername.value}`
|
||||
})
|
||||
.enabled(
|
||||
(state) =>
|
||||
ownerUsername.value !== null &&
|
||||
!currentSlug(state)?.includes(ownerUsername.value),
|
||||
)
|
||||
.onClick((state) => {
|
||||
state['correct-slug'] = `${currentSlug(state)}-${ownerUsername.value}`
|
||||
}),
|
||||
|
||||
button()
|
||||
.icon(TagCategoryRefreshCcwIcon)
|
||||
.tooltip(computed(() => project.value.slug ?? ''))
|
||||
.enabled((state) => currentSlug(state) !== project.value.slug)
|
||||
.onClick((state) => {
|
||||
state['correct-slug'] = project.value.slug
|
||||
}),
|
||||
|
||||
SlugStatus,
|
||||
),
|
||||
)
|
||||
.rawMessage(async (state) => {
|
||||
let correct = ''
|
||||
if (slugValidation.value === 'available') {
|
||||
const slug = state['correct-slug'] as string | undefined
|
||||
if (slug)
|
||||
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
|
||||
SUGGESTED_SLUG: slug,
|
||||
}))(state)
|
||||
}
|
||||
return md('checklist/messages/title-slug/slug/misused', () => ({
|
||||
CORRECT: correct,
|
||||
}))(state)
|
||||
})
|
||||
.fix(
|
||||
//TODO chyz think of some way to have initial values actually be reflected in state without having to store them
|
||||
fix().project((patch, state) => {
|
||||
const slug =
|
||||
(state['correct-slug'] as string | undefined) ?? resolvedAutoSlug.value
|
||||
if (!slug || slug === project.value.slug) return
|
||||
patch.slug = slug
|
||||
}),
|
||||
),
|
||||
),
|
||||
SlugStatus,
|
||||
),
|
||||
)
|
||||
.rawMessage(async (state) => {
|
||||
let correct = ''
|
||||
if (slugValidation.value === 'available') {
|
||||
const slug = state['correct-slug'] as string | undefined
|
||||
if (slug)
|
||||
correct = await md('checklist/messages/title-slug/slug/correction', () => ({
|
||||
SUGGESTED_SLUG: slug,
|
||||
}))(state)
|
||||
}
|
||||
return md('checklist/messages/title-slug/slug/misused', () => ({
|
||||
CORRECT: correct,
|
||||
}))(state)
|
||||
})
|
||||
.fix(
|
||||
fix().project((patch, state) => {
|
||||
if (slugValidation.value !== 'available') return
|
||||
patch.slug = state['correct-slug'] as string
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,38 +19,31 @@ export default function () {
|
||||
group().children(
|
||||
toggle('incorrect-additional-files', 'Incorrect additional files')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('incorrect-project-type', 'Incorrect Project Type')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.children(
|
||||
dropdown('type')
|
||||
.title('Correct Project Type')
|
||||
.required()
|
||||
.none('Unknown')
|
||||
.children(
|
||||
option('modpack', 'Modpack')
|
||||
.shown(computed(() => !project.value.project_types.includes('modpack')))
|
||||
.message(),
|
||||
option('resourcepack', 'Resource Pack')
|
||||
.shown(computed(() => !project.value.project_types.includes('resourcepack')))
|
||||
.message(),
|
||||
option('datapack', 'Data Pack')
|
||||
.shown(computed(() => !project.value.loaders.includes('datapack')))
|
||||
.message(),
|
||||
.options(
|
||||
option('modpack', 'Modpack').message(),
|
||||
option('resourcepack', 'Resource Pack').message(),
|
||||
option('datapack', 'Data Pack').message(),
|
||||
),
|
||||
)
|
||||
.collect(),
|
||||
|
||||
toggle('alternate-versions', 'Alternate Versions')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('high')
|
||||
.children(
|
||||
dropdown('distribution')
|
||||
.title('Distribution Type')
|
||||
.required()
|
||||
.none('Unknown')
|
||||
.children(
|
||||
.options(
|
||||
option('primary', 'Primary Files').message(),
|
||||
option('additional', 'Additional Files').message(),
|
||||
option('mono', 'Monofile')
|
||||
@@ -78,7 +71,6 @@ export default function () {
|
||||
toggle('vanilla-assets', 'Vanilla Assets')
|
||||
.shown(computed(() => project.value.project_types.includes('resourcepack')))
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('redist-libs', 'Packed Libs')
|
||||
@@ -90,18 +82,15 @@ export default function () {
|
||||
),
|
||||
)
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('duplicate-primary-files', 'Duplicate Primary Files')
|
||||
.suggestedStatus('flagged')
|
||||
.severity('medium')
|
||||
.message(),
|
||||
|
||||
toggle('unsupported', 'Unsupported')
|
||||
.suggestedStatus('rejected')
|
||||
.severity('medium')
|
||||
.message(undefined, (state) => ({
|
||||
.message((state) => ({
|
||||
INVALID_TYPE: state['invalid-type'],
|
||||
}))
|
||||
.children(text('invalid-type').title('Unsupported Type').required()),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import keybinds from '../data/keybinds.ts'
|
||||
import {
|
||||
type KeybindDefinition,
|
||||
type KeybindListener,
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
type ModerationContext,
|
||||
normalizeKeybind,
|
||||
} from '../types/keybinds.ts'
|
||||
import keybinds from '../data/keybinds.ts'
|
||||
|
||||
function normalizeKeybinds(
|
||||
keybind: KeybindDefinition | KeybindDefinition[] | string | string[],
|
||||
@@ -58,6 +58,9 @@ export class Keybinds {
|
||||
continue
|
||||
}
|
||||
|
||||
// The scope check above guarantees ctx matches keybind's expected context shape,
|
||||
// but TS can't correlate that narrowing across these two independently-typed variables.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (keybind.enabled && !keybind.enabled(ctx as any)) {
|
||||
continue
|
||||
}
|
||||
@@ -66,6 +69,7 @@ export class Keybinds {
|
||||
const matches = definitions.some((def) => matchesKeybind(event, def))
|
||||
|
||||
if (matches) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
keybind.action(ctx as any)
|
||||
|
||||
const shouldPrevent = definitions.some((def) => def.preventDefault !== false)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
|
||||
import { moderationSettings } from '../index.ts'
|
||||
import { isValidFor, type SettingDefinitionBase } from '../types/settings.ts'
|
||||
|
||||
export class Settings {
|
||||
private readonly settings: { [id: string]: any }
|
||||
private readonly settings: { [id: string]: unknown }
|
||||
private readonly onChange: () => void
|
||||
|
||||
constructor(
|
||||
settings: { [id: string]: any } | undefined = undefined,
|
||||
settings: { [id: string]: unknown } | undefined = undefined,
|
||||
onChange: () => void = () => {},
|
||||
) {
|
||||
this.settings = settings || {}
|
||||
@@ -16,11 +16,15 @@ export class Settings {
|
||||
get<T>(definition: SettingDefinitionBase<T>): T {
|
||||
const value = this.settings[definition.id]
|
||||
|
||||
return (isValidFor(definition, value) ? value : undefined) ?? definition.default
|
||||
return (
|
||||
(isValidFor(definition as SettingDefinitionBase<unknown>, value)
|
||||
? (value as T)
|
||||
: undefined) ?? definition.default
|
||||
)
|
||||
}
|
||||
|
||||
set<T>(definition: SettingDefinitionBase<T>, value?: T): void {
|
||||
const previous = this.settings[definition.id] ?? definition.default
|
||||
const previous = (this.settings[definition.id] as T | undefined) ?? definition.default
|
||||
this.settings[definition.id] = value
|
||||
definition.onChange?.(previous, value ?? definition.default)
|
||||
this.onChange()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export { default as checklist, stages, useStages } from './data/checklist'
|
||||
export { useStages } from './data/checklist'
|
||||
export { default as keybinds } from './data/keybinds'
|
||||
export { default as moderationSettings } from './data/settings'
|
||||
export { default as nags } from './data/nags'
|
||||
export * from './data/nags/index'
|
||||
export { default as attributionQuickReplies } from './data/quick-replies/permissions-quick-replies'
|
||||
@@ -9,17 +8,14 @@ export {
|
||||
type TechReviewContext,
|
||||
default as techReviewQuickReplies,
|
||||
} from './data/quick-replies/tech-review-quick-replies'
|
||||
export { default as moderationSettings } from './data/settings'
|
||||
export * from './handles/keybinds'
|
||||
export * from './handles/settings'
|
||||
export * from './locales'
|
||||
export * from './types/actions'
|
||||
export * from './types/keybinds'
|
||||
export * from './types/settings'
|
||||
export * from './types/messages'
|
||||
export * from './types/nags'
|
||||
export * from './types/node'
|
||||
export * from './types/priority'
|
||||
export * from './types/quick-reply'
|
||||
export * from './types/reports'
|
||||
export * from './types/stage'
|
||||
export * from './types/settings'
|
||||
export * from './utils'
|
||||
export * from './handles/keybinds'
|
||||
export * from './handles/settings'
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { WeightedMessage } from './messages'
|
||||
|
||||
export type ActionType =
|
||||
| 'button'
|
||||
| 'dropdown'
|
||||
| 'multi-select-chips'
|
||||
| 'toggle'
|
||||
| 'conditional-button'
|
||||
|
||||
export type Action =
|
||||
| ButtonAction
|
||||
| DropdownAction
|
||||
| MultiSelectChipsAction
|
||||
| ToggleAction
|
||||
| ConditionalButtonAction
|
||||
|
||||
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
|
||||
export type ModerationSeverity = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
export interface ChecklistActionContext {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
projectV3: Labrinth.Projects.v3.Project
|
||||
versions?: Labrinth.Versions.v2.Version[] | null
|
||||
}
|
||||
|
||||
export interface BaseAction {
|
||||
/**
|
||||
* The type of action, which determines how the action is presented to the moderator and what it does.
|
||||
*/
|
||||
type: ActionType
|
||||
|
||||
/**
|
||||
* Any additional text data that is required to complete the action.
|
||||
*/
|
||||
relevantExtraInput?: AdditionalTextInput[]
|
||||
|
||||
/**
|
||||
* Suggested moderation status when this action is selected.
|
||||
*/
|
||||
suggestedStatus?: ModerationStatus
|
||||
|
||||
/**
|
||||
* Suggested severity level for this moderation action.
|
||||
*/
|
||||
severity?: ModerationSeverity
|
||||
|
||||
/**
|
||||
* Actions that become available when this action is selected.
|
||||
*/
|
||||
enablesActions?: Action[]
|
||||
|
||||
/**
|
||||
* Actions that become unavailable when this action is selected.
|
||||
*/
|
||||
disablesActions?: string[] // Array of action IDs
|
||||
|
||||
/**
|
||||
* Unique identifier for this action, used for conditional logic.
|
||||
*/
|
||||
id?: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this action should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the action is always shown.
|
||||
*/
|
||||
shouldShow?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3: Labrinth.Projects.v3.Project,
|
||||
context?: ChecklistActionContext,
|
||||
) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a conditional message that changes based on other selected actions.
|
||||
*/
|
||||
export interface ConditionalMessage extends WeightedMessage {
|
||||
/**
|
||||
* Conditions that must be met for this message to be used.
|
||||
*/
|
||||
conditions: {
|
||||
/**
|
||||
* Action IDs that must be selected for this message to apply.
|
||||
*/
|
||||
requiredActions?: string[]
|
||||
|
||||
/**
|
||||
* Action IDs that must NOT be selected for this message to apply.
|
||||
*/
|
||||
excludedActions?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a button action, which is a simple toggle button that can be used to append a message to the final moderation message.
|
||||
*/
|
||||
export interface ButtonAction extends BaseAction, WeightedMessage {
|
||||
type: 'button'
|
||||
|
||||
/**
|
||||
* The label of the button, which is displayed to the moderator. The text on the button.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* Alternative messages based on other selected actions.
|
||||
*/
|
||||
conditionalMessages?: ConditionalMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a simple toggle/checkbox action with separate layout handling.
|
||||
*/
|
||||
export interface ToggleAction extends BaseAction, WeightedMessage {
|
||||
type: 'toggle'
|
||||
|
||||
/**
|
||||
* The label of the toggle, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* Description text that appears below the toggle.
|
||||
*/
|
||||
description?: string
|
||||
|
||||
/**
|
||||
* Whether the toggle is checked by default.
|
||||
*/
|
||||
defaultChecked?: boolean
|
||||
|
||||
/**
|
||||
* Alternative messages based on other selected actions.
|
||||
*/
|
||||
conditionalMessages?: ConditionalMessage[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a button that has different behavior based on other selected actions.
|
||||
*/
|
||||
export interface ConditionalButtonAction extends BaseAction {
|
||||
type: 'conditional-button'
|
||||
|
||||
/**
|
||||
* The label of the button, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* Different message configurations based on conditions.
|
||||
*/
|
||||
messageVariants: ConditionalMessage[]
|
||||
|
||||
/**
|
||||
* Global fallback message if no variants match their conditions.
|
||||
*/
|
||||
fallbackMessage?: () => Promise<string>
|
||||
|
||||
/**
|
||||
* The weight of the action's fallback message, used to determine the place where the message is placed in the final moderation message.
|
||||
*/
|
||||
fallbackWeight?: number
|
||||
}
|
||||
|
||||
export interface DropdownActionOption extends WeightedMessage {
|
||||
/**
|
||||
* The label of the option, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this option should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the option is always shown.
|
||||
*/
|
||||
shouldShow?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3: Labrinth.Projects.v3.Project,
|
||||
context?: ChecklistActionContext,
|
||||
) => boolean
|
||||
}
|
||||
|
||||
export interface DropdownAction extends BaseAction {
|
||||
type: 'dropdown'
|
||||
|
||||
/**
|
||||
* The label associated with the dropdown.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* The options available in the dropdown.
|
||||
*/
|
||||
options: DropdownActionOption[]
|
||||
|
||||
/**
|
||||
* The default option selected in the dropdown, by index.
|
||||
*/
|
||||
defaultOption?: number
|
||||
}
|
||||
|
||||
export interface MultiSelectChipsOption extends WeightedMessage {
|
||||
/**
|
||||
* Stable identifier for the option. If omitted, the label is used.
|
||||
*/
|
||||
id?: string
|
||||
|
||||
/**
|
||||
* The label of the chip, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this option should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the option is always shown.
|
||||
*/
|
||||
shouldShow?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3: Labrinth.Projects.v3.Project,
|
||||
context?: ChecklistActionContext,
|
||||
) => boolean
|
||||
}
|
||||
|
||||
export type MultiSelectChipsOptionsResolver = (
|
||||
context: ChecklistActionContext,
|
||||
) => MultiSelectChipsOption[]
|
||||
|
||||
export interface MultiSelectChipsAction extends BaseAction {
|
||||
type: 'multi-select-chips'
|
||||
|
||||
/**
|
||||
* The label associated with the multi-select chips.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* The options available in the multi-select chips.
|
||||
*/
|
||||
options: MultiSelectChipsOption[] | MultiSelectChipsOptionsResolver
|
||||
|
||||
/**
|
||||
* If set, all selected option messages are joined with this string and emitted as a single
|
||||
* message part rather than individual parts. Useful for building bullet lists under a shared header.
|
||||
*/
|
||||
joinWith?: string
|
||||
}
|
||||
|
||||
export interface AdditionalTextInput {
|
||||
/**
|
||||
* The label of the text input, which is displayed to the moderator.
|
||||
*/
|
||||
label: string
|
||||
|
||||
/**
|
||||
* The placeholder text for the text input.
|
||||
*/
|
||||
placeholder?: string
|
||||
|
||||
/**
|
||||
* Whether the text input is required to be filled out before the action can be completed.
|
||||
*/
|
||||
required?: boolean
|
||||
|
||||
/**
|
||||
* Whether the text input should use the full markdown editor rather than a simple text input.
|
||||
*/
|
||||
large?: boolean
|
||||
|
||||
/**
|
||||
* The variable name that will be replaced in the message with the input value.
|
||||
* For example, if variable is "MESSAGE", then "%MESSAGE%" in the action message
|
||||
* will be replaced with the input value.
|
||||
*/
|
||||
variable?: string
|
||||
|
||||
/**
|
||||
* Conditions that determine when this input is shown.
|
||||
*/
|
||||
showWhen?: {
|
||||
/**
|
||||
* Action IDs that must be selected for this input to be shown.
|
||||
*/
|
||||
requiredActions?: string[]
|
||||
|
||||
/**
|
||||
* Action IDs that must NOT be selected for this input to be shown.
|
||||
*/
|
||||
excludedActions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional suggestions for the input. Useful for repeating phrases or common responses.
|
||||
*/
|
||||
suggestions?: string[]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export interface WeightedMessage {
|
||||
/**
|
||||
* The weight of the action's active message, used to determine the place where the message is placed in the final moderation message.
|
||||
*/
|
||||
weight: number
|
||||
|
||||
/**
|
||||
* The message which is appended to the final moderation message if the button is active.
|
||||
* @returns A function that lazily loads the message which is appended if the button is active.
|
||||
* @example async () => (await import('../messages/example.md?raw')).default,
|
||||
*/
|
||||
message: () => Promise<string>
|
||||
}
|
||||
@@ -1,880 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { FunctionalComponent, InjectionKey, Ref, SVGAttributes } from 'vue'
|
||||
import { markRaw, toValue } from 'vue'
|
||||
|
||||
import {
|
||||
expandVariables,
|
||||
flattenProjectV3Variables,
|
||||
flattenProjectVariables,
|
||||
flattenStaticVariables,
|
||||
} from '../utils'
|
||||
import type { ModerationSeverity, ModerationStatus } from './actions'
|
||||
import type { Priority } from './priority.ts'
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NodeState =
|
||||
| boolean
|
||||
| string
|
||||
| number
|
||||
| Set<string>
|
||||
| NodeStateWithChildren
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export interface NodeStateWithChildren {
|
||||
value?: NodeState
|
||||
[childId: string]: NodeState
|
||||
}
|
||||
|
||||
// ─── Function types ───────────────────────────────────────────────────────────
|
||||
|
||||
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
|
||||
concat(...others: MessageFn[]): MessageFn
|
||||
}
|
||||
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
|
||||
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
|
||||
|
||||
export type Reactive<T> = T | Ref<T>
|
||||
|
||||
export function resolve<T>(value: Reactive<T>): T {
|
||||
return toValue(value as T | Ref<T>)
|
||||
}
|
||||
|
||||
export type ChildNode = NodeBuilder | (() => unknown) | string
|
||||
|
||||
export type ChildEntry =
|
||||
| NodeBuilder
|
||||
| string
|
||||
| (() => unknown)
|
||||
| null
|
||||
| Ref<NodeBuilder | null>
|
||||
| ((state?: Record<string, NodeState>) => NodeBuilder | NodeBuilder[] | null)
|
||||
|
||||
export type NodeType =
|
||||
| 'toggle'
|
||||
| 'check'
|
||||
| 'button'
|
||||
| 'text'
|
||||
| 'markdown'
|
||||
| 'group'
|
||||
| 'dropdown'
|
||||
| 'option'
|
||||
| 'stage'
|
||||
|
||||
// ─── Message helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const messageFiles = import.meta.glob('../data/messages/**/*.md', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
})
|
||||
|
||||
export function mdOptional(
|
||||
path: string,
|
||||
getVars?: (state: Record<string, any>) => Record<string, any>,
|
||||
): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const loader = messageFiles[`../data/messages/${path}.md`]
|
||||
if (!loader) return ''
|
||||
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
|
||||
export function mdEscape(text: string): string {
|
||||
return text.replace(/[\\*_`[~]/g, '\\$&')
|
||||
}
|
||||
|
||||
const USER_CONTENT_KEYS = [
|
||||
'PROJECT_TITLE',
|
||||
'PROJECT_SLUG',
|
||||
'PROJECT_SUMMARY',
|
||||
'PROJECT_TYPE',
|
||||
'PROJECT_STATUS',
|
||||
]
|
||||
|
||||
export async function loadMd(
|
||||
path: string,
|
||||
state: Record<string, NodeState>,
|
||||
project: Labrinth.Projects.v3.Project,
|
||||
projectV2: Labrinth.Projects.v2.Project,
|
||||
getVars?: (state: Record<string, any>) => Record<string, any>,
|
||||
): Promise<string> {
|
||||
// Call getVars before any await so Vue's watchEffect tracks reactive reads inside it
|
||||
const extraVars = getVars ? getVars(state) : null
|
||||
const loader = messageFiles[`../data/messages/${path}.md`]
|
||||
if (!loader) {
|
||||
_onMissingMd?.(path)
|
||||
return ''
|
||||
}
|
||||
const raw = (await loader()) as string
|
||||
const vars: Record<string, string> = {
|
||||
...flattenStaticVariables(),
|
||||
...flattenProjectVariables(projectV2),
|
||||
...flattenProjectV3Variables(project),
|
||||
}
|
||||
for (const key of USER_CONTENT_KEYS) {
|
||||
if (key in vars) vars[key] = mdEscape(vars[key])
|
||||
}
|
||||
if (extraVars) {
|
||||
for (const [k, v] of Object.entries(extraVars)) {
|
||||
vars[k] = String(v ?? '')
|
||||
}
|
||||
}
|
||||
const expanded = expandVariables(raw, projectV2, project, vars)
|
||||
// Code spans render literally — markdown escapes inside them show as-is, so strip them
|
||||
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
|
||||
}
|
||||
|
||||
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
|
||||
const rich = fn as MessageFn
|
||||
rich.concat = (...others) =>
|
||||
makeMessageFn(async (state) =>
|
||||
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
|
||||
)
|
||||
return rich
|
||||
}
|
||||
|
||||
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
|
||||
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
|
||||
let _onMissingMd: ((path: string) => void) | null = null
|
||||
|
||||
export function setMissingMdHandler(handler: (path: string) => void) {
|
||||
_onMissingMd = handler
|
||||
}
|
||||
|
||||
export function setMessageProject(
|
||||
project: Ref<Labrinth.Projects.v3.Project>,
|
||||
projectV2: Ref<Labrinth.Projects.v2.Project>,
|
||||
) {
|
||||
_project = project
|
||||
_projectV2 = projectV2
|
||||
}
|
||||
|
||||
export function md(
|
||||
path: string | ((state: Record<string, NodeState>) => string),
|
||||
getVars?: (state: Record<string, any>) => Record<string, any>,
|
||||
): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(state) : path
|
||||
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
// ─── Fix builder ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function createTrackedPatch<T extends object>(
|
||||
source: T,
|
||||
): { proxy: T; changes: () => Partial<T> } {
|
||||
const written = new Set<string | symbol>()
|
||||
const data = { ...source }
|
||||
const proxy = new Proxy(data, {
|
||||
set(target, key, value) {
|
||||
if (value !== (source as Record<string | symbol, unknown>)[key]) {
|
||||
written.add(key)
|
||||
} else {
|
||||
written.delete(key)
|
||||
}
|
||||
;(target as Record<string | symbol, unknown>)[key] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
return {
|
||||
proxy,
|
||||
changes: () =>
|
||||
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
|
||||
}
|
||||
}
|
||||
|
||||
export class FixBuilder {
|
||||
_projectFn?: (
|
||||
patch: Labrinth.Projects.v3.EditProjectRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
_versionFn?: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
|
||||
project(
|
||||
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
|
||||
): this {
|
||||
this._projectFn = fn
|
||||
return this
|
||||
}
|
||||
|
||||
version(
|
||||
fn: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void,
|
||||
): this {
|
||||
this._versionFn = fn
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export function fix(): FixBuilder {
|
||||
return new FixBuilder()
|
||||
}
|
||||
|
||||
// ─── Message segments ─────────────────────────────────────────────────────────
|
||||
|
||||
type GetVarsFn = (state: Record<string, any>) => Record<string, any>
|
||||
|
||||
export type MessageSegment =
|
||||
| { type: 'fn'; fn: ContentFn }
|
||||
| { type: 'auto'; getVars?: GetVarsFn }
|
||||
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
|
||||
| { type: 'collect'; fallback?: MessageSegment }
|
||||
|
||||
export function resolveRelativeMessagePath(
|
||||
messagePath: string | (() => string),
|
||||
statePath: string[],
|
||||
): string {
|
||||
const name = typeof messagePath === 'function' ? messagePath() : messagePath
|
||||
if (name.startsWith('/')) return `checklist/messages${name}`
|
||||
const parts = [...statePath.slice(0, -1), ...name.split('/')]
|
||||
const normalized = parts.reduce<string[]>((acc, p) => {
|
||||
if (p === '..') acc.pop()
|
||||
else if (p) acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
return `checklist/messages/${normalized.join('/')}`
|
||||
}
|
||||
|
||||
export async function evalSegment(
|
||||
seg: MessageSegment,
|
||||
state: Record<string, NodeState>,
|
||||
statePath: string[],
|
||||
): Promise<string> {
|
||||
if (seg.type === 'collect') return ''
|
||||
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
|
||||
if (seg.type === 'auto')
|
||||
return loadMd(
|
||||
`checklist/messages/${statePath.join('/')}`,
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
return loadMd(
|
||||
resolveRelativeMessagePath(seg.path, statePath),
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Node builders ────────────────────────────────────────────────────────────
|
||||
|
||||
export abstract class NodeBuilder {
|
||||
abstract readonly type: NodeType
|
||||
_shown?: Reactive<boolean>
|
||||
_title?: Reactive<string>
|
||||
_icon?: FunctionalComponent<SVGAttributes>
|
||||
_tooltip?: Reactive<string> | ((state: Record<string, NodeState>) => string)
|
||||
|
||||
shown(condition: Reactive<boolean>): this {
|
||||
this._shown = condition
|
||||
return this
|
||||
}
|
||||
|
||||
title(t: Reactive<string>): this {
|
||||
this._title = t
|
||||
return this
|
||||
}
|
||||
|
||||
icon(i: FunctionalComponent<SVGAttributes>): this {
|
||||
this._icon = markRaw(i)
|
||||
return this
|
||||
}
|
||||
|
||||
tooltip(t: Reactive<string> | ((state: Record<string, NodeState>) => string)): this {
|
||||
this._tooltip = t
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class ButtonNodeBuilder extends NodeBuilder {
|
||||
readonly type = 'button' as const
|
||||
readonly label?: string
|
||||
_onClick?: (state: Record<string, NodeState>) => void
|
||||
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
|
||||
|
||||
constructor(nodeLabel?: string) {
|
||||
super()
|
||||
this.label = nodeLabel
|
||||
}
|
||||
|
||||
onClick(fn: (state: Record<string, NodeState>) => void): this {
|
||||
this._onClick = fn
|
||||
return this
|
||||
}
|
||||
|
||||
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
|
||||
this._enabled = condition
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class IdentifiedNodeBuilder extends NodeBuilder {
|
||||
readonly id: string | undefined
|
||||
_children: ChildEntry[] = []
|
||||
_childrenFn?: ChildrenFn
|
||||
_computingChildren = false
|
||||
_segments: MessageSegment[] = []
|
||||
_suggestedStatus?: ModerationStatus
|
||||
_severity?: ModerationSeverity
|
||||
_priority?: Priority
|
||||
_fixes: FixBuilder[] = []
|
||||
_applyFixes = false
|
||||
_enabled?: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)
|
||||
_statePath?: string[]
|
||||
|
||||
constructor(id: string | undefined) {
|
||||
super()
|
||||
this.id = id
|
||||
}
|
||||
|
||||
children(fn: ChildrenFn): this
|
||||
children(...entries: ChildEntry[]): this
|
||||
children(...args: [ChildrenFn] | ChildEntry[]): this {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
this._childrenFn = args[0] as ChildrenFn
|
||||
} else {
|
||||
this._children.push(...(args as ChildEntry[]))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
message(path?: string | (() => string), getVars?: GetVarsFn): this {
|
||||
const segment: MessageSegment =
|
||||
path === undefined
|
||||
? { type: 'auto', ...(getVars && { getVars }) }
|
||||
: { type: 'path', path, ...(getVars && { getVars }) }
|
||||
this._segments.push(segment)
|
||||
return this
|
||||
}
|
||||
|
||||
rawMessage(content: string | ContentFn): this {
|
||||
const fn: ContentFn = typeof content === 'string' ? () => content : content
|
||||
this._segments.push({ type: 'fn', fn })
|
||||
return this
|
||||
}
|
||||
|
||||
collect(path?: string | (() => string), getVars?: GetVarsFn): this {
|
||||
const fallback =
|
||||
path !== undefined || getVars !== undefined
|
||||
? path === undefined
|
||||
? { type: 'auto' as const, ...(getVars && { getVars }) }
|
||||
: { type: 'path' as const, path, ...(getVars && { getVars }) }
|
||||
: undefined
|
||||
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
|
||||
return this
|
||||
}
|
||||
|
||||
suggestedStatus(s: ModerationStatus): this {
|
||||
this._suggestedStatus = s
|
||||
return this
|
||||
}
|
||||
|
||||
severity(s: ModerationSeverity): this {
|
||||
this._severity = s
|
||||
return this
|
||||
}
|
||||
|
||||
priority(p: Priority): this {
|
||||
this._priority = p
|
||||
return this
|
||||
}
|
||||
|
||||
fix(f: FixBuilder): this {
|
||||
this._fixes.push(f)
|
||||
return this
|
||||
}
|
||||
|
||||
applyFixes(): this {
|
||||
this._applyFixes = true
|
||||
return this
|
||||
}
|
||||
|
||||
enabled(condition: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean)): this {
|
||||
this._enabled = condition
|
||||
return this
|
||||
}
|
||||
|
||||
statePath(path: string[]): this {
|
||||
this._statePath = path
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class LabeledNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly label: string
|
||||
|
||||
constructor(id: string, label: string) {
|
||||
super(id)
|
||||
this.label = label
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class ValueNodeBuilder extends LabeledNodeBuilder {
|
||||
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
|
||||
_required?: boolean
|
||||
|
||||
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
|
||||
this._defaultValue = v
|
||||
return this
|
||||
}
|
||||
|
||||
required(v = true): this {
|
||||
this._required = v
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class BooleanNodeBuilder extends ValueNodeBuilder {
|
||||
readonly type: 'toggle' | 'check'
|
||||
|
||||
constructor(id: string, nodeLabel: string, type: 'toggle' | 'check') {
|
||||
super(id, nodeLabel)
|
||||
this.type = type
|
||||
}
|
||||
}
|
||||
|
||||
export type OverrideValue = { readonly __override: string }
|
||||
|
||||
export type OnChangeFn = (
|
||||
value: string,
|
||||
helpers: { override: (value: string) => OverrideValue },
|
||||
) => OverrideValue | void
|
||||
|
||||
export class InputNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly type: 'text' | 'markdown'
|
||||
_placeholder?: Reactive<string>
|
||||
_defaultValue?: NodeState | ((state: Record<string, NodeState>) => NodeState)
|
||||
_required?: boolean
|
||||
_onChange?: OnChangeFn
|
||||
|
||||
constructor(id: string, type: 'text' | 'markdown') {
|
||||
super(id)
|
||||
this.type = type
|
||||
}
|
||||
|
||||
placeholder(p: Reactive<string>): this {
|
||||
this._placeholder = p
|
||||
return this
|
||||
}
|
||||
|
||||
initial(v: NodeState | ((state: Record<string, NodeState>) => NodeState)): this {
|
||||
this._defaultValue = v
|
||||
return this
|
||||
}
|
||||
|
||||
required(v = true): this {
|
||||
this._required = v
|
||||
return this
|
||||
}
|
||||
|
||||
onChange(fn: OnChangeFn): this {
|
||||
this._onChange = fn
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class GroupNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly type = 'group' as const
|
||||
_layout?: 'flex' | 'column'
|
||||
_required?: boolean
|
||||
_selectMode?: 'single' | 'multi'
|
||||
_selectId?: string
|
||||
|
||||
layout(l: 'flex' | 'column'): this {
|
||||
this._layout = l
|
||||
return this
|
||||
}
|
||||
|
||||
required(v = true): this {
|
||||
this._required = v
|
||||
return this
|
||||
}
|
||||
|
||||
singleSelect(id?: string): this {
|
||||
this._selectMode = 'single'
|
||||
if (id !== undefined) this._selectId = id
|
||||
return this
|
||||
}
|
||||
|
||||
multiSelect(id?: string): this {
|
||||
this._selectMode = 'multi'
|
||||
if (id !== undefined) this._selectId = id
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class DropdownNodeBuilder extends IdentifiedNodeBuilder {
|
||||
readonly type = 'dropdown' as const
|
||||
_none?: string
|
||||
|
||||
override children(fn: ChildrenFn): this
|
||||
override children(...nodes: OptionNodeBuilder[]): this
|
||||
override children(...args: [ChildrenFn] | OptionNodeBuilder[]): this {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
super.children(args[0] as ChildrenFn)
|
||||
} else {
|
||||
super.children(...(args as OptionNodeBuilder[]))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
none(text: string): this {
|
||||
this._none = text
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class OptionNodeBuilder extends LabeledNodeBuilder {
|
||||
readonly type = 'option' as const
|
||||
}
|
||||
|
||||
export class StageNodeBuilder extends LabeledNodeBuilder {
|
||||
readonly type = 'stage' as const
|
||||
_hint?: string
|
||||
_guidanceUrl?: string
|
||||
_icon?: FunctionalComponent<SVGAttributes>
|
||||
_navigate?: string
|
||||
|
||||
hint(h: string): this {
|
||||
this._hint = h
|
||||
return this
|
||||
}
|
||||
|
||||
guidance(url: string): this {
|
||||
this._guidanceUrl = url
|
||||
return this
|
||||
}
|
||||
|
||||
icon(i: FunctionalComponent<SVGAttributes>): this {
|
||||
this._icon = markRaw(i)
|
||||
return this
|
||||
}
|
||||
|
||||
navigate(path: string): this {
|
||||
this._navigate = path
|
||||
return this
|
||||
}
|
||||
|
||||
override children(fn: ChildrenFn): this
|
||||
override children(...entries: ChildEntry[]): this
|
||||
override children(...args: [ChildrenFn] | ChildEntry[]): this {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
super.children(args[0] as ChildrenFn)
|
||||
if (!this._statePath) this._statePath = [this.id!]
|
||||
return this
|
||||
}
|
||||
const entries = args as ChildEntry[]
|
||||
super.children(...entries)
|
||||
if (!this._statePath) this._statePath = [this.id!]
|
||||
stampChildPaths(entries, [this.id!])
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export const STAGES_KEY: InjectionKey<Ref<StageNodeBuilder[]>> = Symbol('checklistStages')
|
||||
export const GLOBAL_STATE_KEY: InjectionKey<Ref<Record<string, Record<string, NodeState>>>> =
|
||||
Symbol('checklistGlobalState')
|
||||
|
||||
// ─── Node traversal ───────────────────────────────────────────────────────────
|
||||
|
||||
function childrenScopePath(node: IdentifiedNodeBuilder): string[] | null {
|
||||
if (!node._statePath) return null
|
||||
switch (node.type) {
|
||||
case 'toggle':
|
||||
case 'check':
|
||||
case 'option':
|
||||
case 'stage':
|
||||
return node._statePath
|
||||
case 'group': {
|
||||
const g = node as GroupNodeBuilder
|
||||
if (g._selectMode) {
|
||||
// Option ids become namespaces at the same level as the group
|
||||
return node._statePath.slice(0, -1)
|
||||
}
|
||||
return node._statePath
|
||||
}
|
||||
case 'dropdown':
|
||||
// Option ids become namespaces at the same level as the dropdown
|
||||
return node._statePath.slice(0, -1)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stampChildPaths(entries: ChildEntry[], scopePath: string[]): void {
|
||||
for (const entry of entries) {
|
||||
// Skip reactive entries (refs, functions, nulls) — they get stamped on resolution
|
||||
if (!(entry instanceof NodeBuilder)) continue
|
||||
if (!(entry instanceof IdentifiedNodeBuilder)) continue
|
||||
const selectId = entry instanceof GroupNodeBuilder ? entry._selectId : undefined
|
||||
if (!entry.id && !selectId) {
|
||||
// Truly transparent: no structural id and no select id
|
||||
stampChildPaths(entry._children, scopePath)
|
||||
continue
|
||||
}
|
||||
if (!entry._statePath) {
|
||||
// Structural id contributes to path first, then select id
|
||||
const pathComponents = entry.id
|
||||
? selectId
|
||||
? [...scopePath, entry.id, selectId]
|
||||
: [...scopePath, entry.id]
|
||||
: [...scopePath, selectId!]
|
||||
entry._statePath = pathComponents
|
||||
}
|
||||
const childScope = childrenScopePath(entry)
|
||||
if (childScope) stampChildPaths(entry._children, childScope)
|
||||
}
|
||||
}
|
||||
|
||||
export function isNodeActive(node: NodeBuilder, state: NodeState): boolean {
|
||||
switch (node.type) {
|
||||
case 'toggle':
|
||||
case 'check':
|
||||
case 'option': {
|
||||
if (typeof state === 'boolean') return state
|
||||
if (state && typeof state === 'object' && !(state instanceof Set)) {
|
||||
const v = (state as NodeStateWithChildren).value
|
||||
if (typeof v === 'boolean') return v
|
||||
}
|
||||
return (node as BooleanNodeBuilder)._defaultValue === true
|
||||
}
|
||||
case 'group': {
|
||||
const g = node as GroupNodeBuilder
|
||||
if (g._selectMode === 'single') return typeof state === 'string' && state !== ''
|
||||
if (g._selectMode === 'multi') return state instanceof Set && state.size > 0
|
||||
return false
|
||||
}
|
||||
case 'dropdown':
|
||||
return typeof state === 'string' && state !== ''
|
||||
case 'text':
|
||||
case 'markdown':
|
||||
return typeof state === 'string' && state !== ''
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
|
||||
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
|
||||
return nodeState as Record<string, NodeState>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function resolveChildren(
|
||||
node: IdentifiedNodeBuilder,
|
||||
state: Record<string, NodeState>,
|
||||
): ChildNode[] {
|
||||
let entries: ChildEntry[]
|
||||
let stampStatic = false
|
||||
|
||||
if (node._childrenFn) {
|
||||
if (node._computingChildren) return []
|
||||
node._computingChildren = true
|
||||
try {
|
||||
entries = node._childrenFn(state)
|
||||
stampStatic = true // childrenFn results haven't been pre-stamped
|
||||
} finally {
|
||||
node._computingChildren = false
|
||||
}
|
||||
} else {
|
||||
entries = node._children
|
||||
}
|
||||
|
||||
const scopePath = childrenScopePath(node)
|
||||
const result: ChildNode[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry == null) continue
|
||||
if (entry instanceof NodeBuilder) {
|
||||
if (entry._shown !== undefined && !resolve(entry._shown)) continue
|
||||
if (stampStatic && scopePath) stampChildPaths([entry], scopePath)
|
||||
result.push(entry)
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'string') {
|
||||
result.push(entry)
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'function') {
|
||||
if (entry.length === 0) {
|
||||
result.push(entry as () => unknown)
|
||||
continue
|
||||
}
|
||||
const resolved = entry(state)
|
||||
if (resolved == null) continue
|
||||
if (Array.isArray(resolved)) {
|
||||
for (const r of resolved) {
|
||||
if (r == null) continue
|
||||
if (r._shown !== undefined && !resolve(r._shown)) continue
|
||||
if (scopePath) stampChildPaths([r], scopePath)
|
||||
result.push(r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
|
||||
if (scopePath) stampChildPaths([resolved], scopePath)
|
||||
result.push(resolved)
|
||||
continue
|
||||
}
|
||||
// Ref<NodeBuilder | null>
|
||||
const resolved = toValue(entry as Ref<NodeBuilder | null>)
|
||||
if (resolved == null) continue
|
||||
if (resolved._shown !== undefined && !resolve(resolved._shown)) continue
|
||||
if (scopePath) stampChildPaths([resolved], scopePath)
|
||||
result.push(resolved)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function walkNodes(
|
||||
nodes: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
visitor: (
|
||||
node: IdentifiedNodeBuilder,
|
||||
nodeState: NodeState,
|
||||
localState: Record<string, NodeState>,
|
||||
) => void,
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof NodeBuilder)) continue
|
||||
if (node._shown !== undefined && !resolve(node._shown)) continue
|
||||
|
||||
if (node.type === 'stage') {
|
||||
const identified = node as IdentifiedNodeBuilder
|
||||
if (identified.id) {
|
||||
const raw = stageState[identified.id]
|
||||
const childState =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
walkNodes(resolveChildren(identified, childState), childState, visitor)
|
||||
} else {
|
||||
walkNodes(resolveChildren(identified, stageState), stageState, visitor)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.type === 'group') {
|
||||
const g = node as GroupNodeBuilder
|
||||
if (!g._selectMode) {
|
||||
// Plain container: traverse children in group's own state scope
|
||||
if (g.id) {
|
||||
const raw = stageState[g.id]
|
||||
const childState =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
walkNodes(resolveChildren(g, childState), childState, visitor)
|
||||
} else {
|
||||
walkNodes(resolveChildren(g, stageState), stageState, visitor)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Fall through to value-node path for groups with selectMode
|
||||
}
|
||||
|
||||
if (node.type === 'button') continue
|
||||
|
||||
const identified = node as IdentifiedNodeBuilder
|
||||
const stateKey =
|
||||
node.type === 'group'
|
||||
? ((node as GroupNodeBuilder)._selectId ?? identified.id!)
|
||||
: identified.id!
|
||||
const nodeState = stageState[stateKey]
|
||||
visitor(identified, nodeState, stageState)
|
||||
|
||||
const active = isNodeActive(node, nodeState)
|
||||
const children = resolveChildren(identified, stageState)
|
||||
if (children.length === 0 || !active) continue
|
||||
|
||||
if (node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'multi') {
|
||||
const selected = nodeState instanceof Set ? nodeState : new Set<string>()
|
||||
for (const child of children) {
|
||||
const childId = child as IdentifiedNodeBuilder
|
||||
if (!selected.has(childId.id!) || (child._shown !== undefined && !resolve(child._shown)))
|
||||
continue
|
||||
const rawChildState = stageState[childId.id!]
|
||||
// Option children are active by virtue of being in the selected Set, not by their own
|
||||
// boolean state. If they have child state but no explicit value, inject value: true
|
||||
// so isNodeActive returns true even after child state has been written to their path.
|
||||
const childState: NodeState =
|
||||
rawChildState !== null &&
|
||||
rawChildState !== undefined &&
|
||||
typeof rawChildState === 'object' &&
|
||||
!(rawChildState instanceof Set) &&
|
||||
(rawChildState as NodeStateWithChildren).value === undefined
|
||||
? { ...(rawChildState as NodeStateWithChildren), value: true }
|
||||
: (rawChildState ?? true)
|
||||
visitor(childId, childState, stageState)
|
||||
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
|
||||
}
|
||||
} else if (node.type === 'toggle' || node.type === 'check') {
|
||||
const childState = getBooleanChildState(nodeState)
|
||||
walkNodes(children, childState, visitor)
|
||||
} else if (
|
||||
(node.type === 'group' && (node as GroupNodeBuilder)._selectMode === 'single') ||
|
||||
node.type === 'dropdown'
|
||||
) {
|
||||
const selectedId = typeof nodeState === 'string' ? nodeState : undefined
|
||||
if (selectedId) {
|
||||
for (const child of children) {
|
||||
const childId = child as IdentifiedNodeBuilder
|
||||
if (childId.id !== selectedId || (child._shown !== undefined && !resolve(child._shown)))
|
||||
continue
|
||||
const rawChildState = stageState[childId.id!]
|
||||
const childState: NodeState =
|
||||
rawChildState !== null &&
|
||||
rawChildState !== undefined &&
|
||||
typeof rawChildState === 'object' &&
|
||||
!(rawChildState instanceof Set) &&
|
||||
(rawChildState as NodeStateWithChildren).value === undefined
|
||||
? { ...(rawChildState as NodeStateWithChildren), value: true }
|
||||
: (rawChildState ?? true)
|
||||
visitor(childId, childState, stageState)
|
||||
walkNodes(resolveChildren(childId, stageState), stageState, visitor)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
walkNodes(children, stageState, visitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Factory functions ────────────────────────────────────────────────────────
|
||||
|
||||
export type StageFn = (
|
||||
project: Labrinth.Projects.v3.Project,
|
||||
projectV2: Labrinth.Projects.v2.Project,
|
||||
) => StageNodeBuilder
|
||||
|
||||
export function stageFn(factory: StageFn): StageFn {
|
||||
const cache = new WeakMap<object, StageNodeBuilder>()
|
||||
return (project, projectV2) => {
|
||||
if (!cache.has(project)) cache.set(project, factory(project, projectV2))
|
||||
return cache.get(project)!
|
||||
}
|
||||
}
|
||||
|
||||
export const toggle = (id: string, nodeLabel: string) =>
|
||||
new BooleanNodeBuilder(id, nodeLabel, 'toggle')
|
||||
export const check = (id: string, nodeLabel: string) =>
|
||||
new BooleanNodeBuilder(id, nodeLabel, 'check')
|
||||
export const button = (nodeLabel?: string) => new ButtonNodeBuilder(nodeLabel)
|
||||
export const text = (id: string) => new InputNodeBuilder(id, 'text')
|
||||
export const markdown = (id: string) => new InputNodeBuilder(id, 'markdown')
|
||||
export const group = (id?: string) => new GroupNodeBuilder(id)
|
||||
export const dropdown = (id: string) => new DropdownNodeBuilder(id)
|
||||
export const option = (id: string, nodeLabel: string) => new OptionNodeBuilder(id, nodeLabel)
|
||||
export const stage = (id: string, title: string) => new StageNodeBuilder(id, title)
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import type { Configurable, Visible } from './capabilities'
|
||||
import type { NodeState } from './state'
|
||||
|
||||
export type AnyNode = Visible
|
||||
|
||||
export type ChildNode = AnyNode | (() => unknown) | string
|
||||
|
||||
export type ChildEntry =
|
||||
| AnyNode
|
||||
| string
|
||||
| (() => unknown)
|
||||
| null
|
||||
| Ref<AnyNode | null>
|
||||
| ((state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null)
|
||||
|
||||
export type ChildrenFn = (state: Record<string, NodeState>) => ChildEntry[]
|
||||
|
||||
export interface HasChildren {
|
||||
_children: ChildEntry[]
|
||||
_childrenFn?: ChildrenFn
|
||||
children(this: this, fn: ChildrenFn): this
|
||||
children(this: this, ...entries: ChildEntry[]): this
|
||||
}
|
||||
|
||||
export function withChildren<T extends object>(node: T): T & HasChildren {
|
||||
return Object.assign(node, {
|
||||
_children: [] as ChildEntry[],
|
||||
children(this: HasChildren, ...args: [ChildrenFn] | ChildEntry[]) {
|
||||
if (args.length === 1 && typeof args[0] === 'function' && args[0].length >= 1) {
|
||||
this._childrenFn = args[0] as ChildrenFn
|
||||
} else {
|
||||
this._children.push(...(args as ChildEntry[]))
|
||||
}
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function withAutoProps<T extends Configurable>(
|
||||
node: T,
|
||||
): T & { [key: string]: (value: unknown) => T } {
|
||||
return new Proxy(node, {
|
||||
get(target, prop, receiver) {
|
||||
if (typeof prop === 'string' && !prop.startsWith('_') && !(prop in target)) {
|
||||
return (value: unknown) => {
|
||||
const prev = target._extraProps
|
||||
target._extraProps = (ctx) => ({ ...prev?.(ctx), [prop]: value })
|
||||
return receiver
|
||||
}
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
}) as T & { [key: string]: (value: unknown) => T }
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import type { Component, FunctionalComponent, SVGAttributes } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import { Priority } from '../priority.ts'
|
||||
import type { FixBuilder } from './fix'
|
||||
import type { GetVarsFn, MessageSegment, ModerationStatus, NodeState, Reactive } from './state'
|
||||
|
||||
export interface Visible {
|
||||
_shown: Reactive<boolean> | undefined
|
||||
shown(this: this, condition: Reactive<boolean>): this
|
||||
}
|
||||
|
||||
export function withShown<T extends object>(node: T): T & Visible {
|
||||
return Object.assign(node, {
|
||||
_shown: undefined as Reactive<boolean> | undefined,
|
||||
shown(this: Visible, condition: Reactive<boolean>) {
|
||||
this._shown = condition
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Iconable {
|
||||
_icon: FunctionalComponent<SVGAttributes> | undefined
|
||||
icon(this: this, icon: FunctionalComponent<SVGAttributes> | undefined): this
|
||||
}
|
||||
|
||||
export function withIcon<T extends object>(node: T): T & Iconable {
|
||||
return Object.assign(node, {
|
||||
_icon: undefined as FunctionalComponent<SVGAttributes> | undefined,
|
||||
icon(this: Iconable, icon: FunctionalComponent<SVGAttributes> | undefined) {
|
||||
this._icon = icon ? markRaw(icon) : undefined
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Tooltippable {
|
||||
_tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined
|
||||
tooltip(
|
||||
this: this,
|
||||
tooltip: Reactive<string> | ((state: Record<string, NodeState>) => string) | undefined,
|
||||
): this
|
||||
}
|
||||
|
||||
export function withTooltip<T extends object>(node: T): T & Tooltippable {
|
||||
return Object.assign(node, {
|
||||
_tooltip: undefined as Tooltippable['_tooltip'],
|
||||
tooltip(this: Tooltippable, tooltip: Tooltippable['_tooltip']) {
|
||||
this._tooltip = tooltip
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Titled {
|
||||
_title: Reactive<string> | undefined
|
||||
title(this: this, title: Reactive<string>): this
|
||||
}
|
||||
|
||||
export function withTitle<T extends object>(node: T): T & Titled {
|
||||
return Object.assign(node, {
|
||||
_title: undefined as Reactive<string> | undefined,
|
||||
title(this: Titled, title: Reactive<string>) {
|
||||
this._title = title
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Identified {
|
||||
id: string
|
||||
_statePath?: string[]
|
||||
statePath(this: this, path: string[]): this
|
||||
}
|
||||
|
||||
export function withId<T extends object>(node: T, id: string): T & Identified {
|
||||
return Object.assign(node, {
|
||||
id,
|
||||
statePath(this: Identified, path: string[]) {
|
||||
this._statePath = path
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Prioritizable {
|
||||
_priority: Priority
|
||||
priority(this: this, p: Priority): this
|
||||
}
|
||||
|
||||
export function withPriority<T extends object>(node: T): T & Prioritizable {
|
||||
return Object.assign(node, {
|
||||
_priority: new Priority(),
|
||||
priority(this: Prioritizable, p: Priority) {
|
||||
this._priority = p
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Messageable {
|
||||
_segments: MessageSegment[]
|
||||
_suggestedStatus?: ModerationStatus
|
||||
suggestedStatus(this: this, status: ModerationStatus): this
|
||||
message(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
|
||||
rawMessage(
|
||||
this: this,
|
||||
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
|
||||
): this
|
||||
collect(this: this, path?: string | (() => string) | GetVarsFn, getVars?: GetVarsFn): this
|
||||
}
|
||||
|
||||
function splitPathAndVars(
|
||||
pathOrGetVars: string | (() => string) | GetVarsFn | undefined,
|
||||
getVars: GetVarsFn | undefined,
|
||||
): { path: string | (() => string) | undefined; getVars: GetVarsFn | undefined } {
|
||||
if (typeof pathOrGetVars === 'function' && pathOrGetVars.length >= 1) {
|
||||
return { path: undefined, getVars: pathOrGetVars as GetVarsFn }
|
||||
}
|
||||
return { path: pathOrGetVars as string | (() => string) | undefined, getVars }
|
||||
}
|
||||
|
||||
export function withMessaging<T extends object>(node: T): T & Messageable {
|
||||
return Object.assign(node, {
|
||||
_segments: [] as MessageSegment[],
|
||||
suggestedStatus(this: Messageable, status: ModerationStatus) {
|
||||
this._suggestedStatus = status
|
||||
return this
|
||||
},
|
||||
message(
|
||||
this: Messageable,
|
||||
pathOrGetVars?: string | (() => string) | GetVarsFn,
|
||||
getVarsArg?: GetVarsFn,
|
||||
) {
|
||||
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
|
||||
const segment: MessageSegment =
|
||||
path === undefined
|
||||
? { type: 'auto', ...(getVars && { getVars }) }
|
||||
: { type: 'path', path, ...(getVars && { getVars }) }
|
||||
this._segments.push(segment)
|
||||
return this
|
||||
},
|
||||
rawMessage(
|
||||
this: Messageable,
|
||||
content: string | ((state: Record<string, NodeState>) => string | Promise<string>),
|
||||
) {
|
||||
const fn = typeof content === 'string' ? () => content : content
|
||||
this._segments.push({ type: 'fn', fn })
|
||||
return this
|
||||
},
|
||||
collect(
|
||||
this: Messageable,
|
||||
pathOrGetVars?: string | (() => string) | GetVarsFn,
|
||||
getVarsArg?: GetVarsFn,
|
||||
) {
|
||||
const { path, getVars } = splitPathAndVars(pathOrGetVars, getVarsArg)
|
||||
const fallback: MessageSegment | undefined =
|
||||
path !== undefined || getVars !== undefined
|
||||
? path === undefined
|
||||
? { type: 'auto', ...(getVars && { getVars }) }
|
||||
: { type: 'path', path, ...(getVars && { getVars }) }
|
||||
: undefined
|
||||
this._segments.push({ type: 'collect', ...(fallback && { fallback }) })
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Requireable {
|
||||
_required: boolean | undefined
|
||||
required(this: this, v?: boolean): this
|
||||
}
|
||||
|
||||
export function withRequired<T extends object>(node: T): T & Requireable {
|
||||
return Object.assign(node, {
|
||||
_required: undefined as boolean | undefined,
|
||||
required(this: Requireable, v = true) {
|
||||
this._required = v
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Fixable {
|
||||
_fixes: FixBuilder[]
|
||||
_applyFixes?: boolean
|
||||
fix(this: this, f: FixBuilder): this
|
||||
applyFixes(this: this): this
|
||||
}
|
||||
|
||||
export function withFix<T extends object>(node: T): T & Fixable {
|
||||
return Object.assign(node, {
|
||||
_fixes: [] as FixBuilder[],
|
||||
fix(this: Fixable, f: FixBuilder) {
|
||||
this._fixes.push(f)
|
||||
return this
|
||||
},
|
||||
applyFixes(this: Fixable) {
|
||||
this._applyFixes = true
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Enableable {
|
||||
_enabled: Reactive<boolean> | ((state: Record<string, NodeState>) => boolean) | undefined
|
||||
enabled(this: this, condition: Enableable['_enabled']): this
|
||||
}
|
||||
|
||||
export function withEnabled<T extends object>(node: T): T & Enableable {
|
||||
return Object.assign(node, {
|
||||
_enabled: undefined as Enableable['_enabled'],
|
||||
enabled(this: Enableable, condition: Enableable['_enabled']) {
|
||||
this._enabled = condition
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface HasValue<V = unknown> {
|
||||
_defaultValue?: V | ((state: Record<string, NodeState>) => V)
|
||||
_getValue: (raw: NodeState) => V
|
||||
_setValue: (raw: NodeState, next: V, isDefault?: boolean) => NodeState
|
||||
_isActive: (value: V) => boolean
|
||||
initial(this: this, v: V | ((state: Record<string, NodeState>) => V)): this
|
||||
}
|
||||
|
||||
export function withValue<T extends object, V>(
|
||||
node: T,
|
||||
behavior: Pick<HasValue<V>, '_getValue' | '_setValue' | '_isActive'>,
|
||||
): T & HasValue<V> {
|
||||
return Object.assign(node, {
|
||||
...behavior,
|
||||
initial(this: HasValue<V>, v: V | ((state: Record<string, NodeState>) => V)) {
|
||||
this._defaultValue = v
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Clickable {
|
||||
_onClick: ((state: Record<string, NodeState>) => void) | undefined
|
||||
onClick(this: this, fn: Clickable['_onClick']): this
|
||||
}
|
||||
|
||||
export function withOnClick<T extends object>(node: T): T & Clickable {
|
||||
return Object.assign(node, {
|
||||
_onClick: undefined as Clickable['_onClick'],
|
||||
onClick(this: Clickable, fn: Clickable['_onClick']) {
|
||||
this._onClick = fn
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface ComponentNodePropsContext {
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
toggleSetValue?: (value: string) => void
|
||||
nodeFacts?: { needsAttention: boolean; fixActionable: boolean }
|
||||
tooltip?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Renderable {
|
||||
_component: Component | undefined
|
||||
_rendererKey: string | undefined
|
||||
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
||||
_modelProp: string
|
||||
}
|
||||
|
||||
export function withComponent<T extends object>(
|
||||
node: T,
|
||||
opts: {
|
||||
component?: Component
|
||||
rendererKey?: string
|
||||
modelProp?: string
|
||||
componentProps?: Renderable['_componentProps']
|
||||
},
|
||||
): T & Renderable {
|
||||
return Object.assign(node, {
|
||||
_component: opts.component,
|
||||
_rendererKey: opts.rendererKey,
|
||||
_componentProps: opts.componentProps,
|
||||
_modelProp: opts.modelProp ?? 'modelValue',
|
||||
})
|
||||
}
|
||||
|
||||
export interface Configurable {
|
||||
_extraProps: ((ctx: ComponentNodePropsContext) => Record<string, unknown>) | undefined
|
||||
}
|
||||
|
||||
export function withExtraProps<T extends object>(node: T): T & Configurable {
|
||||
return Object.assign(node, {
|
||||
_extraProps: undefined as Configurable['_extraProps'],
|
||||
})
|
||||
}
|
||||
|
||||
export interface HasNoneLabel {
|
||||
_none: string | undefined
|
||||
none(this: this, text: string): this
|
||||
}
|
||||
|
||||
export function withNoneLabel<T extends object>(node: T): T & HasNoneLabel {
|
||||
return Object.assign(node, {
|
||||
_none: undefined as string | undefined,
|
||||
none(this: HasNoneLabel, text: string) {
|
||||
this._none = text
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Layoutable {
|
||||
_layout: 'flex' | 'column' | undefined
|
||||
layout(this: this, value: 'flex' | 'column'): this
|
||||
}
|
||||
|
||||
export function withLayout<T extends object>(node: T): T & Layoutable {
|
||||
return Object.assign(node, {
|
||||
_layout: undefined as 'flex' | 'column' | undefined,
|
||||
layout(this: Layoutable, value: 'flex' | 'column') {
|
||||
this._layout = value
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface Selectable {
|
||||
_exclusive: boolean | undefined
|
||||
singleSelect(this: this): this
|
||||
}
|
||||
|
||||
export function withSelectable<T extends object>(node: T): T & Selectable {
|
||||
return Object.assign(node, {
|
||||
_exclusive: undefined as boolean | undefined,
|
||||
singleSelect(this: Selectable) {
|
||||
this._exclusive = true
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface StageMeta {
|
||||
_hint: string | undefined
|
||||
_guidanceUrl: string | undefined
|
||||
_navigate: string | undefined
|
||||
_shownSticky: boolean | undefined
|
||||
hint(this: this, hint: string): this
|
||||
guidance(this: this, url: string): this
|
||||
navigate(this: this, path?: string): this
|
||||
sticky(this: this): this
|
||||
}
|
||||
|
||||
export function withStageMeta<T extends object>(node: T): T & StageMeta {
|
||||
return Object.assign(node, {
|
||||
_hint: undefined as string | undefined,
|
||||
_guidanceUrl: undefined as string | undefined,
|
||||
_navigate: undefined as string | undefined,
|
||||
_shownSticky: undefined as boolean | undefined,
|
||||
hint(this: StageMeta, hint: string) {
|
||||
this._hint = hint
|
||||
return this
|
||||
},
|
||||
guidance(this: StageMeta, url: string) {
|
||||
this._guidanceUrl = url
|
||||
return this
|
||||
},
|
||||
navigate(this: StageMeta, path?: string) {
|
||||
this._navigate = path ?? ''
|
||||
return this
|
||||
},
|
||||
sticky(this: StageMeta) {
|
||||
this._shownSticky = true
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export type OverrideValue = { readonly __override: string }
|
||||
|
||||
export type OnChangeFn = (
|
||||
value: string,
|
||||
helpers: { override: (value: string) => OverrideValue },
|
||||
) => OverrideValue | void
|
||||
|
||||
export interface Editable {
|
||||
_placeholder: Reactive<string> | undefined
|
||||
_onChange: OnChangeFn | undefined
|
||||
_showTooltip: boolean | undefined
|
||||
_imperativeSync: boolean | undefined
|
||||
placeholder(this: this, p: Reactive<string>): this
|
||||
onChange(this: this, fn: OnChangeFn): this
|
||||
}
|
||||
|
||||
export function withEditable<T extends object>(node: T): T & Editable {
|
||||
return Object.assign(node, {
|
||||
_placeholder: undefined as Reactive<string> | undefined,
|
||||
_onChange: undefined as OnChangeFn | undefined,
|
||||
_showTooltip: undefined as boolean | undefined,
|
||||
_imperativeSync: undefined as boolean | undefined,
|
||||
placeholder(this: Editable, p: Reactive<string>) {
|
||||
this._placeholder = p
|
||||
return this
|
||||
},
|
||||
onChange(this: Editable, fn: OnChangeFn) {
|
||||
this._onChange = fn
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface TweakDef<V = unknown> {
|
||||
icon: FunctionalComponent<SVGAttributes>
|
||||
compute: (current: V, state: Record<string, NodeState>) => V | null | undefined
|
||||
}
|
||||
|
||||
export interface Tweakable<V = unknown> {
|
||||
_tweaks: TweakDef<V>[]
|
||||
tweak(this: this, icon: FunctionalComponent<SVGAttributes>, compute: TweakDef<V>['compute']): this
|
||||
}
|
||||
|
||||
export function withTweak<T extends HasValue<V>, V>(node: T): T & Tweakable<V> {
|
||||
return Object.assign(node, {
|
||||
_tweaks: [] as TweakDef<V>[],
|
||||
tweak(
|
||||
this: Tweakable<V>,
|
||||
icon: FunctionalComponent<SVGAttributes>,
|
||||
compute: TweakDef<V>['compute'],
|
||||
) {
|
||||
this._tweaks.push({ icon: markRaw(icon), compute })
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface StateOrigin {
|
||||
_stateOrigin: string[] | undefined
|
||||
stateOrigin(this: this, path: string[]): this
|
||||
}
|
||||
|
||||
export function withStateOrigin<T extends object>(node: T): T & StateOrigin {
|
||||
return Object.assign(node, {
|
||||
_stateOrigin: undefined as string[] | undefined,
|
||||
stateOrigin(this: StateOrigin, path: string[]) {
|
||||
this._stateOrigin = path
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ChildNode } from './builder'
|
||||
import { evalSegment } from './messages'
|
||||
import { hasCap, isNodeActive, resolveActionState, walkNodes } from './resolve'
|
||||
import type { MessageSegment, NodeState } from './state'
|
||||
|
||||
export interface ActiveAction {
|
||||
node: object
|
||||
state: Record<string, NodeState>
|
||||
statePath: string[]
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function collectActiveActions(
|
||||
children: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
basePath: string[] = [],
|
||||
): ActiveAction[] {
|
||||
const actions: ActiveAction[] = []
|
||||
walkNodes(
|
||||
children,
|
||||
stageState,
|
||||
(node, nodeState, localState, path) => {
|
||||
if (!hasCap(node, '_segments')) return
|
||||
const segments = node._segments as MessageSegment[]
|
||||
if (segments.length === 0) return
|
||||
if (!isNodeActive(node, nodeState, localState)) return
|
||||
actions.push({
|
||||
node,
|
||||
state: resolveActionState(node, nodeState, localState),
|
||||
statePath: path,
|
||||
active: true,
|
||||
})
|
||||
},
|
||||
basePath,
|
||||
)
|
||||
return actions
|
||||
}
|
||||
|
||||
export function collectMessageNodes(
|
||||
children: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
basePath: string[] = [],
|
||||
): ActiveAction[] {
|
||||
const actions: ActiveAction[] = []
|
||||
walkNodes(
|
||||
children,
|
||||
stageState,
|
||||
(node, nodeState, localState, path) => {
|
||||
if (!hasCap(node, '_segments')) return
|
||||
const segments = node._segments as MessageSegment[]
|
||||
if (segments.length === 0) return
|
||||
const active = isNodeActive(node, nodeState, localState)
|
||||
actions.push({
|
||||
node,
|
||||
state: resolveActionState(node, nodeState, localState),
|
||||
statePath: path,
|
||||
active,
|
||||
})
|
||||
},
|
||||
basePath,
|
||||
)
|
||||
return actions
|
||||
}
|
||||
|
||||
function isDescendant(childPath: string[], ancestorPath: string[]): boolean {
|
||||
return (
|
||||
childPath.length > ancestorPath.length && ancestorPath.every((key, i) => childPath[i] === key)
|
||||
)
|
||||
}
|
||||
|
||||
export async function evalActiveAction(
|
||||
entry: ActiveAction,
|
||||
allActions: ActiveAction[],
|
||||
consumed: Set<object>,
|
||||
): Promise<string> {
|
||||
let result = ''
|
||||
const segments = (entry.node as { _segments: MessageSegment[] })._segments
|
||||
for (const seg of segments) {
|
||||
if (seg.type === 'collect') {
|
||||
let collected = ''
|
||||
for (const child of allActions) {
|
||||
if (consumed.has(child.node)) continue
|
||||
if (!isDescendant(child.statePath, entry.statePath)) continue
|
||||
consumed.add(child.node)
|
||||
if (!child.active) continue
|
||||
collected += await evalActiveAction(child, allActions, consumed)
|
||||
}
|
||||
if (!collected.trim() && seg.fallback) {
|
||||
collected = await evalSegment(seg.fallback, entry.state, entry.statePath)
|
||||
}
|
||||
result += collected
|
||||
} else {
|
||||
result += await evalSegment(seg, entry.state, entry.statePath)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<Button
|
||||
v-tooltip="tooltip"
|
||||
:type="color === 'standard' ? 'base' : 'colored'"
|
||||
:color="color === 'standard' ? undefined : color"
|
||||
:disabled="disabled"
|
||||
:aria-label="icon ? label : undefined"
|
||||
@click="emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<component :is="icon" v-if="icon" />
|
||||
<template v-else>{{ label }}</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Button } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
label?: string
|
||||
icon?: Component
|
||||
disabled?: boolean
|
||||
needsAttention?: boolean
|
||||
fixActionable?: boolean
|
||||
tooltip?: Record<string, unknown>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
}>()
|
||||
|
||||
const color = computed(() => {
|
||||
if (!props.modelValue) return 'standard'
|
||||
if (props.needsAttention) return 'orange'
|
||||
return props.fixActionable ? 'blue' : 'brand'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,401 @@
|
||||
<script lang="ts" setup>
|
||||
import { Button, IconButton } from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, inject, watchEffect } from 'vue'
|
||||
|
||||
import type { AnyNode, ChildNode, HasChildren } from '../builder'
|
||||
import type {
|
||||
ComponentNodePropsContext,
|
||||
Enableable,
|
||||
HasValue,
|
||||
Identified,
|
||||
OnChangeFn,
|
||||
TweakDef,
|
||||
} from '../capabilities'
|
||||
import { CHECKLIST_META_KEY } from '../context'
|
||||
import type { Writer } from '../mutate'
|
||||
import { childWriter, originScope, writeNodeValue } from '../mutate'
|
||||
import {
|
||||
getBooleanChildState,
|
||||
getEffectiveValue,
|
||||
hasCap,
|
||||
hasChildrenCap,
|
||||
hasIdCap,
|
||||
hasOptionsCap,
|
||||
hasValueCap,
|
||||
isNodeActive,
|
||||
isShown,
|
||||
resolveChildren,
|
||||
withStateDefaults,
|
||||
} from '../resolve'
|
||||
import type { NodeState, Reactive } from '../state'
|
||||
import { resolve } from '../state'
|
||||
import ActionButton from './ActionButton.vue'
|
||||
|
||||
const metaCtx = inject(CHECKLIST_META_KEY)
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: ChildNode[]
|
||||
state: Record<string, NodeState>
|
||||
write: Writer
|
||||
onImageUpload?: (file: File) => Promise<string>
|
||||
flex?: boolean
|
||||
titleDepth?: number
|
||||
appComponents?: Record<string, Component>
|
||||
globalState?: Record<string, Record<string, NodeState>>
|
||||
}>()
|
||||
|
||||
type RenderableValueNode = AnyNode &
|
||||
HasValue &
|
||||
Identified &
|
||||
Partial<Enableable> & {
|
||||
_component: Component | undefined
|
||||
_rendererKey: string | undefined
|
||||
_modelProp: string
|
||||
_componentProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
||||
_extraProps?: (ctx: ComponentNodePropsContext) => Record<string, unknown>
|
||||
_tweaks?: TweakDef[]
|
||||
}
|
||||
|
||||
function resolveComponent(node: RenderableValueNode): Component | undefined {
|
||||
return (
|
||||
node._component ?? (node._rendererKey ? props.appComponents?.[node._rendererKey] : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function titleClass(depth: number): string {
|
||||
if (depth === 0) return 'text-lg font-extrabold text-contrast'
|
||||
if (depth === 1) return 'text-base font-semibold'
|
||||
if (depth === 2) return 'text-sm font-semibold'
|
||||
return ''
|
||||
}
|
||||
|
||||
function getTitle(node: object): string | undefined {
|
||||
if (!hasCap(node, '_title')) return undefined
|
||||
const title = node._title as Reactive<string> | undefined
|
||||
if (title === undefined) return undefined
|
||||
return resolve(title) || undefined
|
||||
}
|
||||
|
||||
function needsAttention(node: object): boolean {
|
||||
return metaCtx?.value.attentionMap.get(node) ?? false
|
||||
}
|
||||
|
||||
function isFixActionable(node: object): boolean {
|
||||
return metaCtx?.value.metaMap.get(node)?.isFixActionable ?? false
|
||||
}
|
||||
|
||||
const wrappedState = computed(() => withStateDefaults(props.state, props.nodes, props.write))
|
||||
|
||||
function isEnabled(node: Partial<Enableable>): boolean {
|
||||
if (node._enabled === undefined) return true
|
||||
if (typeof node._enabled === 'function') return node._enabled(wrappedState.value)
|
||||
return resolve(node._enabled)
|
||||
}
|
||||
|
||||
function toggleSetValue(node: RenderableValueNode, value: string): void {
|
||||
const current = getEffectiveValue(
|
||||
node,
|
||||
props.state[node.id],
|
||||
wrappedState.value,
|
||||
) as unknown as string[]
|
||||
const set = new Set(Array.isArray(current) ? current : [])
|
||||
if (set.has(value)) set.delete(value)
|
||||
else set.add(value)
|
||||
writeNodeValue(node, props.state, props.write, Array.from(set) as never, wrappedState.value)
|
||||
}
|
||||
|
||||
const DROPDOWN_TRIGGER_CHROME_PX = 16 * 2 + 10 + 20 + 2
|
||||
let measureEl: HTMLSpanElement | null = null
|
||||
function measureLabelWidth(label: string): number {
|
||||
if (typeof document === 'undefined') return 0
|
||||
if (!measureEl) {
|
||||
measureEl = document.createElement('span')
|
||||
measureEl.className = 'min-w-0 truncate text-primary font-semibold leading-tight'
|
||||
Object.assign(measureEl.style, {
|
||||
position: 'absolute',
|
||||
visibility: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
left: '-9999px',
|
||||
top: '0',
|
||||
})
|
||||
document.body.appendChild(measureEl)
|
||||
}
|
||||
measureEl.textContent = label
|
||||
return measureEl.getBoundingClientRect().width
|
||||
}
|
||||
|
||||
const dropdownMinWidthCache = new Map<string, string>()
|
||||
function getDropdownMinWidth(options: { label: string }[]): string {
|
||||
const key = options.map((o) => o.label).join(' ')
|
||||
const cached = dropdownMinWidthCache.get(key)
|
||||
if (cached) return cached
|
||||
const maxLabelWidth = Math.max(0, ...options.map((o) => measureLabelWidth(o.label)))
|
||||
const result = `${Math.ceil(maxLabelWidth) + DROPDOWN_TRIGGER_CHROME_PX}px`
|
||||
dropdownMinWidthCache.set(key, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function componentProps(node: RenderableValueNode): Record<string, unknown> {
|
||||
const ctx: ComponentNodePropsContext = {
|
||||
onImageUpload: props.onImageUpload,
|
||||
toggleSetValue: (value) => toggleSetValue(node, value),
|
||||
nodeFacts: { needsAttention: needsAttention(node), fixActionable: isFixActionable(node) },
|
||||
tooltip: resolveTooltip(node),
|
||||
}
|
||||
const dropdownStyle = hasOptionsCap(node)
|
||||
? {
|
||||
class: '!w-auto max-w-full',
|
||||
style: { minWidth: getDropdownMinWidth(node._options as unknown as { label: string }[]) },
|
||||
}
|
||||
: undefined
|
||||
return {
|
||||
disabled: !isEnabled(node),
|
||||
...dropdownStyle,
|
||||
...node._componentProps?.(ctx),
|
||||
...node._extraProps?.(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
function containerScope(node: HasChildren & Partial<Identified>): {
|
||||
state: Record<string, NodeState>
|
||||
write: Writer
|
||||
} {
|
||||
if (hasCap(node, '_stateOrigin') && node._stateOrigin && props.globalState) {
|
||||
return originScope(props.globalState, node._stateOrigin as string[])
|
||||
}
|
||||
if (!hasIdCap(node)) return { state: props.state, write: props.write }
|
||||
const raw = props.state[node.id]
|
||||
const state =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
return { state, write: childWriter(props.state, props.write, node.id) }
|
||||
}
|
||||
|
||||
function valueScope(node: HasValue & Identified): {
|
||||
state: Record<string, NodeState>
|
||||
write: Writer
|
||||
} {
|
||||
const state = getBooleanChildState(props.state[node.id])
|
||||
return { state, write: childWriter(props.state, props.write, node.id) }
|
||||
}
|
||||
|
||||
const TOOLTIP_BASE = {
|
||||
delay: { show: 500, hide: 0 },
|
||||
triggers: ['hover', 'focus'],
|
||||
placement: 'top',
|
||||
}
|
||||
|
||||
function resolveTooltip(node: object): Record<string, unknown> | undefined {
|
||||
if (hasCap(node, '_tooltip')) {
|
||||
const t = node._tooltip as
|
||||
| Reactive<string>
|
||||
| ((state: Record<string, NodeState>) => string)
|
||||
| undefined
|
||||
if (t !== undefined) {
|
||||
const content = typeof t === 'function' ? t(wrappedState.value) : resolve(t)
|
||||
if (content) return { ...TOOLTIP_BASE, content }
|
||||
}
|
||||
}
|
||||
const hasSegments = hasCap(node, '_segments')
|
||||
const html = hasSegments ? metaCtx?.value.tooltipHtml.get(node) : undefined
|
||||
return html ? { ...TOOLTIP_BASE, content: html, html: true } : undefined
|
||||
}
|
||||
|
||||
function clickButton(node: object): void {
|
||||
if (!hasCap(node, '_onClick')) return
|
||||
;(node._onClick as (state: Record<string, NodeState>) => void)?.(wrappedState.value)
|
||||
}
|
||||
|
||||
function tweakCurrent(node: RenderableValueNode): unknown {
|
||||
return getEffectiveValue(node, props.state[node.id], wrappedState.value)
|
||||
}
|
||||
|
||||
function tweakResult(tweak: TweakDef, node: RenderableValueNode): unknown {
|
||||
return tweak.compute(tweakCurrent(node), wrappedState.value)
|
||||
}
|
||||
|
||||
function tweakEnabled(tweak: TweakDef, node: RenderableValueNode): boolean {
|
||||
const result = tweakResult(tweak, node)
|
||||
return result !== null && result !== undefined && result !== tweakCurrent(node)
|
||||
}
|
||||
|
||||
function tweakTooltip(
|
||||
tweak: TweakDef,
|
||||
node: RenderableValueNode,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!tweakEnabled(tweak, node)) return undefined
|
||||
const content = tweakResult(tweak, node)
|
||||
return content ? { ...TOOLTIP_BASE, content: String(content) } : undefined
|
||||
}
|
||||
|
||||
function tweakLabel(tweak: TweakDef, node: RenderableValueNode): string {
|
||||
const result = tweakResult(tweak, node)
|
||||
return result !== null && result !== undefined ? String(result) : 'Apply suggested value'
|
||||
}
|
||||
|
||||
function applyTweak(tweak: TweakDef, node: RenderableValueNode): void {
|
||||
const result = tweakResult(tweak, node)
|
||||
if (result !== null && result !== undefined) {
|
||||
updateValue(node, result)
|
||||
}
|
||||
}
|
||||
|
||||
function nodeKey(item: ChildNode, idx: number): string {
|
||||
return typeof item === 'object' && item !== null && hasIdCap(item) ? item.id : `n-${idx}`
|
||||
}
|
||||
|
||||
function modelProp(item: object): string {
|
||||
return (item as RenderableValueNode)._modelProp
|
||||
}
|
||||
|
||||
function updateEvent(item: object): string {
|
||||
return `update:${modelProp(item)}`
|
||||
}
|
||||
|
||||
function updateValue(item: RenderableValueNode, v: unknown): void {
|
||||
const onChange = hasCap(item, '_onChange')
|
||||
? (item._onChange as OnChangeFn | undefined)
|
||||
: undefined
|
||||
if (onChange) {
|
||||
const result = onChange(v as string, { override: (ov) => ({ __override: ov }) })
|
||||
if (result && typeof result === 'object' && '__override' in result) {
|
||||
writeNodeValue(item, props.state, props.write, result.__override as never, wrappedState.value)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeNodeValue(item, props.state, props.write, v as never, wrappedState.value)
|
||||
}
|
||||
|
||||
const seenOnChangeValues = new Map<object, unknown>()
|
||||
|
||||
watchEffect(() => {
|
||||
for (const node of props.nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (!hasCap(node, '_onChange') || !(node as { _onChange: unknown })._onChange) continue
|
||||
if (!hasValueCap(node) || !hasIdCap(node)) continue
|
||||
if (!isShown(node as AnyNode)) continue
|
||||
const value = getEffectiveValue(node, props.state[node.id], wrappedState.value)
|
||||
if (seenOnChangeValues.has(node) && seenOnChangeValues.get(node) === value) continue
|
||||
seenOnChangeValues.set(node, value)
|
||||
const onChange = (node as RenderableValueNode)._onChange as OnChangeFn | undefined
|
||||
onChange?.(value as never, { override: (ov) => ({ __override: ov }) })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[flex ? 'flex flex-wrap gap-2' : 'space-y-4', 'w-full']">
|
||||
<template v-for="(item, idx) in nodes" :key="nodeKey(item, idx)">
|
||||
<template v-if="typeof item !== 'object' || item === null">
|
||||
<template v-if="typeof item === 'string'">{{ item }}</template>
|
||||
<component :is="item" v-else />
|
||||
</template>
|
||||
|
||||
<template v-else-if="isShown(item)">
|
||||
<div
|
||||
:class="
|
||||
hasChildrenCap(item) && !hasValueCap(item)
|
||||
? 'w-full'
|
||||
: !getTitle(item)
|
||||
? 'contents'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<div v-if="getTitle(item)" class="mb-2" :class="titleClass(titleDepth ?? 0)">
|
||||
<!-- eslint-disable vue/no-v-html -- title text is author-controlled (stage definitions), not user input -->
|
||||
<span
|
||||
v-html="renderString(getTitle(item)!).replace(/^<p>([\s\S]*)<\/p>\n?$/, '$1')"
|
||||
/><span v-if="needsAttention(item)" class="text-red">*</span>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</div>
|
||||
|
||||
<template v-if="hasChildrenCap(item) && !hasValueCap(item)">
|
||||
<NodeRenderer
|
||||
:nodes="resolveChildren(item, containerScope(item).state)"
|
||||
:state="containerScope(item).state"
|
||||
:write="containerScope(item).write"
|
||||
:on-image-upload="onImageUpload"
|
||||
:app-components="appComponents"
|
||||
:global-state="globalState"
|
||||
:flex="(item as any)._layout !== 'column'"
|
||||
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasValueCap(item) && hasIdCap(item)">
|
||||
<component
|
||||
:is="resolveComponent(item as RenderableValueNode)"
|
||||
v-if="resolveComponent(item as RenderableValueNode) === ActionButton"
|
||||
v-bind="componentProps(item as RenderableValueNode)"
|
||||
:[modelProp(item)]="
|
||||
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
|
||||
"
|
||||
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
|
||||
/>
|
||||
<component
|
||||
:is="resolveComponent(item as RenderableValueNode)"
|
||||
v-else
|
||||
v-tooltip="resolveTooltip(item)"
|
||||
v-bind="componentProps(item as RenderableValueNode)"
|
||||
:[modelProp(item)]="
|
||||
getEffectiveValue(item as RenderableValueNode, state[item.id], wrappedState)
|
||||
"
|
||||
@[updateEvent(item)]="(v: unknown) => updateValue(item as RenderableValueNode, v)"
|
||||
/>
|
||||
<template
|
||||
v-for="(tweak, tIdx) in (item as RenderableValueNode)._tweaks ?? []"
|
||||
:key="`tweak-${tIdx}`"
|
||||
>
|
||||
<IconButton
|
||||
v-tooltip="tweakTooltip(tweak, item as RenderableValueNode)"
|
||||
:label="tweakLabel(tweak, item as RenderableValueNode)"
|
||||
:disabled="!tweakEnabled(tweak, item as RenderableValueNode)"
|
||||
@click="applyTweak(tweak, item as RenderableValueNode)"
|
||||
>
|
||||
<component :is="tweak.icon" />
|
||||
</IconButton>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else-if="hasCap(item, '_onClick')">
|
||||
<Button
|
||||
v-tooltip="resolveTooltip(item)"
|
||||
:disabled="!isEnabled(item as any)"
|
||||
:aria-label="(item as any)._icon ? (item as any).label : undefined"
|
||||
@click="clickButton(item)"
|
||||
>
|
||||
<component :is="(item as any)._icon" v-if="(item as any)._icon" />
|
||||
<template v-else>{{ (item as any).label }}</template>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-for="(item, idx) in nodes" :key="`children-${nodeKey(item, idx)}`">
|
||||
<NodeRenderer
|
||||
v-if="
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
isShown(item) &&
|
||||
hasValueCap(item) &&
|
||||
hasIdCap(item) &&
|
||||
hasChildrenCap(item) &&
|
||||
isNodeActive(item, state[item.id], wrappedState) &&
|
||||
resolveChildren(item, valueScope(item).state).length
|
||||
"
|
||||
:nodes="resolveChildren(item, valueScope(item).state)"
|
||||
:state="valueScope(item).state"
|
||||
:write="valueScope(item).write"
|
||||
:on-image-upload="onImageUpload"
|
||||
:app-components="appComponents"
|
||||
:global-state="globalState"
|
||||
:title-depth="getTitle(item) !== undefined ? (titleDepth ?? 0) + 1 : titleDepth"
|
||||
class="w-full"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
import type { NodeMeta } from './node-meta'
|
||||
|
||||
export interface ChecklistMetaContext {
|
||||
metaMap: Map<object, NodeMeta>
|
||||
attentionMap: Map<object, boolean>
|
||||
tooltipHtml: Map<object, string>
|
||||
}
|
||||
|
||||
export const CHECKLIST_META_KEY: InjectionKey<Ref<ChecklistMetaContext>> = Symbol('checklistMeta')
|
||||
@@ -0,0 +1,372 @@
|
||||
import { Checkbox, Combobox, MarkdownEditor, StyledInput, Toggle } from '@modrinth/ui'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import { withAutoProps, withChildren } from './builder'
|
||||
import type { ComponentNodePropsContext, Configurable } from './capabilities'
|
||||
import {
|
||||
withComponent,
|
||||
withEditable,
|
||||
withEnabled,
|
||||
withExtraProps,
|
||||
withFix,
|
||||
withIcon,
|
||||
withId,
|
||||
withLayout,
|
||||
withMessaging,
|
||||
withNoneLabel,
|
||||
withOnClick,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withSelectable,
|
||||
withShown,
|
||||
withStageMeta,
|
||||
withStateOrigin,
|
||||
withTitle,
|
||||
withTooltip,
|
||||
withTweak,
|
||||
withValue,
|
||||
} from './capabilities'
|
||||
import ActionButton from './components/ActionButton.vue'
|
||||
import { pipe } from './pipe'
|
||||
import type { NodeState, NodeStateWithChildren } from './state'
|
||||
|
||||
function getBooleanValue(raw: NodeState): boolean {
|
||||
if (typeof raw === 'boolean') return raw
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const v = (raw as NodeStateWithChildren).value
|
||||
if (typeof v === 'boolean') return v
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function setBooleanValue(raw: NodeState, next: boolean, isDefault?: boolean): NodeState {
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
|
||||
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
|
||||
}
|
||||
return isDefault ? undefined : next
|
||||
}
|
||||
|
||||
export const booleanValue = {
|
||||
_getValue: getBooleanValue,
|
||||
_setValue: setBooleanValue,
|
||||
_isActive: (v: boolean) => v === true,
|
||||
}
|
||||
|
||||
export function toggle(id: string, label: string) {
|
||||
return pipe(
|
||||
{ label } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withIcon,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(ActionButton),
|
||||
componentProps: (ctx) => ({
|
||||
label: n.label,
|
||||
icon: n._icon,
|
||||
needsAttention: ctx.nodeFacts?.needsAttention ?? false,
|
||||
fixActionable: ctx.nodeFacts?.fixActionable ?? false,
|
||||
tooltip: ctx.tooltip,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function button(label?: string) {
|
||||
return pipe(
|
||||
{ label } as { label?: string },
|
||||
withShown,
|
||||
withIcon,
|
||||
withTooltip,
|
||||
withEnabled,
|
||||
withOnClick,
|
||||
)
|
||||
}
|
||||
|
||||
export function check(id: string, label: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{ label } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(Checkbox),
|
||||
componentProps: () => ({ label }),
|
||||
}),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function toggleSwitch(id: string, label: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{ label } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, booleanValue),
|
||||
(n) => withComponent(n, { component: markRaw(Toggle) }),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function group(id?: string) {
|
||||
const base = pipe(
|
||||
{} as Record<string, never>,
|
||||
withChildren,
|
||||
withShown,
|
||||
withTitle,
|
||||
withRequired,
|
||||
withSelectable,
|
||||
withLayout,
|
||||
)
|
||||
return id === undefined ? base : withId(base, id)
|
||||
}
|
||||
|
||||
export type GroupNode = ReturnType<typeof group>
|
||||
|
||||
export function externalGroup(path: string[]) {
|
||||
return pipe({} as Record<string, never>, withChildren, withShown, (n) =>
|
||||
withStateOrigin(n).stateOrigin(path),
|
||||
)
|
||||
}
|
||||
|
||||
const optionValue = {
|
||||
_getValue: () => true,
|
||||
_setValue: () => undefined,
|
||||
_isActive: () => true,
|
||||
}
|
||||
|
||||
export function option(value: string, label: string) {
|
||||
return pipe(
|
||||
{ value, label } as { value: string; label: string },
|
||||
withChildren,
|
||||
withShown,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
(n) => withValue(n, optionValue),
|
||||
)
|
||||
}
|
||||
|
||||
type OptionNode = ReturnType<typeof option>
|
||||
|
||||
interface HasOptions {
|
||||
_options: OptionNode[]
|
||||
options(this: this, ...opts: OptionNode[]): this
|
||||
}
|
||||
|
||||
function withOptions<T extends object>(node: T): T & HasOptions {
|
||||
return Object.assign(node, {
|
||||
_options: [] as OptionNode[],
|
||||
options(this: HasOptions, ...opts: OptionNode[]) {
|
||||
this._options = opts
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getStringValue(raw: NodeState): string {
|
||||
if (typeof raw === 'string') return raw
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const v = (raw as NodeStateWithChildren).value
|
||||
if (typeof v === 'string') return v
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function setStringValue(raw: NodeState, next: string, isDefault?: boolean): NodeState {
|
||||
if (raw && typeof raw === 'object' && !(raw instanceof Set)) {
|
||||
const { value: _v, ...children } = raw as NodeStateWithChildren & Record<string, NodeState>
|
||||
if (Object.keys(children).length > 0) return isDefault ? children : { ...children, value: next }
|
||||
}
|
||||
return isDefault ? undefined : next
|
||||
}
|
||||
|
||||
const dropdownValue = {
|
||||
_getValue: getStringValue,
|
||||
_setValue: setStringValue,
|
||||
_isActive: (v: string) => v !== '',
|
||||
}
|
||||
|
||||
export function dropdown(id: string) {
|
||||
return pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withShown,
|
||||
withTitle,
|
||||
withRequired,
|
||||
(n) => withValue(n, dropdownValue),
|
||||
withNoneLabel,
|
||||
withOptions,
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(Combobox),
|
||||
componentProps: () => ({
|
||||
options: [
|
||||
...(n._none !== undefined ? [{ value: '', label: n._none }] : []),
|
||||
...n._options.map((o) => ({ value: o.value, label: o.label })),
|
||||
],
|
||||
triggerClass:
|
||||
'!bg-[var(--color-button-bg)] !rounded-[var(--radius-md)] !shadow-[var(--shadow-inset-sm),0_0_0_0_transparent]',
|
||||
dropdownClass: '!rounded-[var(--radius-md)] !bg-[var(--color-button-bg)] !border-0',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function stage(id: string, title: string) {
|
||||
return pipe(
|
||||
{ label: title } as { label: string },
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withIcon,
|
||||
withStageMeta,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
)
|
||||
}
|
||||
|
||||
export type StageNode = ReturnType<typeof stage>
|
||||
|
||||
const stringValue = dropdownValue
|
||||
|
||||
export function text(id: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
withEditable,
|
||||
(n) => Object.assign(n, { _showTooltip: true, _imperativeSync: true }),
|
||||
(n) => withValue(n, stringValue),
|
||||
withTweak,
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(StyledInput),
|
||||
componentProps: () => ({ class: 'min-w-40 flex-1', autocomplete: 'off' }),
|
||||
}),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function markdown(id: string) {
|
||||
return withAutoProps(
|
||||
pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
withEditable,
|
||||
(n) => withValue(n, stringValue),
|
||||
(n) =>
|
||||
withComponent(n, {
|
||||
component: markRaw(MarkdownEditor),
|
||||
componentProps: (ctx) => ({
|
||||
maxHeight: 300,
|
||||
disabled: false,
|
||||
headingButtons: false,
|
||||
onImageUpload: ctx.onImageUpload,
|
||||
}),
|
||||
}),
|
||||
withExtraProps,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getSetValue(raw: NodeState): string[] {
|
||||
return raw instanceof Set ? Array.from(raw) : []
|
||||
}
|
||||
|
||||
function setSetValue(_raw: NodeState, next: string[], isDefault?: boolean): NodeState {
|
||||
return isDefault || next.length === 0 ? undefined : new Set(next)
|
||||
}
|
||||
|
||||
const setValue = {
|
||||
_getValue: getSetValue,
|
||||
_setValue: setSetValue,
|
||||
_isActive: (v: string[]) => v.length > 0,
|
||||
}
|
||||
|
||||
const stringValueBehavior = {
|
||||
_getValue: getStringValue,
|
||||
_setValue: setStringValue,
|
||||
_isActive: (v: string) => v !== '',
|
||||
}
|
||||
|
||||
export function appComponent(id: string, rendererKey: string) {
|
||||
const node = pipe(
|
||||
{} as Record<string, never>,
|
||||
(n) => withId(n, id),
|
||||
withChildren,
|
||||
withShown,
|
||||
withTooltip,
|
||||
withTitle,
|
||||
withMessaging,
|
||||
withPriority,
|
||||
withRequired,
|
||||
withFix,
|
||||
withEnabled,
|
||||
(n) => withValue(n, stringValueBehavior),
|
||||
(n) => withComponent(n, { rendererKey }),
|
||||
withExtraProps,
|
||||
)
|
||||
return Object.assign(node, {
|
||||
valueKind(this: Configurable, kind: 'boolean' | 'string' | 'set') {
|
||||
Object.assign(
|
||||
this,
|
||||
kind === 'boolean' ? booleanValue : kind === 'set' ? setValue : stringValueBehavior,
|
||||
)
|
||||
return this
|
||||
},
|
||||
props(this: Configurable, fn: (ctx: ComponentNodePropsContext) => Record<string, unknown>) {
|
||||
this._extraProps = fn
|
||||
return this
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type { NodeState } from './state'
|
||||
|
||||
export class FixBuilder {
|
||||
_projectFn?: (
|
||||
patch: Labrinth.Projects.v3.EditProjectRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
_versionFn?: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void
|
||||
|
||||
project(
|
||||
fn: (patch: Labrinth.Projects.v3.EditProjectRequest, state: Record<string, NodeState>) => void,
|
||||
): this {
|
||||
this._projectFn = fn
|
||||
return this
|
||||
}
|
||||
|
||||
version(
|
||||
fn: (
|
||||
patch: Labrinth.Versions.v3.ModifyVersionRequest,
|
||||
state: Record<string, NodeState>,
|
||||
) => void,
|
||||
): this {
|
||||
this._versionFn = fn
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export function fix(): FixBuilder {
|
||||
return new FixBuilder()
|
||||
}
|
||||
|
||||
export function createTrackedPatch<T extends object>(
|
||||
source: T,
|
||||
): { proxy: T; changes: () => Partial<T> } {
|
||||
const written = new Set<string | symbol>()
|
||||
const data = { ...source }
|
||||
const proxy = new Proxy(data, {
|
||||
set(target, key, value) {
|
||||
if (value !== (source as Record<string | symbol, unknown>)[key]) {
|
||||
written.add(key)
|
||||
} else {
|
||||
written.delete(key)
|
||||
}
|
||||
;(target as Record<string | symbol, unknown>)[key] = value
|
||||
return true
|
||||
},
|
||||
})
|
||||
return {
|
||||
proxy,
|
||||
changes: () =>
|
||||
Object.fromEntries([...written].map((k) => [k, data[k as keyof T]])) as Partial<T>,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from './builder'
|
||||
export * from './capabilities'
|
||||
export * from './collect'
|
||||
export * from './context'
|
||||
export * from './factories'
|
||||
export * from './fix'
|
||||
export * from './messages'
|
||||
export * from './mutate'
|
||||
export * from './node-meta'
|
||||
export * from './pipe'
|
||||
export * from './resolve'
|
||||
export * from './state'
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
expandVariables,
|
||||
flattenProjectV3Variables,
|
||||
flattenProjectVariables,
|
||||
flattenStaticVariables,
|
||||
} from '../../utils'
|
||||
import type { GetVarsFn, MessageSegment, NodeState } from './state'
|
||||
|
||||
const messageFiles = import.meta.glob('../../data/messages/**/*.md', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
})
|
||||
|
||||
export type MessageFn = ((state: Record<string, NodeState>) => Promise<string>) & {
|
||||
concat(...others: MessageFn[]): MessageFn
|
||||
}
|
||||
|
||||
function makeMessageFn(fn: (state: Record<string, NodeState>) => Promise<string>): MessageFn {
|
||||
const rich = fn as MessageFn
|
||||
rich.concat = (...others) =>
|
||||
makeMessageFn(async (state) =>
|
||||
(await Promise.all([rich, ...others].map((f) => f(state)))).join(''),
|
||||
)
|
||||
return rich
|
||||
}
|
||||
|
||||
let _project: Ref<Labrinth.Projects.v3.Project> | null = null
|
||||
let _projectV2: Ref<Labrinth.Projects.v2.Project> | null = null
|
||||
let _onMissingMd: ((path: string) => void) | null = null
|
||||
|
||||
export function setMissingMdHandler(handler: (path: string) => void) {
|
||||
_onMissingMd = handler
|
||||
}
|
||||
|
||||
export function setMessageProject(
|
||||
project: Ref<Labrinth.Projects.v3.Project>,
|
||||
projectV2: Ref<Labrinth.Projects.v2.Project>,
|
||||
) {
|
||||
_project = project
|
||||
_projectV2 = projectV2
|
||||
}
|
||||
|
||||
export function mdEscape(text: string): string {
|
||||
return text.replace(/[\\*_`[~]/g, '\\$&')
|
||||
}
|
||||
|
||||
const USER_CONTENT_KEYS = [
|
||||
'PROJECT_TITLE',
|
||||
'PROJECT_SLUG',
|
||||
'PROJECT_SUMMARY',
|
||||
'PROJECT_TYPE',
|
||||
'PROJECT_STATUS',
|
||||
]
|
||||
|
||||
export async function loadMd(
|
||||
path: string,
|
||||
state: Record<string, NodeState>,
|
||||
project: Labrinth.Projects.v3.Project,
|
||||
projectV2: Labrinth.Projects.v2.Project,
|
||||
getVars?: GetVarsFn,
|
||||
): Promise<string> {
|
||||
const extraVars = getVars ? getVars(state) : null
|
||||
const loader = messageFiles[`../../data/messages/${path}.md`]
|
||||
if (!loader) {
|
||||
_onMissingMd?.(path)
|
||||
return ''
|
||||
}
|
||||
const raw = (await loader()) as string
|
||||
const vars: Record<string, string> = {
|
||||
...flattenStaticVariables(),
|
||||
...flattenProjectVariables(projectV2),
|
||||
...flattenProjectV3Variables(project),
|
||||
}
|
||||
for (const key of USER_CONTENT_KEYS) {
|
||||
if (key in vars) vars[key] = mdEscape(vars[key])
|
||||
}
|
||||
if (extraVars) {
|
||||
for (const [k, v] of Object.entries(extraVars)) {
|
||||
vars[k] = String(v ?? '')
|
||||
}
|
||||
}
|
||||
const expanded = expandVariables(raw, projectV2, project, vars)
|
||||
return expanded.replace(/`[^`\n]*`/g, (match) => match.replace(/\\([\\*_`[~])/g, '$1'))
|
||||
}
|
||||
|
||||
export function mdOptional(path: string, getVars?: GetVarsFn): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const loader = messageFiles[`../../data/messages/${path}.md`]
|
||||
if (!loader) return ''
|
||||
return loadMd(path, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
|
||||
export function md(
|
||||
path: string | ((state: Record<string, NodeState>) => string),
|
||||
getVars?: GetVarsFn,
|
||||
): MessageFn {
|
||||
return makeMessageFn(async (state) => {
|
||||
const resolvedPath = typeof path === 'function' ? path(state) : path
|
||||
return loadMd(resolvedPath, state, _project!.value, _projectV2!.value, getVars)
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveRelativeMessagePath(
|
||||
messagePath: string | (() => string),
|
||||
statePath: string[],
|
||||
): string {
|
||||
const name = typeof messagePath === 'function' ? messagePath() : messagePath
|
||||
if (name.startsWith('/')) return `checklist/messages${name}`
|
||||
const parts = [...statePath.slice(0, -1), ...name.split('/')]
|
||||
const normalized = parts.reduce<string[]>((acc, p) => {
|
||||
if (p === '..') acc.pop()
|
||||
else if (p) acc.push(p)
|
||||
return acc
|
||||
}, [])
|
||||
return `checklist/messages/${normalized.join('/')}`
|
||||
}
|
||||
|
||||
export async function evalSegment(
|
||||
seg: MessageSegment,
|
||||
state: Record<string, NodeState>,
|
||||
statePath: string[],
|
||||
): Promise<string> {
|
||||
if (seg.type === 'collect') return ''
|
||||
if (seg.type === 'fn') return String((await seg.fn(state)) ?? '')
|
||||
if (seg.type === 'auto') {
|
||||
return loadMd(
|
||||
`checklist/messages/${statePath.join('/')}`,
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
}
|
||||
return loadMd(
|
||||
resolveRelativeMessagePath(seg.path, statePath),
|
||||
state,
|
||||
_project!.value,
|
||||
_projectV2!.value,
|
||||
seg.getVars,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { HasValue, Identified } from './capabilities'
|
||||
import { getEffectiveValue } from './resolve'
|
||||
import type { NodeState } from './state'
|
||||
|
||||
function valuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false
|
||||
const setB = new Set(b)
|
||||
return a.every((v) => setB.has(v))
|
||||
}
|
||||
return a === b
|
||||
}
|
||||
|
||||
export type Writer = (id: string, value: NodeState) => void
|
||||
|
||||
export function childWriter(
|
||||
parentRead: Record<string, NodeState>,
|
||||
parentWrite: Writer,
|
||||
containerId: string,
|
||||
): Writer {
|
||||
return (childId, value) => {
|
||||
const existing = parentRead[containerId]
|
||||
const container: Record<string, NodeState> =
|
||||
existing && typeof existing === 'object' && !(existing instanceof Set)
|
||||
? { ...(existing as Record<string, NodeState>) }
|
||||
: existing !== undefined
|
||||
? { value: existing }
|
||||
: {}
|
||||
if (value === undefined) Reflect.deleteProperty(container, childId)
|
||||
else container[childId] = value
|
||||
parentWrite(containerId, Object.keys(container).length === 0 ? undefined : container)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeNodeValue<V>(
|
||||
node: HasValue<V> & Identified,
|
||||
read: Record<string, NodeState>,
|
||||
write: Writer,
|
||||
next: V,
|
||||
contextState: Record<string, NodeState> = read,
|
||||
): void {
|
||||
const isDefault = valuesEqual(next, getEffectiveValue(node, undefined, contextState))
|
||||
write(node.id, node._setValue(read[node.id], next, isDefault))
|
||||
}
|
||||
|
||||
export function originScope(
|
||||
root: Record<string, NodeState>,
|
||||
path: string[],
|
||||
): { state: Record<string, NodeState>; write: Writer } {
|
||||
let state = root
|
||||
let write: Writer = (id, value) => {
|
||||
if (value === undefined) Reflect.deleteProperty(root, id)
|
||||
else root[id] = value
|
||||
}
|
||||
for (const segment of path) {
|
||||
write = childWriter(state, write, segment)
|
||||
const raw = state[segment]
|
||||
state =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
}
|
||||
return { state, write }
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { AnyNode, ChildNode, HasChildren } from './builder'
|
||||
import type { FixBuilder } from './fix'
|
||||
import {
|
||||
getBooleanChildState,
|
||||
hasCap,
|
||||
hasChildrenCap,
|
||||
hasIdCap,
|
||||
hasOptionsCap,
|
||||
hasValueCap,
|
||||
isNodeActive,
|
||||
isShown,
|
||||
resolveActionState,
|
||||
resolveChildren,
|
||||
walkNodes,
|
||||
} from './resolve'
|
||||
import type { NodeState } from './state'
|
||||
|
||||
export interface NodeMeta {
|
||||
hasRequiredMissing: boolean
|
||||
isFixActionable: boolean
|
||||
}
|
||||
|
||||
export function computeNodeMeta(
|
||||
children: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
isFixActionable: (fixes: FixBuilder[], state: Record<string, NodeState>) => boolean,
|
||||
): Map<object, NodeMeta> {
|
||||
const map = new Map<object, NodeMeta>()
|
||||
|
||||
walkNodes(children, stageState, (node, nodeState, localState) => {
|
||||
const active = isNodeActive(node, nodeState, localState)
|
||||
|
||||
const required = hasCap(node, '_required') && node._required === true
|
||||
const hasRequiredMissing = required && !active
|
||||
|
||||
let fixActionable = false
|
||||
if (active && hasCap(node, '_fixes')) {
|
||||
const fixes = node._fixes as FixBuilder[]
|
||||
if (fixes.length > 0) {
|
||||
fixActionable = isFixActionable(fixes, resolveActionState(node, nodeState, localState))
|
||||
}
|
||||
}
|
||||
|
||||
map.set(node, { hasRequiredMissing, isFixActionable: fixActionable })
|
||||
})
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
export function computeAttentionMap(
|
||||
nodes: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
metaMap: Map<object, NodeMeta>,
|
||||
attention: Map<object, boolean> = new Map(),
|
||||
): Map<object, boolean> {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (!isShown(node as AnyNode)) continue
|
||||
|
||||
if (hasChildrenCap(node) && !hasValueCap(node)) {
|
||||
const childState = childScopeFor(node, stageState)
|
||||
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
|
||||
const childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
|
||||
const ownRequired = hasCap(node, '_required') && node._required === true
|
||||
const unsatisfied = ownRequired && !hasActiveChild(node, childState)
|
||||
attention.set(node, unsatisfied || childrenNeedAttention)
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasValueCap(node) && hasIdCap(node)) {
|
||||
const rawNodeState = stageState[node.id]
|
||||
const active = isNodeActive(node, rawNodeState, stageState)
|
||||
let childrenNeedAttention = false
|
||||
|
||||
if (active) {
|
||||
if (hasOptionsCap(node)) {
|
||||
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
|
||||
const selected = node._options.find((o) => o.value === selectedValue)
|
||||
if (selected && isShown(selected) && hasChildrenCap(selected)) {
|
||||
computeAttentionMap(
|
||||
resolveChildren(selected, stageState),
|
||||
stageState,
|
||||
metaMap,
|
||||
attention,
|
||||
)
|
||||
childrenNeedAttention = someChildNeedsAttention(selected, stageState, attention)
|
||||
}
|
||||
} else if (hasChildrenCap(node)) {
|
||||
const childState = getBooleanChildState(rawNodeState)
|
||||
computeAttentionMap(resolveChildren(node, childState), childState, metaMap, attention)
|
||||
childrenNeedAttention = someChildNeedsAttention(node, childState, attention)
|
||||
}
|
||||
}
|
||||
|
||||
const ownMissing = metaMap.get(node)?.hasRequiredMissing ?? false
|
||||
attention.set(node, ownMissing || childrenNeedAttention)
|
||||
}
|
||||
}
|
||||
return attention
|
||||
}
|
||||
|
||||
function childScopeFor(
|
||||
node: HasChildren & { id?: string },
|
||||
state: Record<string, NodeState>,
|
||||
): Record<string, NodeState> {
|
||||
if (!hasIdCap(node)) return state
|
||||
const raw = state[node.id]
|
||||
return raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function hasActiveChild(node: HasChildren, childState: Record<string, NodeState>): boolean {
|
||||
return resolveChildren(node, childState).some((child) => {
|
||||
if (typeof child !== 'object' || child === null) return false
|
||||
const raw = hasIdCap(child) ? childState[child.id] : undefined
|
||||
return isNodeActive(child, raw, childState)
|
||||
})
|
||||
}
|
||||
|
||||
function someChildNeedsAttention(
|
||||
node: HasChildren,
|
||||
childState: Record<string, NodeState>,
|
||||
attention: Map<object, boolean>,
|
||||
): boolean {
|
||||
return resolveChildren(node, childState).some(
|
||||
(child) => typeof child === 'object' && child !== null && attention.get(child) === true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function pipe<A>(a: A): A
|
||||
export function pipe<A, B>(a: A, f1: (a: A) => B,): B
|
||||
export function pipe<A, B, C>(a: A, f1: (a: A) => B, f2: (b: B) => C,): C
|
||||
export function pipe<A, B, C, D>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D,): D
|
||||
export function pipe<A, B, C, D, E>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E,): E
|
||||
export function pipe<A, B, C, D, E, F>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F,): F
|
||||
export function pipe<A, B, C, D, E, F, G>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G,): G
|
||||
export function pipe<A, B, C, D, E, F, G, H>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H,): H
|
||||
export function pipe<A, B, C, D, E, F, G, H, I>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I,): I
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J,): J
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K,): K
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L,): L
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M,): M
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N,): N
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O,): O
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P,): P
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q,): Q
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R,): R
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S,): S
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T,): T
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U,): U
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V,): V
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W,): W
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X,): X
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y,): Y
|
||||
export function pipe<A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z>(a: A, f1: (a: A) => B, f2: (b: B) => C, f3: (c: C) => D, f4: (d: D) => E, f5: (e: E) => F, f6: (f: F) => G, f7: (g: G) => H, f8: (h: H) => I, f9: (i: I) => J, f10: (j: J) => K, f11: (k: K) => L, f12: (l: L) => M, f13: (m: M) => N, f14: (n: N) => O, f15: (o: O) => P, f16: (p: P) => Q, f17: (q: Q) => R, f18: (r: R) => S, f19: (s: S) => T, f20: (t: T) => U, f21: (u: U) => V, f22: (v: V) => W, f23: (w: W) => X, f24: (x: X) => Y, f25: (y: Y) => Z,): Z
|
||||
export function pipe(a: unknown, ...fns: Array<(x: unknown) => unknown>): unknown {
|
||||
return fns.reduce((acc, fn) => fn(acc), a)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import type { AnyNode, ChildEntry, ChildNode, HasChildren } from './builder'
|
||||
import type { HasValue, Identified } from './capabilities'
|
||||
import type { Writer } from './mutate'
|
||||
import { childWriter, writeNodeValue } from './mutate'
|
||||
import type { NodeState } from './state'
|
||||
import { resolve } from './state'
|
||||
|
||||
export function hasCap<K extends string>(node: object, key: K): node is Record<K, unknown> {
|
||||
return key in node
|
||||
}
|
||||
|
||||
export function hasValueCap(node: object): node is HasValue {
|
||||
return hasCap(node, '_getValue') && hasCap(node, '_isActive')
|
||||
}
|
||||
|
||||
export function hasChildrenCap(node: object): node is HasChildren {
|
||||
return hasCap(node, '_children')
|
||||
}
|
||||
|
||||
export function hasOptionsCap(node: object): node is { _options: OptionLike[] } {
|
||||
return hasCap(node, '_options')
|
||||
}
|
||||
|
||||
export function hasIdCap(node: object): node is Identified {
|
||||
return hasCap(node, 'id')
|
||||
}
|
||||
|
||||
interface OptionLike extends AnyNode, Partial<HasChildren> {
|
||||
value: string
|
||||
_segments?: unknown[]
|
||||
}
|
||||
|
||||
export function isShown(node: AnyNode): boolean {
|
||||
return node._shown === undefined || resolve(node._shown)
|
||||
}
|
||||
|
||||
export function getEffectiveValue<V>(
|
||||
node: HasValue<V>,
|
||||
rawState: NodeState,
|
||||
contextState: Record<string, NodeState> = {},
|
||||
): V {
|
||||
if (rawState !== undefined) return node._getValue(rawState)
|
||||
if (node._defaultValue !== undefined) {
|
||||
const def =
|
||||
typeof node._defaultValue === 'function'
|
||||
? (node._defaultValue as (state: Record<string, NodeState>) => NodeState)(contextState)
|
||||
: (node._defaultValue as unknown as NodeState)
|
||||
return node._getValue(def)
|
||||
}
|
||||
return node._getValue(rawState)
|
||||
}
|
||||
|
||||
export function isNodeActive(
|
||||
node: object,
|
||||
rawState: NodeState,
|
||||
contextState: Record<string, NodeState> = {},
|
||||
): boolean {
|
||||
if (hasValueCap(node)) return node._isActive(getEffectiveValue(node, rawState, contextState))
|
||||
return false
|
||||
}
|
||||
|
||||
export function getBooleanChildState(nodeState: NodeState): Record<string, NodeState> {
|
||||
if (nodeState && typeof nodeState === 'object' && !(nodeState instanceof Set)) {
|
||||
return nodeState as Record<string, NodeState>
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function resolveChildren(node: HasChildren, state: Record<string, NodeState>): ChildNode[] {
|
||||
const entries: ChildEntry[] = node._childrenFn ? node._childrenFn(state) : node._children
|
||||
|
||||
const result: ChildNode[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry == null) continue
|
||||
if (typeof entry === 'string') {
|
||||
result.push(entry)
|
||||
continue
|
||||
}
|
||||
if (typeof entry === 'function') {
|
||||
if (entry.length === 0) {
|
||||
result.push(entry as () => unknown)
|
||||
continue
|
||||
}
|
||||
const fn = entry as (state?: Record<string, NodeState>) => AnyNode | AnyNode[] | null
|
||||
const resolved = fn(state)
|
||||
if (resolved == null) continue
|
||||
if (Array.isArray(resolved)) {
|
||||
for (const r of resolved) {
|
||||
if (r != null && isShown(r)) result.push(r)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (isShown(resolved)) result.push(resolved)
|
||||
continue
|
||||
}
|
||||
const resolved = toValue(entry as AnyNode | Ref<AnyNode | null>)
|
||||
if (resolved != null && isShown(resolved)) result.push(resolved)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildChildMap(
|
||||
children: ChildNode[],
|
||||
stateForFlattening: Record<string, NodeState>,
|
||||
): Map<string, object> {
|
||||
const map = new Map<string, object>()
|
||||
function collect(nodes: ChildNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (hasIdCap(node)) {
|
||||
map.set(node.id, node)
|
||||
continue
|
||||
}
|
||||
if (hasChildrenCap(node)) collect(resolveChildren(node, stateForFlattening))
|
||||
}
|
||||
}
|
||||
collect(children)
|
||||
return map
|
||||
}
|
||||
|
||||
function optionChildrenFor(node: object, state: Record<string, NodeState>): ChildNode[] {
|
||||
if (!hasOptionsCap(node) || !hasIdCap(node)) return []
|
||||
const raw = state[node.id]
|
||||
const selectedValue = typeof raw === 'string' ? raw : undefined
|
||||
const selected = node._options.find((o) => o.value === selectedValue)
|
||||
if (!selected || !hasChildrenCap(selected)) return []
|
||||
return resolveChildren(selected, state)
|
||||
}
|
||||
|
||||
export function withStateDefaults<T extends Record<string, NodeState>>(
|
||||
rawState: T,
|
||||
children: ChildNode[],
|
||||
write?: Writer,
|
||||
): T {
|
||||
if (rawState == null || typeof rawState !== 'object' || rawState instanceof Set) return rawState
|
||||
|
||||
const childMap = buildChildMap(children, rawState)
|
||||
for (const [, node] of childMap) {
|
||||
for (const c of optionChildrenFor(node, rawState)) {
|
||||
if (typeof c === 'object' && c !== null && hasIdCap(c)) childMap.set(c.id, c)
|
||||
}
|
||||
}
|
||||
const resolving = new Set<string>()
|
||||
|
||||
const proxy = new Proxy(rawState, {
|
||||
get(target, key, receiver) {
|
||||
if (typeof key !== 'string') return Reflect.get(target, key, receiver)
|
||||
const child = childMap.get(key)
|
||||
let value: NodeState = Reflect.get(target, key, receiver)
|
||||
|
||||
if (value === undefined && child && hasValueCap(child) && !resolving.has(key)) {
|
||||
resolving.add(key)
|
||||
try {
|
||||
const effective = getEffectiveValue(child, undefined, proxy)
|
||||
const grandchildren = hasChildrenCap(child) ? resolveChildren(child, {}) : []
|
||||
if (grandchildren.length > 0) {
|
||||
return withStateDefaults(
|
||||
{ value: effective } as Record<string, NodeState>,
|
||||
grandchildren,
|
||||
write && childWriter(target, write, key),
|
||||
)
|
||||
}
|
||||
value = child._setValue(undefined, effective)
|
||||
} finally {
|
||||
resolving.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
child &&
|
||||
hasChildrenCap(child) &&
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!(value instanceof Set)
|
||||
) {
|
||||
const nested = value as Record<string, NodeState>
|
||||
return withStateDefaults(
|
||||
nested,
|
||||
resolveChildren(child, nested),
|
||||
write && childWriter(target, write, key),
|
||||
)
|
||||
}
|
||||
|
||||
if (value === undefined && child && hasChildrenCap(child)) {
|
||||
const grandchildren = resolveChildren(child, {})
|
||||
if (grandchildren.length > 0) return emptyScope(grandchildren)
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
set(target, key, value, receiver) {
|
||||
if (typeof key !== 'string') return Reflect.set(target, key, value, receiver)
|
||||
const child = childMap.get(key)
|
||||
if (child && hasValueCap(child) && write) {
|
||||
writeNodeValue(child as HasValue & Identified, target, write, value as never, proxy)
|
||||
return true
|
||||
}
|
||||
return Reflect.set(target, key, value, receiver)
|
||||
},
|
||||
}) as T
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
export function resolveActionState(
|
||||
node: object,
|
||||
nodeState: NodeState,
|
||||
localState: Record<string, NodeState>,
|
||||
): Record<string, NodeState> {
|
||||
if (!hasValueCap(node)) return localState
|
||||
const childState = getBooleanChildState(nodeState)
|
||||
const base = hasChildrenCap(node)
|
||||
? withStateDefaults(childState, resolveChildren(node, childState))
|
||||
: childState
|
||||
return new Proxy(base, {
|
||||
get(target, key, receiver) {
|
||||
if (key === 'value') return getEffectiveValue(node, nodeState, localState) as NodeState
|
||||
return Reflect.get(target, key, receiver)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function emptyScope(children: ChildNode[]): Record<string, NodeState> {
|
||||
const childMap = buildChildMap(children, {})
|
||||
const resolving = new Set<string>()
|
||||
const cache = new Map<string, Record<string, NodeState>>()
|
||||
|
||||
const proxy: Record<string, NodeState> = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_target, key) {
|
||||
if (typeof key !== 'string') return undefined
|
||||
const child = childMap.get(key)
|
||||
if (!child) return undefined
|
||||
|
||||
if (hasValueCap(child) && !resolving.has(key)) {
|
||||
resolving.add(key)
|
||||
try {
|
||||
const def = getEffectiveValue(child, undefined, proxy)
|
||||
if (def !== undefined) return child._setValue(undefined, def)
|
||||
} finally {
|
||||
resolving.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChildrenCap(child)) return undefined
|
||||
|
||||
const cached = cache.get(key)
|
||||
if (cached) return cached
|
||||
|
||||
const grandchildren = resolveChildren(child, {})
|
||||
if (grandchildren.length === 0) return undefined
|
||||
|
||||
const nested = emptyScope(grandchildren)
|
||||
cache.set(key, nested)
|
||||
return nested
|
||||
},
|
||||
},
|
||||
) as Record<string, NodeState>
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
export type Visitor = (
|
||||
node: object,
|
||||
nodeState: NodeState,
|
||||
localState: Record<string, NodeState>,
|
||||
path: string[],
|
||||
) => void
|
||||
|
||||
export function walkNodes(
|
||||
nodes: ChildNode[],
|
||||
stageState: Record<string, NodeState>,
|
||||
visitor: Visitor,
|
||||
path: string[] = [],
|
||||
): void {
|
||||
for (const node of nodes) {
|
||||
if (typeof node !== 'object' || node === null) continue
|
||||
if (!isShown(node as AnyNode)) continue
|
||||
|
||||
if (hasChildrenCap(node) && !hasValueCap(node)) {
|
||||
if (hasIdCap(node)) {
|
||||
const raw = stageState[node.id]
|
||||
const childState =
|
||||
raw && typeof raw === 'object' && !(raw instanceof Set)
|
||||
? (raw as Record<string, NodeState>)
|
||||
: {}
|
||||
walkNodes(resolveChildren(node, childState), childState, visitor, [...path, node.id])
|
||||
} else {
|
||||
walkNodes(resolveChildren(node, stageState), stageState, visitor, path)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasValueCap(node) && hasIdCap(node)) {
|
||||
const rawNodeState = stageState[node.id]
|
||||
const nodePath = [...path, node.id]
|
||||
visitor(node, rawNodeState, stageState, nodePath)
|
||||
|
||||
const active = isNodeActive(node, rawNodeState, stageState)
|
||||
if (!active) continue
|
||||
|
||||
if (hasOptionsCap(node)) {
|
||||
const selectedValue = typeof rawNodeState === 'string' ? rawNodeState : undefined
|
||||
const selected = node._options.find((o) => o.value === selectedValue)
|
||||
if (selected && isShown(selected)) {
|
||||
const selectedPath = [...path, selected.value]
|
||||
visitor(selected, undefined, stageState, selectedPath)
|
||||
if (hasChildrenCap(selected)) {
|
||||
walkNodes(resolveChildren(selected, stageState), stageState, visitor, selectedPath)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasChildrenCap(node)) {
|
||||
const childState = getBooleanChildState(rawNodeState)
|
||||
walkNodes(resolveChildren(node, childState), childState, visitor, nodePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
export type ModerationStatus = 'approved' | 'rejected' | 'flagged'
|
||||
|
||||
export type NodeState =
|
||||
| boolean
|
||||
| string
|
||||
| number
|
||||
| Set<string>
|
||||
| NodeStateWithChildren
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export interface NodeStateWithChildren {
|
||||
value?: NodeState
|
||||
[childId: string]: NodeState
|
||||
}
|
||||
|
||||
export type Reactive<T> = T | Ref<T>
|
||||
|
||||
export function resolve<T>(value: Reactive<T>): T {
|
||||
return toValue(value as T | Ref<T>)
|
||||
}
|
||||
|
||||
export type GetVarsFn = (state: Record<string, NodeState>) => Record<string, unknown>
|
||||
export type ContentFn = (state: Record<string, NodeState>) => string | Promise<string>
|
||||
|
||||
export type MessageSegment =
|
||||
| { type: 'fn'; fn: ContentFn }
|
||||
| { type: 'auto'; getVars?: GetVarsFn }
|
||||
| { type: 'path'; path: string | (() => string); getVars?: GetVarsFn }
|
||||
| { type: 'collect'; fallback?: MessageSegment }
|
||||
@@ -37,14 +37,14 @@ export const setting = {
|
||||
asString: (data: StringSettingDefinition) => data,
|
||||
}
|
||||
|
||||
export function isValidFor(definitionBase: SettingDefinitionBase<any>, value: any): boolean {
|
||||
export function isValidFor(
|
||||
definitionBase: SettingDefinitionBase<unknown>,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
if (value != null) {
|
||||
// Tried my best with type safety but sadly having the `SettingDefinitions` as the type leads to issues handling types else where...
|
||||
const definition = definitionBase as SettingDefinitions
|
||||
if (
|
||||
definition.type == 'enum' &&
|
||||
definition.entries.map((entry) => entry.value).includes(value)
|
||||
) {
|
||||
if (definition.type == 'enum' && definition.entries.some((entry) => entry.value === value)) {
|
||||
return true
|
||||
} else if (definition.type == 'toggle' && typeof value === 'boolean') {
|
||||
return true
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||
|
||||
import type { Action } from './actions'
|
||||
|
||||
/**
|
||||
* Represents a moderation stage with associated actions and optional navigation logic.
|
||||
*/
|
||||
export interface Stage {
|
||||
/**
|
||||
* The title of the stage, displayed in the checklist header.
|
||||
*/
|
||||
title: string
|
||||
|
||||
/**
|
||||
* The hint for this stage, tells the moderator what to do.
|
||||
*/
|
||||
hint: string
|
||||
|
||||
/**
|
||||
* An optional description or additional text for the stage.
|
||||
*/
|
||||
text?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3?: Labrinth.Projects.v3.Project,
|
||||
) => Promise<string>
|
||||
|
||||
/**
|
||||
* Optional id for the stage, used for identification in the checklist. Will be used in the stage list as well instead of the title.
|
||||
*/
|
||||
id?: string
|
||||
|
||||
/**
|
||||
* Optional icon for the stage, displayed in the stage list and next to the title.
|
||||
*/
|
||||
icon?: FunctionalComponent<SVGAttributes>
|
||||
|
||||
/**
|
||||
* URL to the guidance document for this stage.
|
||||
*/
|
||||
guidance_url: string
|
||||
|
||||
/**
|
||||
* An array of actions that can be taken in this stage.
|
||||
*/
|
||||
actions: Action[]
|
||||
|
||||
/**
|
||||
* Optional navigation path to redirect the moderator when this stage is shown.
|
||||
*
|
||||
* This is relative to the project page. For example, `/settings#side-types` would navigate to `https://modrinth.com/project/:id/settings#side-types`.
|
||||
*/
|
||||
navigate?: string
|
||||
|
||||
/**
|
||||
* A function that determines whether this stage should be shown for a given project.
|
||||
*
|
||||
* By default, it returns `true`, meaning the stage is always shown.
|
||||
*/
|
||||
shouldShow?: (
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
projectV3?: Labrinth.Projects.v3.Project,
|
||||
) => boolean
|
||||
}
|
||||
@@ -1,181 +1,5 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import type {
|
||||
Action,
|
||||
AdditionalTextInput,
|
||||
ButtonAction,
|
||||
ConditionalMessage,
|
||||
ToggleAction,
|
||||
} from './types/actions'
|
||||
|
||||
export interface ActionState {
|
||||
selected: boolean
|
||||
value?: Set<string | number> | number | string | unknown
|
||||
}
|
||||
|
||||
export interface MessagePart {
|
||||
weight: number
|
||||
content: string
|
||||
actionId: string
|
||||
stageIndex: number
|
||||
}
|
||||
|
||||
export function getActionIdForStage(
|
||||
action: Action,
|
||||
stageIndex: number,
|
||||
actionIndex?: number,
|
||||
enabledIndex?: number,
|
||||
): string {
|
||||
if (action.id) {
|
||||
return `stage-${stageIndex}-${action.id}`
|
||||
}
|
||||
const suffix = enabledIndex !== undefined ? `-enabled-${enabledIndex}` : ''
|
||||
return `stage-${stageIndex}-action-${actionIndex}${suffix}`
|
||||
}
|
||||
|
||||
export function getActionId(action: Action, currentStage: number, index?: number): string {
|
||||
return getActionIdForStage(action, currentStage, index)
|
||||
}
|
||||
|
||||
export function getActionKey(
|
||||
action: Action,
|
||||
currentStage: number,
|
||||
visibleActions: Action[],
|
||||
): string {
|
||||
const index = visibleActions.indexOf(action)
|
||||
return `${currentStage}-${index}-${getActionId(action, currentStage)}`
|
||||
}
|
||||
|
||||
export function initializeActionState(action: Action): ActionState {
|
||||
if (action.type === 'toggle') {
|
||||
return {
|
||||
selected: action.defaultChecked || false,
|
||||
}
|
||||
} else if (action.type === 'dropdown') {
|
||||
return {
|
||||
selected: true,
|
||||
value: action.defaultOption || 0,
|
||||
}
|
||||
} else if (action.type === 'multi-select-chips') {
|
||||
return {
|
||||
selected: false,
|
||||
value: new Set<string | number>(),
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function processMessage(
|
||||
message: string,
|
||||
action: Action,
|
||||
stageIndex: number,
|
||||
textInputValues: Record<string, string>,
|
||||
): string {
|
||||
let processedMessage = message
|
||||
|
||||
if (action.relevantExtraInput) {
|
||||
action.relevantExtraInput.forEach((input, index) => {
|
||||
if (input.variable) {
|
||||
const inputKey = `stage-${stageIndex}-${action.id || `action-${index}`}-${index}`
|
||||
const value = textInputValues[inputKey] || ''
|
||||
|
||||
const regex = new RegExp(`%${input.variable}%`, 'g')
|
||||
processedMessage = processedMessage.replace(regex, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return processedMessage
|
||||
}
|
||||
|
||||
export function findMatchingVariant(
|
||||
variants: ConditionalMessage[],
|
||||
selectedActionIds: string[],
|
||||
allValidActionIds?: string[],
|
||||
currentStageIndex?: number,
|
||||
): ConditionalMessage | null {
|
||||
for (const variant of variants) {
|
||||
const conditions = variant.conditions
|
||||
|
||||
const meetsRequired =
|
||||
!conditions.requiredActions ||
|
||||
conditions.requiredActions.every((id) => {
|
||||
let fullId = id
|
||||
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
|
||||
fullId = `stage-${currentStageIndex}-${id}`
|
||||
}
|
||||
|
||||
if (allValidActionIds && !allValidActionIds.includes(fullId)) {
|
||||
return false
|
||||
}
|
||||
return selectedActionIds.includes(fullId)
|
||||
})
|
||||
|
||||
const meetsExcluded =
|
||||
!conditions.excludedActions ||
|
||||
!conditions.excludedActions.some((id) => {
|
||||
let fullId = id
|
||||
if (currentStageIndex !== undefined && !id.startsWith('stage-')) {
|
||||
fullId = `stage-${currentStageIndex}-${id}`
|
||||
}
|
||||
return selectedActionIds.includes(fullId)
|
||||
})
|
||||
|
||||
if (meetsRequired && meetsExcluded) {
|
||||
return variant
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getActionMessage(
|
||||
action: ButtonAction | ToggleAction,
|
||||
selectedActionIds: string[],
|
||||
allValidActionIds?: string[],
|
||||
): Promise<string> {
|
||||
if (action.conditionalMessages && action.conditionalMessages.length > 0) {
|
||||
const matchingConditional = findMatchingVariant(
|
||||
action.conditionalMessages,
|
||||
selectedActionIds,
|
||||
allValidActionIds,
|
||||
)
|
||||
if (matchingConditional) {
|
||||
return (await matchingConditional.message()) as string
|
||||
}
|
||||
}
|
||||
|
||||
return (await action.message()) as string
|
||||
}
|
||||
|
||||
export function getVisibleInputs(
|
||||
action: Action,
|
||||
actionStates: Record<string, ActionState>,
|
||||
): AdditionalTextInput[] {
|
||||
if (!action.relevantExtraInput) return []
|
||||
|
||||
const selectedActionIds = Object.entries(actionStates)
|
||||
.filter(([, state]) => state.selected)
|
||||
.map(([id]) => id)
|
||||
|
||||
return action.relevantExtraInput.filter((input) => {
|
||||
if (!input.showWhen) return true
|
||||
|
||||
const meetsRequired =
|
||||
!input.showWhen.requiredActions ||
|
||||
input.showWhen.requiredActions.every((id) => selectedActionIds.includes(id))
|
||||
|
||||
const meetsExcluded =
|
||||
!input.showWhen.excludedActions ||
|
||||
!input.showWhen.excludedActions.some((id) => selectedActionIds.includes(id))
|
||||
|
||||
return meetsRequired && meetsExcluded
|
||||
})
|
||||
}
|
||||
|
||||
export function expandVariables(
|
||||
template: string,
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
@@ -283,35 +107,6 @@ export function formatProjectTypes(type: string, lower: boolean = false) {
|
||||
return value
|
||||
}
|
||||
|
||||
export function formatEnvironments(environment: string, lower: boolean = false) {
|
||||
let value = environment
|
||||
try {
|
||||
value = value
|
||||
.replaceAll('client_only_server_optional', 'Client and server: Optional on server')
|
||||
.replaceAll('client_and_server', 'Required on both')
|
||||
.replaceAll('client_only', 'Client-side only')
|
||||
.replaceAll('server_only_client_optional', 'Client optional')
|
||||
.replaceAll('dedicated_server_only', 'Dedicated server only')
|
||||
.replaceAll('server_only', 'Servers and Singleplayer')
|
||||
.replaceAll('singleplayer_only', 'Singleplayer only')
|
||||
.replaceAll(
|
||||
'client_or_server_prefers_both',
|
||||
'Optional on both, works best when installed on both sides',
|
||||
)
|
||||
.replaceAll(
|
||||
'client_or_server',
|
||||
'Optional on both, works the same if installed on either side',
|
||||
)
|
||||
// This shouldn't come up for this use but yk
|
||||
.replaceAll('unknown', 'Unknown')
|
||||
} catch {
|
||||
return 'No project environment'
|
||||
}
|
||||
|
||||
if (lower === true) value = value.toLowerCase()
|
||||
return value
|
||||
}
|
||||
|
||||
export function requiresEnvironmentInfo(projectTypes): boolean {
|
||||
return projectTypes.includes('mod') || projectTypes.includes('modpack')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user