From 301a496f30b8adf69e3a189c3f3adb60beabf8f3 Mon Sep 17 00:00:00 2001 From: Valentyne Stigloher Date: Thu, 17 Jul 2025 21:05:03 +0200 Subject: [PATCH] (nouns) submit regular forms (only stems and class) and display them in the dictionary --- components/nouns/NounsClassSelectTable.vue | 87 ++++++++++++++++++ components/nouns/NounsDictionary.vue | 9 +- .../nouns/NounsIrregularWordsSubform.vue | 55 ++++++++++++ components/nouns/NounsRegularWordsSubform.vue | 53 +++++++++++ components/nouns/NounsSubmitForm.vue | 63 +++++-------- components/nouns/NounsTableEntry.vue | 7 +- locale/_base/translations.suml | 16 ++++ locale/de/nouns/nounsData.suml | 28 ++++++ locale/de/translations.suml | 20 ++++- migrations/091-nouns-stems.sql | 9 ++ server/api/nouns/[id].get.ts | 11 +-- server/api/nouns/submit.post.ts | 13 ++- server/api/search.get.ts | 26 ++++-- server/data.ts | 8 ++ server/nouns.ts | 27 ++++++ src/classes.ts | 6 +- src/missingTranslations.ts | 3 +- src/nouns.ts | 88 ++++++++++++++++++- 18 files changed, 466 insertions(+), 63 deletions(-) create mode 100644 components/nouns/NounsClassSelectTable.vue create mode 100644 components/nouns/NounsIrregularWordsSubform.vue create mode 100644 components/nouns/NounsRegularWordsSubform.vue create mode 100644 migrations/091-nouns-stems.sql diff --git a/components/nouns/NounsClassSelectTable.vue b/components/nouns/NounsClassSelectTable.vue new file mode 100644 index 000000000..1c0c185f0 --- /dev/null +++ b/components/nouns/NounsClassSelectTable.vue @@ -0,0 +1,87 @@ + + + diff --git a/components/nouns/NounsDictionary.vue b/components/nouns/NounsDictionary.vue index 1e46b8011..1052d8ed7 100644 --- a/components/nouns/NounsDictionary.vue +++ b/components/nouns/NounsDictionary.vue @@ -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)) diff --git a/components/nouns/NounsIrregularWordsSubform.vue b/components/nouns/NounsIrregularWordsSubform.vue new file mode 100644 index 000000000..6b68108de --- /dev/null +++ b/components/nouns/NounsIrregularWordsSubform.vue @@ -0,0 +1,55 @@ + + + diff --git a/components/nouns/NounsRegularWordsSubform.vue b/components/nouns/NounsRegularWordsSubform.vue new file mode 100644 index 000000000..4ee59266f --- /dev/null +++ b/components/nouns/NounsRegularWordsSubform.vue @@ -0,0 +1,53 @@ + + + diff --git a/components/nouns/NounsSubmitForm.vue b/components/nouns/NounsSubmitForm.vue index 4206e549b..537c33bc9 100644 --- a/components/nouns/NounsSubmitForm.vue +++ b/components/nouns/NounsSubmitForm.vue @@ -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 { words: Record>; + classInstance: NounClassInstance | null; categories: NonNullable; } @@ -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('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 => { if (JSON.stringify(form.value) !== JSON.stringify(emptyForm(config))) { @@ -75,6 +62,7 @@ const applyTemplate = async (template: Noun): Promise => { } 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,

-
-
- + + availableNumeri(config)); diff --git a/locale/_base/translations.suml b/locale/_base/translations.suml index ad077b64b..14e8f5d7f 100644 --- a/locale/_base/translations.suml +++ b/locale/_base/translations.suml @@ -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.' diff --git a/locale/de/nouns/nounsData.suml b/locale/de/nouns/nounsData.suml index fec60a27a..14bbf4715 100644 --- a/locale/de/nouns/nounsData.suml +++ b/locale/de/nouns/nounsData.suml @@ -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. diff --git a/locale/de/translations.suml b/locale/de/translations.suml index f68836fc0..ff6bab3a3 100644 --- a/locale/de/translations.suml +++ b/locale/de/translations.suml @@ -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' diff --git a/migrations/091-nouns-stems.sql b/migrations/091-nouns-stems.sql new file mode 100644 index 000000000..cae3bfec8 --- /dev/null +++ b/migrations/091-nouns-stems.sql @@ -0,0 +1,9 @@ +-- Up + +ALTER TABLE nouns + ADD COLUMN classInstance TEXT NULL; + +-- Down + +ALTER TABLE nouns + DROP COLUMN classInstance; diff --git a/server/api/nouns/[id].get.ts b/server/api/nouns/[id].get.ts index 593811191..685a36091 100644 --- a/server/api/nouns/[id].get.ts +++ b/server/api/nouns/[id].get.ts @@ -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, ); diff --git a/server/api/nouns/submit.post.ts b/server/api/nouns/submit.post.ts index 7ec6139cf..18fb15781 100644 --- a/server/api/nouns/submit.post.ts +++ b/server/api/nouns/submit.post.ts @@ -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} diff --git a/server/api/search.get.ts b/server/api/search.get.ts index fc1b71500..34a07cf8f 100644 --- a/server/api/search.get.ts +++ b/server/api/search.get.ts @@ -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(' – '), }; }); diff --git a/server/data.ts b/server/data.ts index cfc6fcb58..ffb3bdeee 100644 --- a/server/data.ts +++ b/server/data.ts @@ -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 (map: Map, key: K, supplier: () => Promise): Promise => { @@ -80,6 +81,13 @@ export const loadPronounExamples = async (locale: string): Promise = new Map(); +export const loadNounsData = async (locale: string): Promise => { + return setDefault(nounsDataByLocale, locale, async () => { + return loadSuml(`locale/${locale}/nouns/nounsData.suml`); + }); +}; + const calendarByLocale: Map = new Map(); export const loadCalendar = async (locale: string): Promise => { return setDefault(calendarByLocale, locale, async () => { diff --git a/server/nouns.ts b/server/nouns.ts index bd2598989..fa22ae427 100644 --- a/server/nouns.ts +++ b/server/nouns.ts @@ -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 => 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, 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 => { 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, diff --git a/src/classes.ts b/src/classes.ts index 61cad9576..a462367a3 100644 --- a/src/classes.ts +++ b/src/classes.ts @@ -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 { +export interface NounRawWithLoadedWords extends Omit { 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)) ?? []; diff --git a/src/missingTranslations.ts b/src/missingTranslations.ts index 579a88d42..47bc77e1d 100644 --- a/src/missingTranslations.ts +++ b/src/missingTranslations.ts @@ -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; } diff --git a/src/nouns.ts b/src/nouns.ts index a508f5d77..cdaae6a98 100644 --- a/src/nouns.ts +++ b/src/nouns.ts @@ -48,6 +48,7 @@ export const symbolsByNumeri: Record = { export interface NounWord { spelling: string; + regular?: boolean; convention?: keyof Required['conventions']; declension?: keyof Required['declensions'] | NounDeclension; } @@ -65,6 +66,7 @@ export interface NounConventionGroup { export interface NounConvention { name: string; normative: boolean; + gender: Gender; warning?: string; description?: string[]; morphemes: Record; @@ -77,8 +79,13 @@ interface NounConventionTemplate { declension: string; } +interface NounStem { + name: string; + example: string; +} + export interface NounClass { - exampleStems: Record; + exampleStems: Record['stems'], string>; } export interface NounClassExample { @@ -125,9 +132,88 @@ export interface NounsData { morphemes?: string[]; examples?: string[]; grammarTables?: GrammarTableDefinition[]; + stems?: Record; classes?: Record; classExample?: NounClassExample; declensions?: Record; groups?: Record; conventions?: Record; } + +export interface NounClassInstance { + classKey: keyof Required['classes']; + stems: Record['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; +};