diff options
| -rw-r--r-- | components/base/ItemStack/ItemStackModal.vue | 5 | ||||
| -rw-r--r-- | components/base/Pulser.vue | 56 | ||||
| -rw-r--r-- | components/editor/EditorSidebarCategory.vue | 1 | ||||
| -rw-r--r-- | components/editor/EditorSidebarQuest.vue | 3 | ||||
| -rw-r--r-- | components/editor/quest/EditorQuestOptionsPanel.vue | 2 | ||||
| -rw-r--r-- | components/editor/task/EditorTaskConfiguration.vue | 2 | ||||
| -rw-r--r-- | components/editor/task/modal/EditorTaskModalCreate.vue | 1 | ||||
| -rw-r--r-- | components/header/SiteHeader.vue | 18 | ||||
| -rw-r--r-- | components/loader/LoaderFileSystemButton.vue | 40 | ||||
| -rw-r--r-- | components/loader/LoaderFileSystemModal.vue | 81 | ||||
| -rw-r--r-- | components/loader/LoaderTestDataButton.vue | 17 | ||||
| -rw-r--r-- | components/loader/LoaderTestDataModal.vue | 52 | ||||
| -rw-r--r-- | data/taskDefinitions.json | 4 | ||||
| -rw-r--r-- | layouts/editor.vue | 10 | ||||
| -rw-r--r-- | pages/category/[id].vue | 1 | ||||
| -rw-r--r-- | pages/item/[id].vue | 1 | ||||
| -rw-r--r-- | pages/quest/[id].vue | 1 | ||||
| -rw-r--r-- | stores/loader.ts | 55 | ||||
| -rw-r--r-- | stores/session.ts | 6 | ||||
| -rw-r--r-- | utils/loader.ts | 93 | ||||
| -rw-r--r-- | utils/util.ts (renamed from lib/util.ts) | 29 | ||||
| -rw-r--r-- | utils/validators.ts | 13 |
22 files changed, 456 insertions, 35 deletions
diff --git a/components/base/ItemStack/ItemStackModal.vue b/components/base/ItemStack/ItemStackModal.vue index 865c054..2a53926 100644 --- a/components/base/ItemStack/ItemStackModal.vue +++ b/components/base/ItemStack/ItemStackModal.vue @@ -47,7 +47,9 @@ const selectedQuestItem = computed({ }, set(newValue: string) { value.value = {} - value.value['quest-item'] = newValue; + if (newValue) { + value.value['quest-item'] = newValue; + } } }) const knownQuestItems = computed(() => { return session.session.items.map((item) => item.id) }); @@ -102,6 +104,7 @@ const confirm = () => { <div id="material" class="option-group" v-if="selectedType === 'material'"> <label for="material">Material</label> <multiselect v-model="value" :options="materials" :searchable="true" placeholder="Enter material name" /> + <p>Any items of this material will be matched.</p> </div> <div id="itemstack" class="option-group" v-if="selectedType === 'itemstack'"> diff --git a/components/base/Pulser.vue b/components/base/Pulser.vue new file mode 100644 index 0000000..796b3cc --- /dev/null +++ b/components/base/Pulser.vue @@ -0,0 +1,56 @@ +<template> + <div class="circles"> + <div class="circle1"></div> + <div class="circle2"></div> + <div class="circle3"></div> + </div> +</template> + +<style scoped> +.circles { + position: relative; + height: 100px; + width: 100px; + + >div { + animation: growAndFade 3s infinite ease-out; + background-color: var(--color-primary); + border-radius: 50%; + height: 100%; + opacity: 0; + position: absolute; + width: 100%; + } + + .circle1 { + animation-delay: 1s; + } + + .circle2 { + animation-delay: 2s; + } + + .circle3 { + animation-delay: 3s; + } +} + +@keyframes growAndFade { + 0% { + opacity: .25; + transform: scale(0); + } + + 100% { + opacity: 0; + transform: scale(1); + } +} + +body { + align-items: center; + display: flex; + justify-content: center; + margin: 0; +} +</style>
\ No newline at end of file diff --git a/components/editor/EditorSidebarCategory.vue b/components/editor/EditorSidebarCategory.vue index 9dedf33..cfabf39 100644 --- a/components/editor/EditorSidebarCategory.vue +++ b/components/editor/EditorSidebarCategory.vue @@ -1,7 +1,6 @@ <script setup lang="ts"> import { useSessionStore, type EditorCategory } from '@/stores/session'; import { computed, ref, toRefs } from 'vue'; -import { stripColorCodes } from '@/lib/util'; const props = defineProps<{ category: EditorCategory; diff --git a/components/editor/EditorSidebarQuest.vue b/components/editor/EditorSidebarQuest.vue index 422e8c6..85edb38 100644 --- a/components/editor/EditorSidebarQuest.vue +++ b/components/editor/EditorSidebarQuest.vue @@ -1,7 +1,6 @@ <script setup lang="ts"> -import { useSessionStore, type EditorQuest } from '@/stores/session'; +import type { EditorQuest } from '@/stores/session'; import { computed, toRefs } from 'vue'; -import { stripColorCodes } from '@/lib/util'; const props = defineProps<{ quest: EditorQuest; diff --git a/components/editor/quest/EditorQuestOptionsPanel.vue b/components/editor/quest/EditorQuestOptionsPanel.vue index 5aaff23..6c1c8b1 100644 --- a/components/editor/quest/EditorQuestOptionsPanel.vue +++ b/components/editor/quest/EditorQuestOptionsPanel.vue @@ -1,6 +1,6 @@ <script setup lang="ts"> import { useSessionStore, type EditorQuest } from '@/stores/session'; -import { computed, ref } from 'vue'; +import { computed } from 'vue'; const props = defineProps<{ questId: string; diff --git a/components/editor/task/EditorTaskConfiguration.vue b/components/editor/task/EditorTaskConfiguration.vue index bda75f4..5313767 100644 --- a/components/editor/task/EditorTaskConfiguration.vue +++ b/components/editor/task/EditorTaskConfiguration.vue @@ -32,7 +32,7 @@ const requiredFields = computed(() => { // }); const remainingGivenFields = computed(() => { - return Object.keys(taskConfig.value).filter((fieldName) => !requiredFields.value.includes(fieldName)); + return Object.keys(taskConfig.value).filter((fieldName) => !requiredFields.value.includes(fieldName) && fieldName in taskDefintion.value.configuration); }); const configKeysOptions = computed(() => Object.keys(taskDefintion.value.configuration).filter((key) => !Object.keys(taskConfig.value).some((fieldName) => fieldName === key))); diff --git a/components/editor/task/modal/EditorTaskModalCreate.vue b/components/editor/task/modal/EditorTaskModalCreate.vue index 30e4cca..e5b2d7a 100644 --- a/components/editor/task/modal/EditorTaskModalCreate.vue +++ b/components/editor/task/modal/EditorTaskModalCreate.vue @@ -1,7 +1,6 @@ <script setup lang="ts"> import { computed, ref } from 'vue'; import { useSessionStore } from '@/stores/session'; -import { validateTaskId } from '@/lib/util'; const model = defineModel(); diff --git a/components/header/SiteHeader.vue b/components/header/SiteHeader.vue index 0773a03..75686d8 100644 --- a/components/header/SiteHeader.vue +++ b/components/header/SiteHeader.vue @@ -1,3 +1,6 @@ +<script setup land="ts"> +</script> + <template> <header> <div id="nav"> @@ -5,6 +8,11 @@ <h1>Quests Web Editor</h1> <code>Preview</code> </div> + + <div id="controls"> + <LoaderTestDataButton /> + <LoaderFileSystemButton /> + </div> </header> </template> @@ -36,11 +44,21 @@ } +#controls { + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; +} + header { border-bottom: 1px solid var(--color-border); background-color: var(--color-header); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); position: relative; z-index: 1; + display: flex; + justify-content: space-between; } </style>
\ No newline at end of file diff --git a/components/loader/LoaderFileSystemButton.vue b/components/loader/LoaderFileSystemButton.vue new file mode 100644 index 0000000..0d02477 --- /dev/null +++ b/components/loader/LoaderFileSystemButton.vue @@ -0,0 +1,40 @@ +<script setup lang="ts"> +import type LoaderFileSystemModal from './LoaderFileSystemModal.vue'; + +const loaderStore = useLoaderStore(); + +const { canUseFsApi } = getBrowserCapabilities(); + +const fileSystemModal = ref<InstanceType<typeof LoaderFileSystemModal> | null>(null); + +const openFileSystemPrompt = async () => { + fileSystemModal.value?.open(); + loaderStore.setFileSystemLoaderStatus('pending'); + const dirHandle = await openFileSystem(); + if (!dirHandle) { + loaderStore.setFileSystemLoaderStatus('inactive'); + return; + } + loaderStore.setPath(dirHandle.name); + loaderStore.setFileSystemLoaderStatus('loaded'); + try { + const { categories, quests, items } = await enumerateQuestDirectory(dirHandle); + loaderStore.setCategories(categories); + loaderStore.setQuests(quests); + loaderStore.setItems(items); + loaderStore.setFileSystemLoaderStatus('valid'); + } catch (e) { + console.error(e); + loaderStore.setFileSystemLoaderStatus('invalid'); + } +} +</script> + +<template> + <ClientOnly> + <Button type="solid" :icon="['fas', 'folder-open']" label="Import from Filesystem" @click="openFileSystemPrompt" + :disabled="!canUseFsApi" /> + + <LoaderFileSystemModal ref="fileSystemModal" /> + </ClientOnly> +</template>
\ No newline at end of file diff --git a/components/loader/LoaderFileSystemModal.vue b/components/loader/LoaderFileSystemModal.vue new file mode 100644 index 0000000..27f4e12 --- /dev/null +++ b/components/loader/LoaderFileSystemModal.vue @@ -0,0 +1,81 @@ +<script setup lang="ts"> +const loader = useLoaderStore(); +const session = useSessionStore(); + +const showModal = ref(false); + +const open = () => { + showModal.value = true; +} + +const confirm = () => { + const categories = loader.getCategories(); + const quests = loader.getQuests(); + const items = loader.getItems(); + + session.setCategories(categories); + session.setQuests(quests); + session.setItems(items); + + showModal.value = false; +} + +const status = computed(() => loader.getFileSystemLoaderStatus()); +const questsCount = computed(() => loader.getQuests().length); +const categoriesCount = computed(() => loader.getCategories().length); +const itemsCount = computed(() => loader.getItems().length); +const path = computed(() => loader.getPath()); + +defineExpose({ + open +}) +</script> + +<template> + <Modal v-model="showModal"> + <template v-slot:header> + <h2>Import from Filesystem</h2> + </template> + + <div v-if="status === 'pending'"> + <p>Select the Quests plugin data directory.</p> + <p>Waiting for selection...</p> + </div> + + <div v-if="status === 'inactive'"> + <p>The request was aborted.</p> + </div> + + <div v-if="status === 'loaded'"> + <p>Parsing files in directory <code>{{ path }}</code>...</p> + </div> + + <div v-if="status === 'invalid'"> + <p>You have selected an invalid directory.</p> + </div> + + <div v-if="status === 'valid'"> + <p>Successfully parsed directory <code>{{ path }}</code>.</p> + <ul> + <li>{{ categoriesCount }} categories loaded</li> + <li>{{ questsCount }} quests loaded</li> + <li>{{ itemsCount }} items loaded</li> + </ul> + <p>You are about to replace your current workspace. Are you sure you want to continue?</p> + </div> + + <div id="controls" class="control-group"> + <Button :icon="['fas', 'xmark']" :label="'Cancel'" @click="showModal = false"></Button> + <Button v-if="status === 'valid'" type="solid" :icon="['fas', 'check']" :label="'Confirm'" + @click="confirm"></Button> + </div> + </Modal> +</template> + +<style scoped> +#controls { + display: flex; + justify-content: flex-end; + margin-top: 1rem; +} +</style>
\ No newline at end of file diff --git a/components/loader/LoaderTestDataButton.vue b/components/loader/LoaderTestDataButton.vue new file mode 100644 index 0000000..b01b0ee --- /dev/null +++ b/components/loader/LoaderTestDataButton.vue @@ -0,0 +1,17 @@ +<script setup lang="ts"> +import type LoaderTestDataModal from './LoaderTestDataModal.vue'; + +const testDataModal = ref<InstanceType<typeof LoaderTestDataModal> | null>(null); + +const openTestDataModal = async () => { + testDataModal.value?.open(); +} +</script> + +<template> + <ClientOnly> + <Button :icon="['fas', 'flask-vial']" label="Demo" @click="openTestDataModal" /> + + <LoaderTestDataModal ref="testDataModal" /> + </ClientOnly> +</template>
\ No newline at end of file diff --git a/components/loader/LoaderTestDataModal.vue b/components/loader/LoaderTestDataModal.vue new file mode 100644 index 0000000..36d2d6d --- /dev/null +++ b/components/loader/LoaderTestDataModal.vue @@ -0,0 +1,52 @@ +<script setup lang="ts"> +import { loadCategoriesFromJson, loadItemsFromJson, loadQuestsFromJson } from '~/lib/questsLoader'; +import testData from '@/data/testData.json'; + +const session = useSessionStore(); + +const showModal = ref(false); + +const open = () => { + showModal.value = true; +} + +const confirm = () => { + const quests = loadQuestsFromJson(testData.quests); + const categories = loadCategoriesFromJson(testData.categories); + const items = loadItemsFromJson(testData.items); + + session.setQuests(quests); + session.setCategories(categories); + session.setItems(items); + + showModal.value = false; +} + +defineExpose({ + open +}) +</script> + +<template> + <Modal v-model="showModal"> + <template v-slot:header> + <h2>Import test data</h2> + </template> + + <p>You can view a demo of the Quests editor by loading test data. This will replace your current workspace. + Do you want to continue?</p> + + <div id="controls" class="control-group"> + <Button :icon="['fas', 'xmark']" :label="'Cancel'" @click="showModal = false"></Button> + <Button type="solid" :icon="['fas', 'check']" :label="'Confirm'" @click="confirm"></Button> + </div> + </Modal> +</template> + +<style scoped> +#controls { + display: flex; + justify-content: flex-end; + margin-top: 1rem; +} +</style>
\ No newline at end of file diff --git a/data/taskDefinitions.json b/data/taskDefinitions.json index 92b728f..c8dce80 100644 --- a/data/taskDefinitions.json +++ b/data/taskDefinitions.json @@ -1,4 +1,8 @@ { + "aliases": { + "blockbreakcertain": "blockbreak", + "blockplacecertain": "blockplace" + }, "taskTypes": { "blockbreak": { "description": "Break a set amount of blocks.", diff --git a/layouts/editor.vue b/layouts/editor.vue index 7275651..d37a285 100644 --- a/layouts/editor.vue +++ b/layouts/editor.vue @@ -1,19 +1,11 @@ <script setup lang="ts"> import { useSessionStore } from '@/stores/session'; -import { loadQuestsFromJson, loadCategoriesFromJson, loadItemsFromJson } from '@/lib/questsLoader'; -import testData from '@/data/testData.json'; import taskDefinitions from '@/data/taskDefinitions.json'; const sessionStore = useSessionStore(); -const quests = loadQuestsFromJson(testData.quests); -const categories = loadCategoriesFromJson(testData.categories); -const items = loadItemsFromJson(testData.items); - -sessionStore.setQuests(quests); -sessionStore.setCategories(categories); -sessionStore.setItems(items); sessionStore.setTaskDefinitions(taskDefinitions.taskTypes); +sessionStore.setTaskTypeAliases(taskDefinitions.aliases); // sessionStore.updateEditorCategories(); </script> diff --git a/pages/category/[id].vue b/pages/category/[id].vue index da68bb5..3189d2a 100644 --- a/pages/category/[id].vue +++ b/pages/category/[id].vue @@ -1,6 +1,5 @@ <script setup lang="ts"> import { useSessionStore } from '@/stores/session'; -import { stripColorCodes } from '@/lib/util'; definePageMeta({ layout: 'editor' diff --git a/pages/item/[id].vue b/pages/item/[id].vue index f1dbb95..c2456be 100644 --- a/pages/item/[id].vue +++ b/pages/item/[id].vue @@ -1,6 +1,5 @@ <script setup lang="ts"> import { useSessionStore } from '@/stores/session'; -import { stripColorCodes } from '@/lib/util'; definePageMeta({ layout: 'editor' diff --git a/pages/quest/[id].vue b/pages/quest/[id].vue index 080f232..bc480d9 100644 --- a/pages/quest/[id].vue +++ b/pages/quest/[id].vue @@ -1,7 +1,6 @@ <script setup lang="ts"> import { useSessionStore } from '@/stores/session'; import { computed, ref } from 'vue'; -import { navigateToEditorPane, stripColorCodes } from '@/lib/util'; import type EditorQuestModalYaml from '~/components/editor/quest/modal/EditorQuestModalYaml.vue'; definePageMeta({ diff --git a/stores/loader.ts b/stores/loader.ts new file mode 100644 index 0000000..fe3742c --- /dev/null +++ b/stores/loader.ts @@ -0,0 +1,55 @@ +import { defineStore } from 'pinia' +import type { EditorCategory, EditorItem } from './session'; + +export type FileSystemLoaderStatus = 'inactive' | 'pending' | 'loaded' | 'invalid' | 'valid'; + +export const useLoaderStore = defineStore('loader', { + state: () => ({ + fileSystem: { + status: 'inactive' as FileSystemLoaderStatus, + path: '' as string, + quests: [] as EditorQuest[], + categories: [] as EditorCategory[], + items: [] as EditorItem[], + } + }), + getters: { + getFileSystemLoaderStatus: (state) => () => { + return state.fileSystem.status; + }, + getPath: (state) => () => { + return state.fileSystem.path; + }, + getQuests: (state) => () => { + return state.fileSystem.quests; + }, + getCategories: (state) => () => { + return state.fileSystem.categories; + }, + getItems: (state) => () => { + return state.fileSystem.items; + }, + }, + actions: { + setFileSystemLoaderStatus(status: FileSystemLoaderStatus) { + this.fileSystem.status = status; + if (status === 'inactive' || status === 'pending') { + this.fileSystem.quests = []; + this.fileSystem.categories = []; + this.fileSystem.items = []; + } + }, + setPath(path: string) { + this.fileSystem.path = path; + }, + setQuests(quests: EditorQuest[]) { + this.fileSystem.quests = quests; + }, + setCategories(categories: EditorCategory[]) { + this.fileSystem.categories = categories; + }, + setItems(items: EditorItem[]) { + this.fileSystem.items = items; + } + } +}); diff --git a/stores/session.ts b/stores/session.ts index 7822547..40587e9 100644 --- a/stores/session.ts +++ b/stores/session.ts @@ -101,6 +101,7 @@ export const useSessionStore = defineStore('session', { categories: [] as EditorCategory[], items: [] as EditorItem[], taskDefinitions: {} as { [key: string]: TaskDefinition }, + taskTypeAliases: {} as { [key: string]: string }, questItemDefinitions: {} as { [key: string]: QuestItemDefinition } } }), @@ -137,7 +138,7 @@ export const useSessionStore = defineStore('session', { return state.session.taskDefinitions }, getTaskDefinitionByTaskType: (state) => (type: string) => { - return state.session.taskDefinitions[type] + return state.session.taskDefinitions[type] || state.session.taskDefinitions[state.session.taskTypeAliases[type]] }, getKnownTaskTypes: (state) => () => { return Object.keys(state.session.taskDefinitions) @@ -168,6 +169,9 @@ export const useSessionStore = defineStore('session', { setTaskDefinitions(definitions: { [key: string]: TaskDefinition }) { this.session.taskDefinitions = definitions; }, + setTaskTypeAliases(taskTypeAliases: { [key: string]: string }) { + this.session.taskTypeAliases = taskTypeAliases; + }, setQuestItemDefinitions(definitions: { [key: string]: QuestItemDefinition }) { this.session.questItemDefinitions = definitions; }, diff --git a/utils/loader.ts b/utils/loader.ts new file mode 100644 index 0000000..56e29fa --- /dev/null +++ b/utils/loader.ts @@ -0,0 +1,93 @@ +import { parse } from "yaml"; +import { loadCategoriesFromJson, loadItemsFromJson, loadQuestsFromJson } from "~/lib/questsLoader"; + +export async function openFileSystem() { + try { + return await (window as any).showDirectoryPicker(); + } catch (e) { + return undefined; + } +} + +export async function enumerateQuestDirectory(dirHandle: any) { + let configFile: any = null; + let categoryFile: any = null; + let questsDirectory: any = null; + let itemsDirectory: any = null; + + for await (const [name, handle] of dirHandle) { + if (name === 'quests' && handle.kind === 'directory') { + questsDirectory = handle; + } else if (name === 'items' && handle.kind === 'directory') { + itemsDirectory = handle; + } else if (name === 'config.yml' && handle.kind === 'file') { + configFile = handle; + } else if (name === 'categories.yml' && handle.kind === 'file') { + categoryFile = handle; + } + } + + if (!configFile) { + throw Error('invalid quest directory'); + } + + const [questFiles, itemFiles] = await Promise.all([questsDirectory ? listAllFilesAndDirs(questsDirectory) : [], itemsDirectory ? listAllFilesAndDirs(itemsDirectory) : []]); + const [categories, quests, items] = await Promise.all([(async () => { + if (!categoryFile) { + return []; + } + + const file: any = await categoryFile.getFile(); + const text: string = await file.text(); + const parsedYaml: any = parse(text); + + return loadCategoriesFromJson(parsedYaml.categories); + })(), + (async () => { + if (!questFiles) { + return []; + } + + const allQuests = await Promise.all(questFiles.filter(({ name, handle, kind }) => name.endsWith('.yml')).map(async ({ name, handle, kind }) => { + const file: any = await handle.getFile(); + const text: string = await file.text(); + return [ + name.replace('.yml', ''), + parse(text) + ]; + })) + + return loadQuestsFromJson(Object.fromEntries(allQuests)); + })(), + (async () => { + if (!itemFiles) { + return []; + } + + const allItems = await Promise.all(itemFiles.filter(({ name, handle, kind }) => name.endsWith('.yml')).map(async ({ name, handle, kind }) => { + const file: any = await handle.getFile(); + const text: string = await file.text(); + return [ + name.replace('.yml', ''), + parse(text) + ]; + })) + + return loadItemsFromJson(Object.fromEntries(allItems)); + })()]); + + return { categories, quests, items }; +} + +async function listAllFilesAndDirs(dirHandle: any): Promise<any[]> { + const files = []; + for await (const [name, handle] of dirHandle) { + const { kind } = handle; + if (handle.kind === 'directory') { + files.push(...await listAllFilesAndDirs(handle)); + } else { + files.push({ name, handle, kind }); + } + } + return files; +}
\ No newline at end of file diff --git a/lib/util.ts b/utils/util.ts index 3a7e9aa..cc500e0 100644 --- a/lib/util.ts +++ b/utils/util.ts @@ -1,30 +1,29 @@ -const COLOR_CODE_REGEX = /&[0-9a-fk-or]/i; -const VALID_ID_REGEX = /^[a-z0-9_]+$/i; +const COLOR_CODE_REGEX = /&([0-9a-fk-or]|#[0-9A-F]{6})/ig; export function stripColorCodes(str: string): string { return str.replace(COLOR_CODE_REGEX, ''); } -export function validateQuestId(id: string): boolean { - return VALID_ID_REGEX.test(id); -} - -export function validateCategoryId(id: string): boolean { - return VALID_ID_REGEX.test(id); -} - -export function validateTaskId(id: string): boolean { - return VALID_ID_REGEX.test(id); -} - -export function navigateToEditorPane(type: 'quest' | 'category' | null, id?: string) { +export function navigateToEditorPane(type: 'quest' | 'category' | 'item' | null, id?: string) { if (id) { if (type === 'category') { navigateTo({ path: '/category/' + id }) } else if (type === 'quest') { navigateTo({ path: '/quest/' + id }) + } else if (type === 'item') { + navigateTo({ path: '/item/' + id }) } } else if (!id && !type) { navigateTo({ path: '/' }) } +} + +export type BrowserCapabilities = { + canUseFsApi: boolean +} + +export function getBrowserCapabilities(): BrowserCapabilities { + return { + canUseFsApi: typeof (window as any)?.showDirectoryPicker === 'function' + } }
\ No newline at end of file diff --git a/utils/validators.ts b/utils/validators.ts new file mode 100644 index 0000000..5bf259a --- /dev/null +++ b/utils/validators.ts @@ -0,0 +1,13 @@ +const VALID_ID_REGEX = /^[a-z0-9_]+$/i; + +export function validateQuestId(id: string): boolean { + return VALID_ID_REGEX.test(id); +} + +export function validateCategoryId(id: string): boolean { + return VALID_ID_REGEX.test(id); +} + +export function validateTaskId(id: string): boolean { + return VALID_ID_REGEX.test(id); +} |
