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

View File

@ -21,7 +21,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
<template>
<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 />
</div>
<div
@ -29,6 +29,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
:key="numerus"
:style="{ gridArea: `${gender}${numerus === 'plural' ? 'Pl' : ''}` }"
role="cell"
v-bind="$attrs"
>
<NounsItem :noun :gender :numerus />
@ -41,7 +42,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
</small>
</div>
</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>
<ul class="list-unstyled mb-0">
<li v-for="source in noun.sourcesData" :key="source.id">
@ -49,7 +50,7 @@ const visibleNumeri = computed(() => availableNumeri(config));
</li>
</ul>
</div>
<div style="grid-area: buttons">
<div style="grid-area: buttons" v-bind="$attrs">
<slot name="buttons"></slot>
</div>
</template>

View File

@ -204,6 +204,22 @@ nouns:
submit:
action: 'Submit'
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!'
another: 'Submit another one'
moderation: 'Submissions will have to get approved before getting published.'

View File

@ -241,6 +241,16 @@ declensions:
g: ['ne']
d: ['ne']
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:
t1:
exampleStems:
@ -316,6 +326,7 @@ conventions:
maskulinum:
name: 'Maskulinum'
normative: true
gender: 'masc'
morphemes:
article_n: 'der'
article_g: 'des'
@ -353,6 +364,7 @@ conventions:
femininum:
name: 'Femininum'
normative: true
gender: 'fem'
morphemes:
article_n: 'die'
article_g: 'der'
@ -390,6 +402,7 @@ conventions:
partizip-formen:
name: 'Partizip-Formen'
normative: true
gender: 'neutr'
description:
- >
Aus dem Partizip I (infinite Verbform) lassen sich substantivierte Adjektive bilden.
@ -417,6 +430,7 @@ conventions:
person-formen:
name: 'Person-Formen'
normative: true
gender: 'neutr'
morphemes:
article_n: 'die'
article_g: 'der'
@ -458,6 +472,7 @@ conventions:
mensch-formen:
name: 'Mensch-Formen'
normative: true
gender: 'neutr'
morphemes:
article_n: 'der'
article_g: 'des'
@ -499,6 +514,7 @@ conventions:
diminuitiv:
name: 'Diminuitiv'
normative: true
gender: 'neutr'
warning: >
Die Verniedlichungsform benutzt zwar das Neutrum und ist damit eine normativ neutrale Form,
jedoch zeichnet diese Form üblicherweise kleine und junge Nomen aus.
@ -541,6 +557,7 @@ conventions:
y-formen:
name: 'Y-Formen'
normative: false
gender: 'neutr'
description:
- >
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:
name: 'I-Formen'
normative: false
gender: 'neutr'
description:
- >
Verwendet u.a. in den Romanen
@ -629,6 +647,7 @@ conventions:
inklusivum:
name: 'Inklusivum'
normative: false
gender: 'neutr'
description:
- 'Formen vorgestellt vom {https://geschlechtsneutral.net/=Verein für geschlechtsneutrales Deutsch}.'
- 'Siehe auch: {/en/em=Neopronomen „en“}'
@ -669,6 +688,7 @@ conventions:
indefinitivum:
name: 'Indefinitivum'
normative: false
gender: 'neutr'
description:
- '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“}.'
@ -705,6 +725,7 @@ conventions:
ens-formen:
name: 'ens-Formen'
normative: false
gender: 'neutr'
description:
- 'Formen vorgestellt von Lann Hornscheidt.'
- 'Siehe auch: {/ens=Neopronomen „ens“}.'
@ -734,6 +755,7 @@ conventions:
ex-formen:
name: 'ex-Formen'
normative: false
gender: 'neutr'
description:
- 'Formen vorgestellt von {https://www.lannhornscheidt.com/w_ortungen/nonbinare-w_ortungen/=Lann Hornscheidt und Lio Oppenländer}.'
- 'Siehe auch: {/ex=Neopronomen „ex“}.'
@ -757,6 +779,7 @@ conventions:
ojum:
name: 'Ojum'
normative: false
gender: 'neutr'
description:
- 'Formen vorgestellt von {https://www.frumble.de/blog/2021/03/26/ueberlegungen-zu-einer-genderneutralen-deutschen-grammatik=Frumble}.'
- 'Siehe auch: {/oj=Neopronomen „oj/ojm“}.'
@ -788,6 +811,7 @@ conventions:
nona-system:
name: 'NoNa-System'
normative: false
gender: 'neutr'
description:
- 'Formen vorgestellt von {https://geschlechtsneutralesdeutsch.com/=Geschlechtsneutrales Deutsch}.'
morphemes:
@ -823,6 +847,7 @@ conventions:
genderdoppelpunkt:
name: 'Genderdoppelpunkt'
normative: false
gender: 'neutr'
morphemes:
article_n: 'der:die'
article_g: 'des:der'
@ -860,6 +885,7 @@ conventions:
gendergap:
name: 'Gendergap'
normative: false
gender: 'neutr'
morphemes:
article_n: 'der_die'
article_g: 'des_der'
@ -897,6 +923,7 @@ conventions:
gendersternchen:
name: 'Gendersternchen'
normative: false
gender: 'neutr'
morphemes:
article_n: 'der*die'
article_g: 'des*der'
@ -934,6 +961,7 @@ conventions:
binnen-i:
name: 'Binnen-I'
normative: false
gender: 'neutr'
warning: >
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.

View File

@ -224,10 +224,28 @@ nouns:
submit:
action: '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!'
another: 'Einen weiteren Eintrag einreichen'
moderation: 'Einreichungen müssen erst genehmigt werden, bevor sie veröffentlicht werden.'
template:
header: 'Eine Vorlage nutzen'
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 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 { parseNounRow } from '~/server/nouns.ts';
import { buildNoun, displayWord, parseNounRow } from '~/server/nouns.ts';
import type { NounRow } from '~/server/nouns.ts';
import {
availableGenders,
@ -16,7 +16,8 @@ import {
export default defineEventHandler(async (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');
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);
@ -99,7 +100,7 @@ export default defineEventHandler(async (event) => {
const symbol = symbolsByNumeri[numerus];
noun.words[gender]?.[numerus]?.forEach((word) => {
context.fillText(
`${symbol} ${word}`,
`${symbol} ${displayWord(word, numerus, nounsData)}`,
column * (width - 2 * padding) / genders.length + padding,
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 { approveNounEntry } from '~/server/nouns.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 => {
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)
.map((wordsByNumerus) => wordsByNumerus.singular?.[0])
.find((word) => word !== undefined);
@ -52,12 +56,13 @@ export default defineEventHandler(async (event) => {
const id = ulid();
await db.get(SQL`
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 (
${id},
${generateKey(body.words)},
${generateKey(body.words, body.classInstance)},
${JSON.stringify(minimizeWords(body.words))},
${body.classInstance ? JSON.stringify(body.classInstance) : null},
${body.categories.join('|')},
${body.sources ? body.sources.join(',') : null},
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 localeDescriptions from '~/locale/locales.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 { getNounEntries } from '~/server/nouns.ts';
import { buildNoun, displayWord, getNounEntries } from '~/server/nouns.ts';
import { rootDir } from '~/server/paths.ts';
import { getSourcesEntries } from '~/server/sources.ts';
import { getTermsEntries } from '~/server/terms.ts';
import { shortForVariant } from '~/src/buildPronoun.ts';
import { Day } from '~/src/calendar/helpers.ts';
import { loadWords, Noun } from '~/src/classes.ts';
import { getUrlForLocale } from '~/src/domain.ts';
import forbidden from '~/src/forbidden.ts';
import { clearLinkedText, buildImageUrl } from '~/src/helpers.ts';
import type { Numerus } from '~/src/nouns.ts';
import parseMarkdown from '~/src/parseMarkdown.ts';
import { normaliseQuery, validateQuery } from '~/src/search.ts';
import type { SearchDocument } from '~/src/search.ts';
@ -613,11 +620,13 @@ const kinds: SearchKind[] = [
return [];
}
const nounsData = await loadNounsData(config.locale);
const base = encodeURIComponent(config.nouns.route);
const db = useDatabase();
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 => {
const firstWords = noun.firstWords;
return {
@ -626,8 +635,13 @@ const kinds: SearchKind[] = [
url: `/${base}?filter=${firstWords[0]}`,
title: firstWords.join(' '),
content: Object.values(noun.words)
.flatMap((wordsByNumerus) => Object.values(wordsByNumerus))
.flatMap((words) => words)
.map((wordsByNumerus) => {
return Object.entries(wordsByNumerus)
.flatMap(([numerus, words]) => {
return words.map((word) => displayWord(word, numerus as Numerus, nounsData));
})
.join(', ');
})
.join(' '),
};
});

View File

@ -9,6 +9,7 @@ import type { Calendar } from '~/src/calendar/helpers.ts';
import { PronounLibrary } from '~/src/classes.ts';
import type { Pronoun, PronounGroup } from '~/src/classes.ts';
import { getLocaleForUrl, getUrlForLocale } from '~/src/domain.ts';
import type { NounsData } from '~/src/nouns.ts';
import { Translator } from '~/src/translator.ts';
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();
export const loadCalendar = async (locale: string): Promise<Calendar> => {
return setDefault(calendarByLocale, locale, async () => {

View File

@ -1,18 +1,23 @@
/* eslint-disable camelcase */
import SQL from 'sql-template-strings';
import type { Config } from '~/locale/config.ts';
import type { Database } from '~/server/db.ts';
import type { UserRow } from '~/server/express/user.ts';
import type { SourceRow } from '~/server/sources.ts';
import type { IsGrantedFn } from '~/server/utils/useAuthentication.ts';
import { loadWords, Noun } from '~/src/classes.ts';
import type { NounRaw } from '~/src/classes.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';
export interface NounRow {
id: string;
key: string;
words: string;
classInstance: string | null;
approved: number;
base_id: string | null;
locale: string;
@ -28,11 +33,22 @@ export const parseNounRow = (nounRow: NounRow): Omit<NounRaw, 'sourcesData'> =>
return {
id: nounRow.id,
words: JSON.parse(nounRow.words),
classInstance: nounRow.classInstance ? JSON.parse(nounRow.classInstance) : null,
categories: nounRow.categories?.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 noun = parseNounRow(nounRow);
if (isGranted('nouns')) {
@ -43,6 +59,17 @@ const parseNounRowWithAuthor = (nounRow: NounRowWithAuthor, isGranted: IsGranted
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 (
db: Database,
isGranted: IsGrantedFn,

View File

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

View File

@ -163,7 +163,8 @@ export function listMissingTranslations(
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;
}

View File

@ -48,6 +48,7 @@ export const symbolsByNumeri: Record<Numerus, string> = {
export interface NounWord {
spelling: string;
regular?: boolean;
convention?: keyof Required<NounsData>['conventions'];
declension?: keyof Required<NounsData>['declensions'] | NounDeclension;
}
@ -65,6 +66,7 @@ export interface NounConventionGroup {
export interface NounConvention {
name: string;
normative: boolean;
gender: Gender;
warning?: string;
description?: string[];
morphemes: Record<string, MorphemeValue | string>;
@ -77,8 +79,13 @@ interface NounConventionTemplate {
declension: string;
}
interface NounStem {
name: string;
example: string;
}
export interface NounClass {
exampleStems: Record<string, string>;
exampleStems: Record<keyof Required<NounsData>['stems'], string>;
}
export interface NounClassExample {
@ -125,9 +132,88 @@ export interface NounsData {
morphemes?: string[];
examples?: string[];
grammarTables?: GrammarTableDefinition[];
stems?: Record<string, NounStem>;
classes?: Record<string, NounClass>;
classExample?: NounClassExample;
declensions?: Record<string, NounDeclension>;
groups?: Record<string, NounConventionGroup>;
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;
};