(nouns) submit regular forms (only stems and class) and display them in the dictionary

This commit is contained in:
Valentyne Stigloher 2025-07-17 21:05:03 +02:00
parent 23dcc1a454
commit 301a496f30
18 changed files with 466 additions and 63 deletions

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
import { Noun } from '~/src/classes.ts';
import type { NounRawWithLoadedWords } from '~/src/classes.ts';
import { loadNounsData } from '~/src/data.ts';
import { availableGenders, resolveWordsFromClassInstance } from '~/src/nouns.ts';
import type { NounsData, NounClassInstance } from '~/src/nouns.ts';
const props = defineProps<{
stemValues: Record<string, string>;
}>();
const modelValue = defineModel<keyof Required<NounsData>['classes'] | undefined>();
const config = useConfig();
const nounsData = await loadNounsData();
const nouns = computed((): Noun[] => {
return withKey(nounsData.classes ?? {})
.map((nounClass): NounClassInstance => ({ classKey: nounClass.key, stems: props.stemValues }))
.map((classInstance, i): NounRawWithLoadedWords => ({
id: `template-${i}`,
words: resolveWordsFromClassInstance(classInstance, nounsData),
classInstance,
}))
.filter((nounRaw) => Object.keys(nounRaw.words).length > 0)
.map((nounRaw) => new Noun(config, nounRaw));
});
const edit = ref(modelValue.value === undefined);
watch(modelValue, () => {
edit.value = false;
});
const visibleNouns = computed((): Noun[] => {
if (edit.value || modelValue.value === undefined) {
return nouns.value;
}
return nouns.value.filter((noun) => noun.classInstance!.classKey === modelValue.value);
});
</script>
<template>
<button v-if="!edit" type="button" class="btn btn-outline-primary" @click="edit = true">
<Icon v="exchange" />
<T>nouns.submit.regular.class.change</T>
</button>
<Table
ref="table"
:data="visibleNouns"
fixed
:marked="noun => modelValue === noun.classInstance!.classKey"
:class="['nouns-table mt-0', config.nouns.nonbinary ? 'nouns-table-nonbinary' : '']"
>
<template #header>
<div v-for="gender in availableGenders(config)" :key="gender" class="d-none d-md-block bold">
<NounsGenderLabel :gender="gender" />
</div>
</template>
<template #row="{ el: noun }">
<NounsTableEntry :noun @click="modelValue = noun.classInstance!.classKey">
<template #buttons>
<ul v-if="edit" class="list-unstyled list-btn-concise">
<li>
<button
type="button"
class="btn btn-concise btn-outline-primary btn-sm"
@click="modelValue = noun.classInstance!.classKey"
>
<Icon v="check" />
<span class="btn-label">
<T>nouns.submit.regular.class.apply</T>
</span>
</button>
</li>
</ul>
</template>
</NounsTableEntry>
</template>
<template #empty>
<Icon v="search" />
<T>nouns.empty</T>
</template>
</Table>
</template>

View File

@ -3,7 +3,7 @@ import type NounsSubmitForm from '~/components/nouns/NounsSubmitForm.vue';
import { filterWordsForConvention, loadWords, Noun } from '~/src/classes.ts'; import { filterWordsForConvention, loadWords, Noun } from '~/src/classes.ts';
import type { Filter } from '~/src/classes.ts'; import type { Filter } from '~/src/classes.ts';
import { loadNounsData } from '~/src/data.ts'; import { loadNounsData } from '~/src/data.ts';
import { availableGenders, buildNounDeclensionsByFirstCase } from '~/src/nouns.ts'; import { addWordsFromClassInstance, availableGenders, buildNounDeclensionsByFirstCase } from '~/src/nouns.ts';
import type { NounConvention } from '~/src/nouns.ts'; import type { NounConvention } from '~/src/nouns.ts';
const props = defineProps<{ const props = defineProps<{
@ -33,6 +33,13 @@ const nounsAsyncData = useAsyncData(
const collator = Intl.Collator(config.locale); const collator = Intl.Collator(config.locale);
return Object.fromEntries((await $fetch('/api/nouns')) return Object.fromEntries((await $fetch('/api/nouns'))
.map((nounRaw) => loadWords(nounRaw, config, declensionsByFirstCase)) .map((nounRaw) => loadWords(nounRaw, config, declensionsByFirstCase))
.map((nounRaw) => {
if (!nounRaw.classInstance) {
return nounRaw;
}
const words = addWordsFromClassInstance(nounRaw.words, nounRaw.classInstance, nounsData);
return { ...nounRaw, words };
})
.map((nounRaw) => filterWordsForConvention(nounRaw, props.nounConvention)) .map((nounRaw) => filterWordsForConvention(nounRaw, props.nounConvention))
.filter((nounRaw) => nounRaw !== undefined) .filter((nounRaw) => nounRaw !== undefined)
.map((nounRaw) => new Noun(config, nounRaw)) .map((nounRaw) => new Noun(config, nounRaw))

View File

@ -0,0 +1,55 @@
<script setup lang="ts">
import { availableGenders, availableNumeri, symbolsByNumeri } from '~/src/nouns.ts';
import type { Gender, NounWord, Numerus } from '~/src/nouns.ts';
const modelValue = defineModel<Record<Gender, Record<Numerus, NounWord[]>>>({ required: true });
const config = useConfig();
const editDeclensions = ref(false);
watch(modelValue, () => {
editDeclensions.value = !!config.nouns.declension?.enabled && Object.values(modelValue.value)
.flatMap((wordsOfNumerus) => Object.values(wordsOfNumerus))
.flatMap((words) => words)
.some((word) => word.declension);
});
const canRemoveWord = (gender: Gender, numerus: Numerus): boolean => {
if (config.nouns.conventions?.enabled) {
return true;
}
if (numerus === 'plural' && !config.nouns.pluralsRequired) {
return true;
}
const wordsOfOtherGenderAndSameNumerus = availableGenders(config).filter((otherGender) => {
return otherGender !== gender && (modelValue.value[otherGender]?.[numerus] ?? []).length > 0;
});
if (wordsOfOtherGenderAndSameNumerus.length > 1) {
return true;
}
return (modelValue.value[gender]?.[numerus] ?? []).length > 1;
};
</script>
<template>
<div v-for="numerus of availableNumeri(config)" :key="numerus" class="row">
<div v-if="config.nouns.plurals" class="col-12 text-nowrap">
<label><strong>{{ symbolsByNumeri[numerus] }} <T>nouns.{{ numerus }}</T></strong></label>
</div>
<div v-for="gender in availableGenders(config)" :key="gender" class="col-12 col-md-6 my-2">
<label><strong><NounsGenderLabel :gender="gender" /></strong></label>
<NounsWordsInput
v-model="modelValue[gender][numerus]"
:edit-declensions
:minitems="canRemoveWord(gender, numerus) ? 0 : 1"
/>
</div>
</div>
<div v-if="config.nouns.declension?.enabled" class="form-check form-switch my-2">
<label>
<input v-model="editDeclensions" class="form-check-input" type="checkbox" role="switch">
<T>nouns.declension.edit</T>
</label>
</div>
</template>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { loadNounsData } from '~/src/data.ts';
import type { NounClassInstance, NounsData } from '~/src/nouns.ts';
const modelValue = defineModel<NounClassInstance | null>({ required: true });
const stemValues = ref<NounClassInstance['stems']>({});
const classKey = ref<keyof Required<NounsData>['classes']>();
watch(modelValue, () => {
stemValues.value = modelValue.value?.stems ?? {};
classKey.value = modelValue.value?.classKey;
});
const hasStems = computed(() => {
return Object.values(stemValues.value).some((stem) => stem);
});
watch([classKey, hasStems], () => {
if (hasStems.value && classKey.value !== undefined) {
modelValue.value = { classKey: classKey.value, stems: stemValues.value };
}
});
const nounsData = await loadNounsData();
const id = useId();
</script>
<template>
<h5><T>nouns.submit.regular.stems.header</T></h5>
<p><T>nouns.submit.regular.stems.description</T></p>
<template v-for="stem of withKey(nounsData.stems ?? {})" :key="stem.key">
<label :for="`${id}-stem-${stem.key}`">{{ stem.name }}</label>
<input
:id="`${id}-stem-${stem.key}`"
v-model="stemValues[stem.key]"
type="text"
class="form-control form-control-sm"
:class="stem.key === 'default' ? 'border-primary' : ''"
:placeholder="stem.example"
>
</template>
<template v-if="hasStems">
<h5 class="mt-2 mb-0">
<T>nouns.submit.regular.class.header</T>
</h5>
<p>
<T>nouns.submit.regular.class.description</T>
</p>
<NounsClassSelectTable v-model="classKey" :stem-values />
</template>
</template>

View File

@ -3,11 +3,12 @@ import type { Config } from '~/locale/config.ts';
import type { Noun, NounRaw } from '~/src/classes.ts'; import type { Noun, NounRaw } from '~/src/classes.ts';
import { loadNounAbbreviations } from '~/src/data.ts'; import { loadNounAbbreviations } from '~/src/data.ts';
import { fromUnionEntries } from '~/src/helpers.ts'; import { fromUnionEntries } from '~/src/helpers.ts';
import { availableGenders, availableNumeri, genders, symbolsByNumeri } from '~/src/nouns.ts'; import { filterIrregularWords, genders } from '~/src/nouns.ts';
import type { Numerus, Gender, NounWord, NounWords } from '~/src/nouns.ts'; import type { NounClassInstance, Numerus, Gender, NounWord, NounWords } from '~/src/nouns.ts';
interface NounFormValue extends Omit<NounRaw, 'id' | 'words'> { interface NounFormValue extends Omit<NounRaw, 'id' | 'words'> {
words: Record<Gender, Record<Numerus, NounWord[]>>; words: Record<Gender, Record<Numerus, NounWord[]>>;
classInstance: NounClassInstance | null;
categories: NonNullable<NounRaw['categories']>; categories: NonNullable<NounRaw['categories']>;
} }
@ -15,10 +16,11 @@ const emptyForm = (config: Config): NounFormValue => {
return { return {
words: fromUnionEntries(genders.map((gender) => { words: fromUnionEntries(genders.map((gender) => {
return [gender, { return [gender, {
singular: [{ spelling: '' }], singular: !config.nouns.conventions?.enabled ? [{ spelling: '' }] : [],
plural: config.nouns.pluralsRequired ? [{ spelling: '' }] : [], plural: config.nouns.pluralsRequired ? [{ spelling: '' }] : [],
}]; }];
})), })),
classInstance: null,
categories: [], categories: [],
sources: [], sources: [],
base: null, base: null,
@ -46,8 +48,6 @@ const templateFilterInput = useTemplateRef<HTMLInputElement>('templateFilterInpu
const form = ref(emptyForm(config)); const form = ref(emptyForm(config));
const editDeclensions = ref(false);
const submitting = ref(false); const submitting = ref(false);
const afterSubmit = ref(false); const afterSubmit = ref(false);
@ -55,19 +55,6 @@ const templateBase = ref('');
const templateFilter = ref(''); const templateFilter = ref('');
const templateVisible = ref(false); const templateVisible = ref(false);
const canRemoveWord = (gender: Gender, numerus: Numerus): boolean => {
if (numerus === 'plural' && !config.nouns.pluralsRequired) {
return true;
}
const wordsOfOtherGenderAndSameNumerus = availableGenders(config).filter((otherGender) => {
return otherGender !== gender && (form.value.words[otherGender]?.[numerus] ?? []).length > 0;
});
if (wordsOfOtherGenderAndSameNumerus.length > 1) {
return true;
}
return (form.value.words[gender]?.[numerus] ?? []).length > 1;
};
const dialogue = useDialogue(); const dialogue = useDialogue();
const applyTemplate = async (template: Noun): Promise<void> => { const applyTemplate = async (template: Noun): Promise<void> => {
if (JSON.stringify(form.value) !== JSON.stringify(emptyForm(config))) { if (JSON.stringify(form.value) !== JSON.stringify(emptyForm(config))) {
@ -75,6 +62,7 @@ const applyTemplate = async (template: Noun): Promise<void> => {
} }
form.value = { form.value = {
words: fillMissingVariants(template.words), words: fillMissingVariants(template.words),
classInstance: null,
categories: template.categories ?? [], categories: template.categories ?? [],
sources: template.sources, sources: template.sources,
base: template.id, base: template.id,
@ -100,15 +88,12 @@ const submit = async () => {
}; };
const edit = (noun: Noun): void => { const edit = (noun: Noun): void => {
form.value = { form.value = {
words: fillMissingVariants(noun.words), words: fillMissingVariants(filterIrregularWords(noun.words)),
classInstance: noun.classInstance,
categories: noun.categories, categories: noun.categories,
sources: noun.sources, sources: noun.sources,
base: noun.id, base: noun.id,
}; };
editDeclensions.value = !!config.nouns.declension?.enabled && Object.values(noun.words)
.flatMap((wordsOfNumerus) => Object.values(wordsOfNumerus))
.flatMap((words) => words)
.some((word) => word.declension);
focus(); focus();
}; };
const focus = (editable = true): void => { const focus = (editable = true): void => {
@ -138,25 +123,23 @@ const { data: sourcesKeys } = await useFetch('/api/sources/keys', { lazy: true,
</p> </p>
</div> </div>
<form v-else @submit.prevent="submit"> <form v-else @submit.prevent="submit">
<div v-for="numerus of availableNumeri(config)" :key="numerus" class="row"> <template v-if="config.nouns.conventions?.enabled">
<div v-if="config.nouns.plurals" class="col-12 text-nowrap mt-md-4"> <h5 class="border mb-0 p-3 bg-light">
<label><strong>{{ symbolsByNumeri[numerus] }} <T>nouns.{{ numerus }}</T></strong></label> <Icon v="bars" />
<T>nouns.submit.regular.header</T>
</h5>
<div class="p-3 border border-top-0 mb-3">
<NounsRegularWordsSubform v-model="form.classInstance" class="mb-2" />
</div> </div>
<div v-for="gender in availableGenders(config)" :key="gender" class="col-12 col-md-6 mt-2"> <h5 class="border mb-0 p-3 bg-light">
<label><strong><NounsGenderLabel :gender="gender" /></strong></label> <Icon v="stream" />
<NounsWordsInput <T>nouns.submit.irregular.header</T>
v-model="form.words[gender][numerus]" </h5>
:edit-declensions <div class="p-3 border border-top-0 mb-3">
:minitems="canRemoveWord(gender, numerus) ? 0 : 1" <NounsIrregularWordsSubform v-model="form.words" />
/>
</div> </div>
</div> </template>
<div v-if="config.nouns.declension?.enabled" class="form-check form-switch mb-2"> <NounsIrregularWordsSubform v-else v-model="form.words" />
<label>
<input v-model="editDeclensions" class="form-check-input" type="checkbox" role="switch">
<T>nouns.declension.edit</T>
</label>
</div>
<CategoriesSelector <CategoriesSelector
v-model="form.categories" v-model="form.categories"
:label="$t('nouns.categories')" :label="$t('nouns.categories')"

View File

@ -21,7 +21,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
<template> <template>
<template v-for="gender in visibleGenders" :key="gender"> <template v-for="gender in visibleGenders" :key="gender">
<div class="d-md-none bold" :style="{ gridArea: `${gender}Label` }"> <div class="d-md-none bold" :style="{ gridArea: `${gender}Label` }" v-bind="$attrs">
<NounsGenderLabel :gender="gender" concise /> <NounsGenderLabel :gender="gender" concise />
</div> </div>
<div <div
@ -29,6 +29,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
:key="numerus" :key="numerus"
:style="{ gridArea: `${gender}${numerus === 'plural' ? 'Pl' : ''}` }" :style="{ gridArea: `${gender}${numerus === 'plural' ? 'Pl' : ''}` }"
role="cell" role="cell"
v-bind="$attrs"
> >
<NounsItem :noun :gender :numerus /> <NounsItem :noun :gender :numerus />
@ -41,7 +42,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
</small> </small>
</div> </div>
</template> </template>
<div v-if="noun.sourcesData?.length" style="grid-area: sources"> <div v-if="noun.sourcesData?.length" style="grid-area: sources" v-bind="$attrs">
<p><strong><T>sources.referenced</T><T>quotation.colon</T></strong></p> <p><strong><T>sources.referenced</T><T>quotation.colon</T></strong></p>
<ul class="list-unstyled mb-0"> <ul class="list-unstyled mb-0">
<li v-for="source in noun.sourcesData" :key="source.id"> <li v-for="source in noun.sourcesData" :key="source.id">
@ -49,7 +50,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
</li> </li>
</ul> </ul>
</div> </div>
<div style="grid-area: buttons"> <div style="grid-area: buttons" v-bind="$attrs">
<slot name="buttons"></slot> <slot name="buttons"></slot>
</div> </div>
</template> </template>

View File

@ -204,6 +204,22 @@ nouns:
submit: submit:
action: 'Submit' action: 'Submit'
actionLong: 'Submit a word' actionLong: 'Submit a word'
regular:
header: 'Regular forms'
stems:
header: 'Stems'
description: >
The common parts of word spellings are referred to as stems.
Most noun conventions can then be derived from a stem.
class:
header: 'Class'
description: >
The formation rules from a stem to a word in a noun convention is called a class here.
Select the appropriate class that derive the correct words.
apply: 'Select class'
change: 'Change class'
irregular:
header: 'Irregular forms'
thanks: 'Thank you for contributing!' thanks: 'Thank you for contributing!'
another: 'Submit another one' another: 'Submit another one'
moderation: 'Submissions will have to get approved before getting published.' moderation: 'Submissions will have to get approved before getting published.'

View File

@ -241,6 +241,16 @@ declensions:
g: ['ne'] g: ['ne']
d: ['ne'] d: ['ne']
a: ['nen'] a: ['nen']
stems:
default:
name: 'Hauptstamm'
example: 'Stamm vor der geschlechtsspezifischen Endung (Arbeit bei Arbeiter*in)'
flucht:
name: 'Fluchtsubstantiv'
example: 'Substantiv, welches die Tätigkeit beschreibt, ggf. inkl. Genitivpartikel (Expertise bei Expert*in)'
partizip:
name: 'Partizip'
example: 'Partizip ohne Endung ende (Studier bei Studierende)'
classes: classes:
t1: t1:
exampleStems: exampleStems:
@ -316,6 +326,7 @@ conventions:
maskulinum: maskulinum:
name: 'Maskulinum' name: 'Maskulinum'
normative: true normative: true
gender: 'masc'
morphemes: morphemes:
article_n: 'der' article_n: 'der'
article_g: 'des' article_g: 'des'
@ -353,6 +364,7 @@ conventions:
femininum: femininum:
name: 'Femininum' name: 'Femininum'
normative: true normative: true
gender: 'fem'
morphemes: morphemes:
article_n: 'die' article_n: 'die'
article_g: 'der' article_g: 'der'
@ -390,6 +402,7 @@ conventions:
partizip-formen: partizip-formen:
name: 'Partizip-Formen' name: 'Partizip-Formen'
normative: true normative: true
gender: 'neutr'
description: description:
- > - >
Aus dem Partizip I (infinite Verbform) lassen sich substantivierte Adjektive bilden. Aus dem Partizip I (infinite Verbform) lassen sich substantivierte Adjektive bilden.
@ -417,6 +430,7 @@ conventions:
person-formen: person-formen:
name: 'Person-Formen' name: 'Person-Formen'
normative: true normative: true
gender: 'neutr'
morphemes: morphemes:
article_n: 'die' article_n: 'die'
article_g: 'der' article_g: 'der'
@ -458,6 +472,7 @@ conventions:
mensch-formen: mensch-formen:
name: 'Mensch-Formen' name: 'Mensch-Formen'
normative: true normative: true
gender: 'neutr'
morphemes: morphemes:
article_n: 'der' article_n: 'der'
article_g: 'des' article_g: 'des'
@ -499,6 +514,7 @@ conventions:
diminuitiv: diminuitiv:
name: 'Diminuitiv' name: 'Diminuitiv'
normative: true normative: true
gender: 'neutr'
warning: > warning: >
Die Verniedlichungsform benutzt zwar das Neutrum und ist damit eine normativ neutrale Form, Die Verniedlichungsform benutzt zwar das Neutrum und ist damit eine normativ neutrale Form,
jedoch zeichnet diese Form üblicherweise kleine und junge Nomen aus. jedoch zeichnet diese Form üblicherweise kleine und junge Nomen aus.
@ -541,6 +557,7 @@ conventions:
y-formen: y-formen:
name: 'Y-Formen' name: 'Y-Formen'
normative: false normative: false
gender: 'neutr'
description: description:
- > - >
Bekannt als {https://www.bpb.de/shop/zeitschriften/apuz/geschlechtergerechte-sprache-2022/346085/entgendern-nach-phettberg/=Entgendern nach Phettberg}. Bekannt als {https://www.bpb.de/shop/zeitschriften/apuz/geschlechtergerechte-sprache-2022/346085/entgendern-nach-phettberg/=Entgendern nach Phettberg}.
@ -582,6 +599,7 @@ conventions:
i-formen: i-formen:
name: 'I-Formen' name: 'I-Formen'
normative: false normative: false
gender: 'neutr'
description: description:
- > - >
Verwendet u.a. in den Romanen Verwendet u.a. in den Romanen
@ -629,6 +647,7 @@ conventions:
inklusivum: inklusivum:
name: 'Inklusivum' name: 'Inklusivum'
normative: false normative: false
gender: 'neutr'
description: description:
- 'Formen vorgestellt vom {https://geschlechtsneutral.net/=Verein für geschlechtsneutrales Deutsch}.' - 'Formen vorgestellt vom {https://geschlechtsneutral.net/=Verein für geschlechtsneutrales Deutsch}.'
- 'Siehe auch: {/en/em=Neopronomen „en“}' - 'Siehe auch: {/en/em=Neopronomen „en“}'
@ -669,6 +688,7 @@ conventions:
indefinitivum: indefinitivum:
name: 'Indefinitivum' name: 'Indefinitivum'
normative: false normative: false
gender: 'neutr'
description: description:
- 'Formen vorgestellt von {https://www.geschlechtsneutral.com/lit/Liminalis-2008-Sylvain-Balzer.pdf=Cabala de Sylvain und Carsten Balzer}' - 'Formen vorgestellt von {https://www.geschlechtsneutral.com/lit/Liminalis-2008-Sylvain-Balzer.pdf=Cabala de Sylvain und Carsten Balzer}'
- 'Siehe auch: {/nin=Neopronomen „nin/nim“}.' - 'Siehe auch: {/nin=Neopronomen „nin/nim“}.'
@ -705,6 +725,7 @@ conventions:
ens-formen: ens-formen:
name: 'ens-Formen' name: 'ens-Formen'
normative: false normative: false
gender: 'neutr'
description: description:
- 'Formen vorgestellt von Lann Hornscheidt.' - 'Formen vorgestellt von Lann Hornscheidt.'
- 'Siehe auch: {/ens=Neopronomen „ens“}.' - 'Siehe auch: {/ens=Neopronomen „ens“}.'
@ -734,6 +755,7 @@ conventions:
ex-formen: ex-formen:
name: 'ex-Formen' name: 'ex-Formen'
normative: false normative: false
gender: 'neutr'
description: description:
- 'Formen vorgestellt von {https://www.lannhornscheidt.com/w_ortungen/nonbinare-w_ortungen/=Lann Hornscheidt und Lio Oppenländer}.' - 'Formen vorgestellt von {https://www.lannhornscheidt.com/w_ortungen/nonbinare-w_ortungen/=Lann Hornscheidt und Lio Oppenländer}.'
- 'Siehe auch: {/ex=Neopronomen „ex“}.' - 'Siehe auch: {/ex=Neopronomen „ex“}.'
@ -757,6 +779,7 @@ conventions:
ojum: ojum:
name: 'Ojum' name: 'Ojum'
normative: false normative: false
gender: 'neutr'
description: description:
- 'Formen vorgestellt von {https://www.frumble.de/blog/2021/03/26/ueberlegungen-zu-einer-genderneutralen-deutschen-grammatik=Frumble}.' - 'Formen vorgestellt von {https://www.frumble.de/blog/2021/03/26/ueberlegungen-zu-einer-genderneutralen-deutschen-grammatik=Frumble}.'
- 'Siehe auch: {/oj=Neopronomen „oj/ojm“}.' - 'Siehe auch: {/oj=Neopronomen „oj/ojm“}.'
@ -788,6 +811,7 @@ conventions:
nona-system: nona-system:
name: 'NoNa-System' name: 'NoNa-System'
normative: false normative: false
gender: 'neutr'
description: description:
- 'Formen vorgestellt von {https://geschlechtsneutralesdeutsch.com/=Geschlechtsneutrales Deutsch}.' - 'Formen vorgestellt von {https://geschlechtsneutralesdeutsch.com/=Geschlechtsneutrales Deutsch}.'
morphemes: morphemes:
@ -823,6 +847,7 @@ conventions:
genderdoppelpunkt: genderdoppelpunkt:
name: 'Genderdoppelpunkt' name: 'Genderdoppelpunkt'
normative: false normative: false
gender: 'neutr'
morphemes: morphemes:
article_n: 'der:die' article_n: 'der:die'
article_g: 'des:der' article_g: 'des:der'
@ -860,6 +885,7 @@ conventions:
gendergap: gendergap:
name: 'Gendergap' name: 'Gendergap'
normative: false normative: false
gender: 'neutr'
morphemes: morphemes:
article_n: 'der_die' article_n: 'der_die'
article_g: 'des_der' article_g: 'des_der'
@ -897,6 +923,7 @@ conventions:
gendersternchen: gendersternchen:
name: 'Gendersternchen' name: 'Gendersternchen'
normative: false normative: false
gender: 'neutr'
morphemes: morphemes:
article_n: 'der*die' article_n: 'der*die'
article_g: 'des*der' article_g: 'des*der'
@ -934,6 +961,7 @@ conventions:
binnen-i: binnen-i:
name: 'Binnen-I' name: 'Binnen-I'
normative: false normative: false
gender: 'neutr'
warning: > warning: >
Das Binnen-I bezieht sich nur auf die männliche und die weibliche Form der Wörter Das Binnen-I bezieht sich nur auf die männliche und die weibliche Form der Wörter
und schließt damit (wie das generische Maskulinum) immer noch sehr viele Menschen aus der Sprache aus. und schließt damit (wie das generische Maskulinum) immer noch sehr viele Menschen aus der Sprache aus.

View File

@ -224,10 +224,28 @@ nouns:
submit: submit:
action: 'Einreichen' action: 'Einreichen'
actionLong: 'Ein Wort einreichen' actionLong: 'Ein Wort einreichen'
regular:
header: 'Reguläre Formen'
stems:
header: 'Stämme'
description: >
Die gemeinsamen Wortteile werden als Stämme bezeichnet.
Die meisten Substantivkonventionen können dann von einem Stamm abgeleitet werden.
Trage alle vorhandenen Stämme an, es kann allerdings sein,
dass einige Wörter keine Fluchtsubstantive oder Partizipformen haben.
class:
header: 'Klasse'
description: >
Die Bildungsregeln von einem Stamm zu einem Wort in einer Substantivkonvention
wird hier Klasse genannt.
Wähle die passende Klasse an, die die richtigen Wörter bildet.
apply: 'Klasse wählen'
change: 'Klasse wechseln'
irregular:
header: 'Irreguläre Formen'
thanks: 'Danke für deinen Beitrag!' thanks: 'Danke für deinen Beitrag!'
another: 'Einen weiteren Eintrag einreichen' another: 'Einen weiteren Eintrag einreichen'
moderation: 'Einreichungen müssen erst genehmigt werden, bevor sie veröffentlicht werden.' moderation: 'Einreichungen müssen erst genehmigt werden, bevor sie veröffentlicht werden.'
template: template:
header: 'Eine Vorlage nutzen' header: 'Eine Vorlage nutzen'
root: 'Wurzel' root: 'Wurzel'

View File

@ -0,0 +1,9 @@
-- Up
ALTER TABLE nouns
ADD COLUMN classInstance TEXT NULL;
-- Down
ALTER TABLE nouns
DROP COLUMN classInstance;

View File

@ -1,9 +1,9 @@
import { createCanvas, loadImage, registerFont } from 'canvas'; import { createCanvas, loadImage, registerFont } from 'canvas';
import SQL from 'sql-template-strings'; import SQL from 'sql-template-strings';
import { getLocale, loadConfig, loadTranslator } from '~/server/data.ts'; import { getLocale, loadConfig, loadNounsData, loadTranslator } from '~/server/data.ts';
import { registerLocaleFont } from '~/server/localeFont.ts'; import { registerLocaleFont } from '~/server/localeFont.ts';
import { parseNounRow } from '~/server/nouns.ts'; import { buildNoun, displayWord, parseNounRow } from '~/server/nouns.ts';
import type { NounRow } from '~/server/nouns.ts'; import type { NounRow } from '~/server/nouns.ts';
import { import {
availableGenders, availableGenders,
@ -16,7 +16,8 @@ import {
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const locale = getLocale(event); const locale = getLocale(event);
const [config, translator] = await Promise.all([loadConfig(locale), loadTranslator(locale)]); const [config, translator, nounsData] =
await Promise.all([loadConfig(locale), loadTranslator(locale), loadNounsData(locale)]);
checkIsConfigEnabledOr404(await loadConfig(locale), 'nouns'); checkIsConfigEnabledOr404(await loadConfig(locale), 'nouns');
const { isGranted } = await useAuthentication(event); const { isGranted } = await useAuthentication(event);
@ -45,7 +46,7 @@ export default defineEventHandler(async (event) => {
}); });
} }
const noun = parseNounRow(nounRow); const noun = buildNoun(parseNounRow(nounRow), config, nounsData);
const genders = availableGenders(config); const genders = availableGenders(config);
@ -99,7 +100,7 @@ export default defineEventHandler(async (event) => {
const symbol = symbolsByNumeri[numerus]; const symbol = symbolsByNumeri[numerus];
noun.words[gender]?.[numerus]?.forEach((word) => { noun.words[gender]?.[numerus]?.forEach((word) => {
context.fillText( context.fillText(
`${symbol} ${word}`, `${symbol} ${displayWord(word, numerus, nounsData)}`,
column * (width - 2 * padding) / genders.length + padding, column * (width - 2 * padding) / genders.length + padding,
padding * 2.5 + i * 48, padding * 2.5 + i * 48,
); );

View File

@ -5,7 +5,7 @@ import { auditLog } from '~/server/audit.ts';
import { getLocale, loadConfig } from '~/server/data.ts'; import { getLocale, loadConfig } from '~/server/data.ts';
import { approveNounEntry } from '~/server/nouns.ts'; import { approveNounEntry } from '~/server/nouns.ts';
import { isAllowedToPost } from '~/server/user.ts'; import { isAllowedToPost } from '~/server/user.ts';
import type { NounWord, NounWords, NounWordsRaw } from '~/src/nouns.ts'; import type { NounClassInstance, NounWord, NounWords, NounWordsRaw } from '~/src/nouns.ts';
const minimizeWords = (words: NounWords): NounWordsRaw => { const minimizeWords = (words: NounWords): NounWordsRaw => {
return Object.fromEntries(Object.entries(words) return Object.fromEntries(Object.entries(words)
@ -26,7 +26,11 @@ const minimizeWords = (words: NounWords): NounWordsRaw => {
})); }));
}; };
const generateKey = (words: NounWords): string => { const generateKey = (words: NounWords, classInstance: NounClassInstance | null): string => {
const defaultStem = classInstance?.stems.default;
if (defaultStem) {
return defaultStem.toLowerCase();
}
const word = Object.values(words) const word = Object.values(words)
.map((wordsByNumerus) => wordsByNumerus.singular?.[0]) .map((wordsByNumerus) => wordsByNumerus.singular?.[0])
.find((word) => word !== undefined); .find((word) => word !== undefined);
@ -52,12 +56,13 @@ export default defineEventHandler(async (event) => {
const id = ulid(); const id = ulid();
await db.get(SQL` await db.get(SQL`
INSERT INTO nouns ( INSERT INTO nouns (
id, key, words, categories, sources, approved, base_id, locale, author_id id, key, words, classInstance, categories, sources, approved, base_id, locale, author_id
) )
VALUES ( VALUES (
${id}, ${id},
${generateKey(body.words)}, ${generateKey(body.words, body.classInstance)},
${JSON.stringify(minimizeWords(body.words))}, ${JSON.stringify(minimizeWords(body.words))},
${body.classInstance ? JSON.stringify(body.classInstance) : null},
${body.categories.join('|')}, ${body.categories.join('|')},
${body.sources ? body.sources.join(',') : null}, ${body.sources ? body.sources.join(',') : null},
0, ${body.base}, ${locale}, ${user.id} 0, ${body.base}, ${locale}, ${user.id}

View File

@ -9,18 +9,25 @@ import type { RuntimeConfig } from 'nuxt/schema';
import type { Config } from '~/locale/config.ts'; import type { Config } from '~/locale/config.ts';
import localeDescriptions from '~/locale/locales.ts'; import localeDescriptions from '~/locale/locales.ts';
import { getPosts } from '~/server/blog.ts'; import { getPosts } from '~/server/blog.ts';
import { getLocale, loadCalendar, loadConfig, loadPronounLibrary, loadTranslator } from '~/server/data.ts'; import {
getLocale,
loadCalendar,
loadConfig,
loadNounsData,
loadPronounLibrary,
loadTranslator,
} from '~/server/data.ts';
import { getInclusiveEntries } from '~/server/inclusive.ts'; import { getInclusiveEntries } from '~/server/inclusive.ts';
import { getNounEntries } from '~/server/nouns.ts'; import { buildNoun, displayWord, getNounEntries } from '~/server/nouns.ts';
import { rootDir } from '~/server/paths.ts'; import { rootDir } from '~/server/paths.ts';
import { getSourcesEntries } from '~/server/sources.ts'; import { getSourcesEntries } from '~/server/sources.ts';
import { getTermsEntries } from '~/server/terms.ts'; import { getTermsEntries } from '~/server/terms.ts';
import { shortForVariant } from '~/src/buildPronoun.ts'; import { shortForVariant } from '~/src/buildPronoun.ts';
import { Day } from '~/src/calendar/helpers.ts'; import { Day } from '~/src/calendar/helpers.ts';
import { loadWords, Noun } from '~/src/classes.ts';
import { getUrlForLocale } from '~/src/domain.ts'; import { getUrlForLocale } from '~/src/domain.ts';
import forbidden from '~/src/forbidden.ts'; import forbidden from '~/src/forbidden.ts';
import { clearLinkedText, buildImageUrl } from '~/src/helpers.ts'; import { clearLinkedText, buildImageUrl } from '~/src/helpers.ts';
import type { Numerus } from '~/src/nouns.ts';
import parseMarkdown from '~/src/parseMarkdown.ts'; import parseMarkdown from '~/src/parseMarkdown.ts';
import { normaliseQuery, validateQuery } from '~/src/search.ts'; import { normaliseQuery, validateQuery } from '~/src/search.ts';
import type { SearchDocument } from '~/src/search.ts'; import type { SearchDocument } from '~/src/search.ts';
@ -613,11 +620,13 @@ const kinds: SearchKind[] = [
return []; return [];
} }
const nounsData = await loadNounsData(config.locale);
const base = encodeURIComponent(config.nouns.route); const base = encodeURIComponent(config.nouns.route);
const db = useDatabase(); const db = useDatabase();
const nouns = (await getNounEntries(db, () => false, config.locale)) const nouns = (await getNounEntries(db, () => false, config.locale))
.map((nounRaw) => new Noun(config, loadWords(nounRaw, config, { singular: {}, plural: {} }))); .map((nounRaw) => buildNoun(nounRaw, config, nounsData));
return nouns.map((noun, id): SearchDocument => { return nouns.map((noun, id): SearchDocument => {
const firstWords = noun.firstWords; const firstWords = noun.firstWords;
return { return {
@ -626,8 +635,13 @@ const kinds: SearchKind[] = [
url: `/${base}?filter=${firstWords[0]}`, url: `/${base}?filter=${firstWords[0]}`,
title: firstWords.join(' '), title: firstWords.join(' '),
content: Object.values(noun.words) content: Object.values(noun.words)
.flatMap((wordsByNumerus) => Object.values(wordsByNumerus)) .map((wordsByNumerus) => {
.flatMap((words) => words) return Object.entries(wordsByNumerus)
.flatMap(([numerus, words]) => {
return words.map((word) => displayWord(word, numerus as Numerus, nounsData));
})
.join(', ');
})
.join(' '), .join(' '),
}; };
}); });

View File

@ -9,6 +9,7 @@ import type { Calendar } from '~/src/calendar/helpers.ts';
import { PronounLibrary } from '~/src/classes.ts'; import { PronounLibrary } from '~/src/classes.ts';
import type { Pronoun, PronounGroup } from '~/src/classes.ts'; import type { Pronoun, PronounGroup } from '~/src/classes.ts';
import { getLocaleForUrl, getUrlForLocale } from '~/src/domain.ts'; import { getLocaleForUrl, getUrlForLocale } from '~/src/domain.ts';
import type { NounsData } from '~/src/nouns.ts';
import { Translator } from '~/src/translator.ts'; import { Translator } from '~/src/translator.ts';
const setDefault = async <K, V>(map: Map<K, V>, key: K, supplier: () => Promise<V>): Promise<V> => { const setDefault = async <K, V>(map: Map<K, V>, key: K, supplier: () => Promise<V>): Promise<V> => {
@ -80,6 +81,13 @@ export const loadPronounExamples = async (locale: string): Promise<PronounExampl
}); });
}; };
const nounsDataByLocale: Map<string, NounsData> = new Map();
export const loadNounsData = async (locale: string): Promise<NounsData> => {
return setDefault(nounsDataByLocale, locale, async () => {
return loadSuml<NounsData>(`locale/${locale}/nouns/nounsData.suml`);
});
};
const calendarByLocale: Map<string, Calendar> = new Map(); const calendarByLocale: Map<string, Calendar> = new Map();
export const loadCalendar = async (locale: string): Promise<Calendar> => { export const loadCalendar = async (locale: string): Promise<Calendar> => {
return setDefault(calendarByLocale, locale, async () => { return setDefault(calendarByLocale, locale, async () => {

View File

@ -1,18 +1,23 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import SQL from 'sql-template-strings'; import SQL from 'sql-template-strings';
import type { Config } from '~/locale/config.ts';
import type { Database } from '~/server/db.ts'; import type { Database } from '~/server/db.ts';
import type { UserRow } from '~/server/express/user.ts'; import type { UserRow } from '~/server/express/user.ts';
import type { SourceRow } from '~/server/sources.ts'; import type { SourceRow } from '~/server/sources.ts';
import type { IsGrantedFn } from '~/server/utils/useAuthentication.ts'; import type { IsGrantedFn } from '~/server/utils/useAuthentication.ts';
import { loadWords, Noun } from '~/src/classes.ts';
import type { NounRaw } from '~/src/classes.ts'; import type { NounRaw } from '~/src/classes.ts';
import { clearKey } from '~/src/helpers.ts'; import { clearKey } from '~/src/helpers.ts';
import { addWordsFromClassInstance } from '~/src/nouns.ts';
import type { Numerus, NounWord, NounsData } from '~/src/nouns.ts';
import type { User } from '~/src/user.ts'; import type { User } from '~/src/user.ts';
export interface NounRow { export interface NounRow {
id: string; id: string;
key: string; key: string;
words: string; words: string;
classInstance: string | null;
approved: number; approved: number;
base_id: string | null; base_id: string | null;
locale: string; locale: string;
@ -28,11 +33,22 @@ export const parseNounRow = (nounRow: NounRow): Omit<NounRaw, 'sourcesData'> =>
return { return {
id: nounRow.id, id: nounRow.id,
words: JSON.parse(nounRow.words), words: JSON.parse(nounRow.words),
classInstance: nounRow.classInstance ? JSON.parse(nounRow.classInstance) : null,
categories: nounRow.categories?.split('|') ?? [], categories: nounRow.categories?.split('|') ?? [],
sources: nounRow.sources ? nounRow.sources.split(',') : [], sources: nounRow.sources ? nounRow.sources.split(',') : [],
}; };
}; };
export const buildNoun = (nounRaw: Omit<NounRaw, 'sourcesData'>, config: Config, nounsData: NounsData): Noun => {
const nounRawWithLoadedWords = loadWords(nounRaw, config, { singular: {}, plural: {} });
if (!nounRawWithLoadedWords.classInstance) {
return new Noun(config, nounRawWithLoadedWords);
}
const words =
addWordsFromClassInstance(nounRawWithLoadedWords.words, nounRawWithLoadedWords.classInstance, nounsData);
return new Noun(config, { ...nounRaw, words });
};
const parseNounRowWithAuthor = (nounRow: NounRowWithAuthor, isGranted: IsGrantedFn): Omit<NounRaw, 'sourcesData'> => { const parseNounRowWithAuthor = (nounRow: NounRowWithAuthor, isGranted: IsGrantedFn): Omit<NounRaw, 'sourcesData'> => {
const noun = parseNounRow(nounRow); const noun = parseNounRow(nounRow);
if (isGranted('nouns')) { if (isGranted('nouns')) {
@ -43,6 +59,17 @@ const parseNounRowWithAuthor = (nounRow: NounRowWithAuthor, isGranted: IsGranted
return noun; return noun;
}; };
export const displayWord = (word: NounWord, numerus: Numerus, nounsData: NounsData): string => {
if (!nounsData.declensions || !nounsData.cases) {
return word.spelling;
}
const declension = typeof word.declension === 'string' ? nounsData.declensions[word.declension] : word.declension;
const firstCaseAbbreviation = Object.keys(nounsData.cases)[0];
const endings = declension?.[numerus]?.[firstCaseAbbreviation];
return `${word.spelling}${endings?.[0] ?? ''}`;
};
export const addVersions = async ( export const addVersions = async (
db: Database, db: Database,
isGranted: IsGrantedFn, isGranted: IsGrantedFn,

View File

@ -11,6 +11,7 @@ import type {
NounWordsRaw, NounWordsRaw,
NounWord, NounWord,
NounDeclensionsByFirstCase, NounDeclensionsByFirstCase,
NounClassInstance,
NounConvention, NounConvention,
Gender, Gender,
Numerus, Numerus,
@ -844,6 +845,7 @@ export class PronounLibrary {
export interface NounRaw { export interface NounRaw {
id: string; id: string;
words: NounWordsRaw; words: NounWordsRaw;
classInstance?: NounClassInstance | null;
categories?: string[]; categories?: string[];
sources?: string[]; sources?: string[];
sourcesData?: SourceRaw[]; sourcesData?: SourceRaw[];
@ -874,7 +876,7 @@ const loadWord = (
return wordRaw; return wordRaw;
}; };
interface NounRawWithLoadedWords extends Omit<NounRaw, 'words'> { export interface NounRawWithLoadedWords extends Omit<NounRaw, 'words'> {
words: NounWords; words: NounWords;
} }
@ -920,6 +922,7 @@ const hasWordOfGender = (words: NounWords, gender: Gender): boolean => {
export class Noun implements Entry { export class Noun implements Entry {
id: string; id: string;
words: NounWords; words: NounWords;
classInstance: NounClassInstance | null;
categories: string[]; categories: string[];
sources: string[]; sources: string[];
sourcesData: Source[]; sourcesData: Source[];
@ -930,6 +933,7 @@ export class Noun implements Entry {
constructor(config: Config, nounRaw: NounRawWithLoadedWords) { constructor(config: Config, nounRaw: NounRawWithLoadedWords) {
this.id = nounRaw.id; this.id = nounRaw.id;
this.words = nounRaw.words; this.words = nounRaw.words;
this.classInstance = nounRaw.classInstance ?? null;
this.categories = nounRaw.categories ?? []; this.categories = nounRaw.categories ?? [];
this.sources = nounRaw.sources ?? []; this.sources = nounRaw.sources ?? [];
this.sourcesData = nounRaw.sourcesData?.filter((s) => !!s).map((s) => new Source(config, s)) ?? []; this.sourcesData = nounRaw.sourcesData?.filter((s) => !!s).map((s) => new Source(config, s)) ?? [];

View File

@ -163,7 +163,8 @@ export function listMissingTranslations(
return false; return false;
} }
if (!config.nouns.conventions?.enabled && keyMatches('nouns.conventions.')) { if (!config.nouns.conventions?.enabled &&
keyMatches('nouns.conventions.', 'nouns.submit.regular', 'nouns.submit.irregular')) {
return false; return false;
} }

View File

@ -48,6 +48,7 @@ export const symbolsByNumeri: Record<Numerus, string> = {
export interface NounWord { export interface NounWord {
spelling: string; spelling: string;
regular?: boolean;
convention?: keyof Required<NounsData>['conventions']; convention?: keyof Required<NounsData>['conventions'];
declension?: keyof Required<NounsData>['declensions'] | NounDeclension; declension?: keyof Required<NounsData>['declensions'] | NounDeclension;
} }
@ -65,6 +66,7 @@ export interface NounConventionGroup {
export interface NounConvention { export interface NounConvention {
name: string; name: string;
normative: boolean; normative: boolean;
gender: Gender;
warning?: string; warning?: string;
description?: string[]; description?: string[];
morphemes: Record<string, MorphemeValue | string>; morphemes: Record<string, MorphemeValue | string>;
@ -77,8 +79,13 @@ interface NounConventionTemplate {
declension: string; declension: string;
} }
interface NounStem {
name: string;
example: string;
}
export interface NounClass { export interface NounClass {
exampleStems: Record<string, string>; exampleStems: Record<keyof Required<NounsData>['stems'], string>;
} }
export interface NounClassExample { export interface NounClassExample {
@ -125,9 +132,88 @@ export interface NounsData {
morphemes?: string[]; morphemes?: string[];
examples?: string[]; examples?: string[];
grammarTables?: GrammarTableDefinition[]; grammarTables?: GrammarTableDefinition[];
stems?: Record<string, NounStem>;
classes?: Record<string, NounClass>; classes?: Record<string, NounClass>;
classExample?: NounClassExample; classExample?: NounClassExample;
declensions?: Record<string, NounDeclension>; declensions?: Record<string, NounDeclension>;
groups?: Record<string, NounConventionGroup>; groups?: Record<string, NounConventionGroup>;
conventions?: Record<string, NounConvention>; conventions?: Record<string, NounConvention>;
} }
export interface NounClassInstance {
classKey: keyof Required<NounsData>['classes'];
stems: Record<keyof Required<NounsData>['stems'], string>;
}
export const resolveWordsFromClassInstance = (
nounClassInstance: NounClassInstance,
nounsData: NounsData,
): NounWords => {
const words: NounWords = {};
for (const [conventionKey, convention] of Object.entries(nounsData.conventions ?? {})) {
if (!Object.hasOwn(convention.templates, nounClassInstance.classKey)) {
continue;
}
const template = convention.templates[nounClassInstance.classKey];
const stem = nounClassInstance.stems[template.stem ?? 'default'];
if (!stem) {
continue;
}
const word: NounWord = {
spelling: stem + template.suffix,
regular: true,
convention: conventionKey,
declension: template.declension,
};
const declension = nounsData.declensions![template.declension];
for (const numerus of numeri) {
if (!declension[numerus]) {
continue;
}
insertIntoNounWords(words, word, convention.gender, numerus);
}
}
return words;
};
const insertIntoNounWords = (words: NounWords, word: NounWord, gender: Gender, numerus: Numerus) => {
if (!Object.hasOwn(words, gender)) {
words[gender] = {};
}
if (!Object.hasOwn(words[gender]!, numerus)) {
words[gender]![numerus] = [];
}
words[gender]![numerus]!.push(word);
};
export const addWordsFromClassInstance = (
nounWords: NounWords,
nounClassInstance: NounClassInstance,
nounsData: NounsData,
): NounWords => {
const mergedNounWords = structuredClone(nounWords);
const wordsFromTemplate = resolveWordsFromClassInstance(nounClassInstance, nounsData);
for (const [gender, wordsOfNumerus] of Object.entries(wordsFromTemplate)) {
for (const [numerus, words] of Object.entries(wordsOfNumerus)) {
for (const word of words) {
insertIntoNounWords(mergedNounWords, word, gender as Gender, numerus as Numerus);
}
}
}
return mergedNounWords;
};
export const filterIrregularWords = (words: NounWords) => {
const filteredNounWords: NounWords = {};
for (const [gender, wordsOfNumerus] of Object.entries(words)) {
for (const [numerus, words] of Object.entries(wordsOfNumerus)) {
for (const word of words) {
if (!word.regular) {
insertIntoNounWords(filteredNounWords, word, gender as Gender, numerus as Numerus);
}
}
}
}
return filteredNounWords;
};