import type { Config, ConfigWithEnabled } from '../locale/config.ts'; import { buildDict, buildList, escapeControlSymbols, removeSuffix } from './helpers.ts'; import type { Translator } from './translator.ts'; import type { Example, ExampleValues } from '#shared/language/examples.ts'; import { getBaseMorpheme, MorphemeValues } from '#shared/language/morphemes.ts'; import type { NounWords, NounWordsRaw, NounWord, NounDeclensionsByFirstCase, NounClassInstance, NounConvention, Gender, Numerus, } from '#shared/nouns.ts'; import type { WithKey } from '#shared/utils/entriesWithKeys.ts'; import type { NounTemplatesData } from '~~/locale/data.ts'; export class PronounExample { singular: Example; plural: Example; singularNull: Example; pluralNull: Example; isHonorific: boolean; categories: string[]; constructor(singular: Example, plural: Example, singularNull: Example, pluralNull: Example, isHonorific = false, categories: string[] = []) { this.singular = singular; this.plural = plural; this.singularNull = singularNull; this.pluralNull = pluralNull; this.isHonorific = isHonorific; this.categories = categories; } example(pronoun: Pronoun, counter = 0): Example { const plural = this.isHonorific ? pronoun.isPluralHonorific(counter) : pronoun.isPlural(counter); return pronoun.nullPronoun ? this[plural ? 'pluralNull' : 'singularNull'] : this[plural ? 'plural' : 'singular']; } } export class ExampleCategory { name: string | undefined; examples: PronounExample[]; comprehensive: boolean; constructor(name: string | undefined, examples: PronounExample[], comprehensive: boolean = false) { this.name = name; this.examples = examples; this.comprehensive = comprehensive; } static from(examples: PronounExample[], config: Config): ExampleCategory[] { if (!config.pronouns.exampleCategories) { return examples.map((example) => new ExampleCategory(undefined, [example])); } return config.pronouns.exampleCategories.map((exampleCategory) => { const matchingExamples = examples.filter((example) => { return exampleCategory.morphemes?.some((morpheme) => example.singular.hasMorpheme(morpheme)) || example.categories?.includes(exampleCategory.name); }); return new ExampleCategory(exampleCategory.name, matchingExamples, exampleCategory.comprehensive); }); } } function clone(mainObject: T): T { const objectCopy = {} as T; for (const [key, value] of Object.entries(mainObject)) { objectCopy[key as keyof T] = value; } return objectCopy; } export interface Category { key: string; text: string; icon?: string; } export const moderationFilters = ['unapproved', 'no key', 'no image', 'no category'] as const; export interface Filter { text: string; /** {@link Category.key} */ category: string; moderation: typeof moderationFilters[number] | undefined; } export interface Entry { id: string; approved: boolean; matches(filter: Filter): boolean; } export type SourceType = '' | 'Book' | 'Article' | 'Movie' | 'Series' | 'Song' | 'Poetry' | 'Comics' | 'Game' | 'Other'; export interface SourceRaw { id: string; pronouns: string; type: SourceType; author: string | null; title: string; extra: string | null; year: number | null; fragments?: string; comment?: string | null; link?: string | null; spoiler?: boolean; submitter?: string | null; approved: boolean; base_id?: string | null; key?: string | null; versions?: SourceRaw[]; locale?: string; images?: string | null; } export class Source implements Entry { id: string; pronouns: string[]; type: SourceType; author: string | null; title: string; extra: string | null; year: number | null; fragments: string[]; comment: string | null; link: string | null; spoiler: boolean; submitter: string | null; approved: boolean; base_id: string | null; key: string | null; versions: Source[]; locale: string; images: string[]; typePriority?: number; sortString?: string; index?: string; constructor(config: Config, { id, pronouns, type, author, title, extra, year, fragments = '', comment = null, link = null, spoiler = false, submitter = null, approved, base_id = null, key = null, versions = [], locale = config.locale, images = null, }: SourceRaw) { this.id = id; this.pronouns = pronouns ? pronouns.split(';') : []; this.type = type; this.author = author; this.title = title; this.extra = extra; this.year = year; this.fragments = fragments ? fragments.replace(/\|/g, '\n').replace(/\\@/g, '###') .split('@') .map((x) => x.replace(/###/g, '@')) : []; this.comment = comment; this.link = link; this.spoiler = !!spoiler; this.submitter = submitter; this.approved = approved; this.base_id = base_id; this.key = key; this.versions = versions.map((v) => new Source(config, v)); this.locale = locale; this.images = images ? images.split(',') : []; } static get TYPES(): Record { return { '': 'clipboard-list', 'Book': 'book-open', 'Article': 'newspaper', 'Movie': 'film', 'Series': 'tv', 'Song': 'music', 'Poetry': 'scroll', 'Comics': 'file-image', 'Game': 'gamepad-alt', 'Other': 'comment-alt-lines', }; } static get TYPES_PRIORITIES(): Record { return { '': 4, 'Book': 1, 'Article': 2, 'Movie': 3, 'Series': 3, 'Song': 0, 'Poetry': 0, 'Comics': 4, 'Game': 4, 'Other': 4, }; } icon(): string { return Source.TYPES[this.type]; } matches(filter: Filter) { return (!filter.text || !!this.index?.includes(filter.text.toLowerCase())) && (!filter.category || this.type === filter.category) && this.matchesModeration(filter.moderation); } matchesModeration(moderationFilter: Filter['moderation']) { switch (moderationFilter) { case undefined: return true; case 'unapproved': return !this.approved; case 'no key': return !this.key; default: return false; } } } export class SourceLibrary { sources: Source[]; map: Record; countApproved: number; countPending: number; pronouns: string[]; multiple: string[]; cache: Record; constructor(config: Config, rawSources: SourceRaw[]) { this.sources = rawSources.map((s) => new Source(config, s)); this.map = {}; const multiple = new Set(); const pronouns = new Set(); this.countApproved = 0; this.countPending = 0; for (const source of this.sources) { this[source.approved ? 'countApproved' : 'countPending']++; if (!source.pronouns.length) { if (this.map[''] === undefined) { this.map[''] = []; } this.map[''].push(source); continue; } for (const pronoun of source.pronouns) { if (this.map[pronoun] === undefined) { this.map[pronoun] = []; } this.map[pronoun].push(source); pronouns.add(pronoun); if (pronoun.includes('&')) { multiple.add(pronoun); } } } this.pronouns = [...pronouns]; this.multiple = [...multiple]; this.cache = {}; } getForPronoun(pronoun: string, pronounLibrary: PronounLibrary | null = null): Source[] { if (this.cache[pronoun] === undefined) { let sources = this.map[pronoun] || []; if (pronoun === '') { for (const p of this.pronouns) { if (pronounLibrary && !pronounLibrary.isCanonical(p)) { sources = [...sources, ...this.map[p]]; } } } this.cache[pronoun] = sources .map((s) => this.addMetaData(s)) .sort((a, b) => { if (a.typePriority !== b.typePriority) { return b.typePriority! - a.typePriority!; } return a.sortString!.localeCompare(b.sortString!); }); } return this.cache[pronoun]; } getForPronounExtended(pronoun: string): Record { const sources: Record = {}; const s = this.getForPronoun(pronoun); sources[pronoun] = s.length ? s : undefined; if (pronoun.includes('&')) { for (const option of pronoun.split('&')) { const s = this.getForPronoun(option); sources[option] = s.length ? s : undefined; } } return sources; } addMetaData(source: Source): Source { source.typePriority = Source.TYPES_PRIORITIES[source.type]; source.sortString = source.author || `ZZZZZ${source.title}`; // if no author, put on the end if (source.sortString.includes('^')) { const index = source.sortString.indexOf('^'); source.sortString = `${source.sortString.substring(index + 1)} ${source.sortString.substring(0, index)}`; } source.index = [ (source.author || '').replace('^', ''), source.title, source.extra, source.year, ...source.fragments, source.comment, source.link, ].join(' ').toLowerCase() .replace(/<\/?[^>]+(>|$)/g, ''); return source; } } const escape = (s: string[] | string | null): string => { if (Array.isArray(s)) { s = s.join('&'); } return (s || '') .replace(/,/g, '') .replace(/!/g, '') .replace(/\./g, '') // .replace(/\/', '%2F') .replace(/#/g, '%23') .replace(/\?/g, '%3F'); }; export interface PronounUsage { short: { options: string[]; glue?: string }; pronoun?: Pronoun; } export class Pronoun { config: ConfigWithEnabled<'pronouns'>; canonicalName: string; description: string | string[]; normative: boolean; morphemes: Record; pronunciations: Record; plural: boolean[]; pluralHonorific: boolean[]; aliases: string[]; history: string; pronounceable: boolean; thirdForm: string | null; smallForm: string | null; sourcesInfo: string | null; hidden: boolean; nullPronoun: boolean; static DESCRIPTION_MAXLENGTH = 64; constructor( config: ConfigWithEnabled<'pronouns'>, canonicalName: string, description: string | string[], normative: boolean, morphemes: Record, plural: boolean[], pluralHonorific: boolean[], aliases: string[] = [], history: string = '', pronounceable: boolean = true, thirdForm: string | null = null, smallForm: string | null = null, sourcesInfo: string | null = null, hidden: boolean = false, nullPronoun: boolean = false, ) { this.config = config; this.canonicalName = canonicalName; this.description = description || ''; this.normative = normative; this.morphemes = {}; this.pronunciations = {}; for (const [m, value] of Object.entries(morphemes)) { const [morpheme, pronunciation] = typeof value === 'string' ? value.split('|') : [null, null]; this.morphemes[m] = morpheme; this.pronunciations[m] = pronunciation; } this.plural = plural; this.pluralHonorific = pluralHonorific; this.aliases = aliases; this.history = history; this.pronounceable = pronounceable; this.thirdForm = thirdForm; this.smallForm = smallForm; this.sourcesInfo = sourcesInfo; this.hidden = hidden; this.nullPronoun = nullPronoun; } pronoun(): string | null { return this.morphemes[this.config.pronouns.morphemes[0]]; } nameOptions(): string[] { const options: Set = new Set(); const optionsN = (this.morphemes[this.config.pronouns.morphemes[0]] || '').split('&'); if (this.config.pronouns.morphemes.length === 1 || this.config.pronouns.shortMorphemes === 1) { return optionsN; } const optionsG: string[] = (this.morphemes[this.config.pronouns.morphemes[1]] || '').split('&'); const optionsGAlt = this.config.pronouns.morphemes.length > 2 ? (this.morphemes[this.config.pronouns.morphemes[2]] || '').split('&') : []; for (let i = 0; i < optionsN.length; i++) { const optionN = optionsN[i]; let optionG = optionsG[i < optionsG.length - 1 ? i : optionsG.length - 1]; if (optionN === optionG && optionsGAlt.length && this.config.pronouns.shortMorphemes !== 3) { optionG = optionsGAlt[i < optionsGAlt.length - 1 ? i : optionsGAlt.length - 1]; } // If there is no secondary option, don't include a `/` let nameOption = optionG ? `${optionN}/${optionG}` : optionN; if (this.config.pronouns.shortMorphemes === 3) { let thirdForms = (this.morphemes[this.config.pronouns.morphemes[2]] || '').split('&'); if (this.config.locale === 'ru' || this.config.locale === 'ua') { thirdForms = thirdForms.map((x) => `[-${x}]`); } nameOption += `/${thirdForms[i]}`; } else if (this.thirdForm) { nameOption += `/${this.morphemes[this.thirdForm]?.split('&')[i]}`; } options.add(nameOption); } return [...options]; } name(glue?: string): string { return this.nameOptions().join(glue); } clone(removeDescription: boolean = false): Pronoun { return new Pronoun( this.config, this.canonicalName, removeDescription ? '' : this.description, this.normative, clone(this.morphemes), [...this.plural], [...this.pluralHonorific], [...this.aliases], this.history, this.pronounceable, ); } equals(other: Pronoun, ignoreBaseDescription = false): boolean { return this.toString() === other.clone(ignoreBaseDescription).toString(); } merge(other: Pronoun): Pronoun { const descriptionA = Array.isArray(this.description) ? this.description : [this.description]; const descriptionB = Array.isArray(other.description) ? other.description : [other.description]; const config = this.config; return new Pronoun( this.config, `${this.canonicalName}&${other.canonicalName}`, [...descriptionA, ...descriptionB], this.normative && other.normative, buildDict(function* (that, other) { for (const morpheme of config.pronouns.morphemes) { yield [morpheme, `${that.morphemes[morpheme] || ''}&${other.morphemes[morpheme] || ''}`]; // yield [morpheme, buildMorpheme(that.morphemes[morpheme], that.plural) + '&' + buildMorpheme(other.morphemes[morpheme], other.plural)] } }, this, other), [...this.plural, ...other.plural], [...this.pluralHonorific, ...other.pluralHonorific], [], '', false, ); } toMorphemeValues(counter = 0): MorphemeValues { return new MorphemeValues(Object.fromEntries(Object.entries(this.morphemes) .filter(([_morpheme, spelling]) => spelling !== null) .map(([morpheme, spelling]) => { if (spelling === null) { return [morpheme, undefined]; } const spellingOptions = spelling.split('&'); let pronunciation: string | false | undefined; if (this.pronounceable) { const pronunciationOptions = this.pronunciations[morpheme]?.split('&'); pronunciation = pronunciationOptions ? pronunciationOptions[counter % pronunciationOptions.length] : undefined; } else { pronunciation = false; } return [morpheme, { spelling: spellingOptions[counter % spellingOptions.length], pronunciation, }]; }))); } toExampleValues(counter = 0): ExampleValues { return { morphemeValues: this.toMorphemeValues(counter), plural: this.isPlural(counter) }; } isInterchangable(morpheme: string): boolean { return (this.morphemes[getBaseMorpheme(morpheme)] || '').includes('&'); } isPlural(counter = 0): boolean { return this.plural[counter % this.plural.length]; } isPluralHonorific(counter = 0): boolean { return this.pluralHonorific[counter % this.pluralHonorific.length]; } format(str: string): string { return str.replace(/{[^}]+}/g, (m) => (this.morphemes[m.substring(1, m.length - 1)] || '').split('&')[0]); } toArray(): string[] { const elements = Object.values(this.morphemes).map((s) => escape(s)); // TODO #136 // Object.values(this.pronunciations).forEach((p, i) => { // if (p) { // elements[i] += '|' + escape(p); // } // }); if (this.config.pronouns.plurals) { elements.push(this.plural.map((p) => p ? 1 : 0).join('')); if (this.config.pronouns.honorifics) { elements.push(this.pluralHonorific.map((p) => p ? 1 : 0).join('')); } } elements.push(escape(this.description)); return elements; } toString(): string { return this.toArray().join(','); } toStringSlashes(translator: Translator): string | null { if (!this.config.pronouns.generator?.enabled || !this.config.pronouns.generator.slashes) { return null; } let chunks; if (Array.isArray(this.config.pronouns.generator.slashes)) { chunks = this.config.pronouns.generator.slashes.map((m: string) => this.morphemes[m]); } else { chunks = Object.values(this.morphemes); } chunks = chunks.map((chunk: string | null): string => { if (chunk === null) { return '~'; } else if (chunk === '') { // use an extra space because double slashes get replaced by a single one during a request return ' '; } else { return escapeControlSymbols(chunk)!; } }); if (this.plural[0]) { chunks.push(`:${translator.translate('pronouns.slashes.plural')}`); } if (this.pluralHonorific[0]) { chunks.push(`:${translator.translate('pronouns.slashes.pluralHonorific')}`); } if (this.description && !Array.isArray(this.description)) { const escapedDescription = escapeControlSymbols(this.description); chunks.push(`:${translator.translate('pronouns.slashes.description')}=${escapedDescription}`); } // encode a trailing space so that it does not get removed during a request return chunks.join('/').replace(/ $/, encodeURI(' ')); } static from(data: (string | null)[], config: ConfigWithEnabled<'pronouns'>): Pronoun | null { if (!data) { return null; } let extraFields = 1; // description if (config.locale === 'pl') { try { if (['0', '1'].includes(data[data.length - 1]!)) { data.push(''); // description } if (data.length === 22) { data.splice(2, 0, data[4]); data.splice(8, 0, data[8]); data.splice(8, 0, data[8]); } else if (data.length === 23) { data.splice(2, 0, data[4]); data.splice(8, 0, data[8]); } else if (data.length === 24) { data.splice(2, 0, data[4]); } if (data.length < 30) { data = [ data[0], data[1], // g data[2], data[1], data[1]!.replace(/^je/, 'nie'), // d data[4]!.replace(/^je/, ''), data[4], data[4]!.replace(/^je/, 'nie'), // a data[5]!.replace(/^je/, ''), data[5], data[5]!.replace(/^je/, 'nie'), // rest ...data.slice(6), ]; } if (data.length < 31) { data = [ ...data.slice(0, data.length - 8), data[data.length - 8], ...data.slice(data.length - 8), ]; } } catch { return null; } } if (config.pronouns.plurals) { extraFields += 1; if (![0, 1].includes(parseInt(data[config.pronouns.morphemes.length]!))) { return null; } if (config.pronouns.honorifics) { extraFields += 1; if (![0, 1].includes(parseInt(data[config.pronouns.morphemes.length + 1]!))) { return null; } } } if (data.length === config.pronouns.morphemes.length + extraFields - 1) { data.push(''); // description } if (data.length !== config.pronouns.morphemes.length + extraFields || data[0]!.length === 0 || data[data.length - 1]!.length > Pronoun.DESCRIPTION_MAXLENGTH || data.slice(1, data.length - extraFields).filter((s) => s !== null && s.length > 24).length ) { return null; } const m: Record = {}; for (const i in config.pronouns.morphemes) { m[config.pronouns.morphemes[parseInt(i)]] = data[parseInt(i)]!; } return new Pronoun( config, `${m[config.pronouns.morphemes[0]]}/${m[config.pronouns.morphemes[1]]}`, data[data.length - 1]!, false, m, config.pronouns.plurals ? data[config.pronouns.morphemes.length]!.split('').map((p) => parseInt(p) === 1) : [false], config.pronouns.honorifics ? data[config.pronouns.morphemes.length + 1]!.split('').map((p) => parseInt(p) === 1) : [false], [], '__generator__', false, ); } } export class PronounGroup { name: string; pronouns: string[]; description: string | null; key: string | null; hidden: boolean; constructor( name: string, pronouns: string[], description: string | null = null, key: string | null = null, hidden: boolean = false, ) { this.name = name; this.pronouns = pronouns; this.description = description; this.key = key; this.hidden = hidden; } } export class MergedPronounGroup { key: string; groups: { group: PronounGroup; groupPronouns: Record }[]; constructor(key: string, groups: { group: PronounGroup; groupPronouns: Record }[]) { this.key = key; this.groups = groups; } short(translator: Translator): string { const specificTranslationKey = `pronouns.any.group.${this.key}.short`; if (translator.has(specificTranslationKey)) { return translator.translate(specificTranslationKey); } else { return `${translator.translate('pronouns.any.short')} ${this.key}`; } } } export class PronounLibrary { config: Config; groups: PronounGroup[]; pronouns: Record; canonicalNames: string[]; constructor(config: Config, groups: PronounGroup[], pronouns: Record) { this.config = config; this.groups = groups; this.pronouns = pronouns; this.canonicalNames = Object.keys(this.pronouns); } *split(filter: ((pronoun: Pronoun) => boolean) | null = null, includeOthers: boolean = true): Generator<[PronounGroup, Pronoun[]]> { let pronounsLeft = Object.keys(this.pronouns); const that = this; for (const g of this.groups) { yield [g, buildList(function* () { for (const t of g.pronouns) { pronounsLeft = pronounsLeft.filter((i) => i !== t); const pronoun = that.pronouns[t] || t; if (!filter || filter(pronoun)) { yield pronoun; } } })]; } if (!pronounsLeft.length || !includeOthers) { return; } if (this.config.pronouns.others !== undefined) { yield [ new PronounGroup(this.config.pronouns.others, pronounsLeft), buildList(function* () { for (const t of pronounsLeft) { if (!filter || filter(that.pronouns[t])) { yield that.pronouns[t]; } } }), ]; } } byKey(): Record { const ret: Record = {}; for (const g of this.groups) { if (g.key === null) { continue; } if (ret[g.key] === undefined) { ret[g.key] = new MergedPronounGroup(g.key, []); } const p: Record = {}; for (const t of g.pronouns) { const pronoun = this.pronouns[t]; if (!pronoun) { continue; } p[pronoun.canonicalName] = pronoun; } ret[g.key].groups.push({ group: g, groupPronouns: p }); } return ret; } find(pronoun: Pronoun | null): { group: PronounGroup; groupPronouns: Pronoun[] } | null { if (!pronoun) { return null; } for (const [group, groupPronouns] of this.split()) { for (const t of groupPronouns) { if (t.canonicalName === pronoun.canonicalName) { return { group, groupPronouns }; } } } return null; } isCanonical(pronoun: string): boolean { for (const p of pronoun.split('&')) { if (!this.canonicalNames.includes(p)) { return false; } } return true; } } export interface NounRaw { id: string; key?: string | null; words: NounWordsRaw; classInstance?: NounClassInstance | null; categories?: string[]; sources?: string[]; sourcesData?: SourceRaw[]; approved?: boolean; base?: string | null; author?: string | null; } const loadWord = ( config: Config, declensionsByFirstCase: NounDeclensionsByFirstCase, wordRaw: string | NounWord, numerus: Numerus, ): NounWord => { if (typeof wordRaw === 'string') { wordRaw = { spelling: wordRaw }; } if (config.nouns.declension?.enabled && config.nouns.declension.detect) { for (const [declensionSuffix, declensionKey] of Object.entries(declensionsByFirstCase[numerus])) { if (wordRaw.spelling.endsWith(declensionSuffix)) { return { spelling: removeSuffix(wordRaw.spelling, declensionSuffix), declension: declensionKey, }; } } } return wordRaw; }; export interface NounRawWithLoadedWords extends Omit { words: NounWords; } export const loadWords = ( nounRaw: NounRaw, config: Config, declensionsByFirstCase: NounDeclensionsByFirstCase, ): NounRawWithLoadedWords => { const words = Object.fromEntries(Object.entries(nounRaw.words).map(([gender, wordsOfGender]) => { return [gender, Object.fromEntries(Object.entries(wordsOfGender).map(([numerus, wordsOfNumerus]) => { return [numerus, wordsOfNumerus .map((wordRaw) => loadWord(config, declensionsByFirstCase, wordRaw, numerus as Numerus))]; }))]; })); return { ...nounRaw, words }; }; export const filterWordsForConvention = ( nounRaw: NounRawWithLoadedWords, nounConvention: WithKey | undefined = undefined, ): NounRawWithLoadedWords | undefined => { const words = Object.fromEntries(Object.entries(nounRaw.words).map(([gender, wordsOfGender]) => { return [gender, Object.fromEntries(Object.entries(wordsOfGender).map(([numerus, wordsOfNumerus]) => { return [numerus, wordsOfNumerus .filter((word) => { return nounConvention === undefined || ['masc', 'fem'].includes(gender) || word.convention === nounConvention.key; })]; }))]; })); if (nounConvention !== undefined && !hasWordOfGender(words, 'neutr') && !hasWordOfGender(words, 'nb')) { return undefined; } return { ...nounRaw, words }; }; const hasWordOfGender = (words: NounWords, gender: Gender): boolean => { return Object.values(words[gender] ?? {}).flatMap((wordsOfNumerus) => wordsOfNumerus).length > 0; }; export class Noun implements Entry { id: string; key: string; words: NounWords; classInstance: NounClassInstance | null; categories: string[]; sources: string[]; sourcesData: Source[]; approved: boolean; base: string | null; author: string | null; constructor(config: Config, nounRaw: NounRawWithLoadedWords) { this.id = nounRaw.id; this.key = nounRaw.key ?? ''; 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)) ?? []; this.approved = nounRaw.approved ?? true; this.base = nounRaw.base ?? null; this.author = nounRaw.author ?? null; } get firstWords(): string[] { return Object.values(this.words) .map((wordsByNumerus) => wordsByNumerus.singular?.[0]) .filter((word) => word !== undefined) .map((word) => word.spelling); } matches(filter: Filter) { return this.matchesText(filter.text) && (!filter.category || this.categories.includes(filter.category)) && this.matchesModeration(filter.moderation); } matchesText(filter: string): boolean { if (!filter) { return true; } const words = Object.values(this.words) .flatMap((wordsByNumerus) => Object.values(wordsByNumerus)) .flatMap((words) => words); for (const word of words) { const value = word.spelling.toLowerCase(); if (filter.startsWith('-') && value.endsWith(filter.substring(1))) { return true; } else if (filter.endsWith('-') && value.startsWith(filter.substring(0, filter.length - 1))) { return true; } else if (value.indexOf(filter.toLowerCase()) > -1) { return true; } } return false; } matchesModeration(moderationFilter: Filter['moderation']) { switch (moderationFilter) { case undefined: return true; case 'unapproved': return !this.approved; case 'no category': return this.categories.length === 0; default: return false; } } compare(other: Noun, collator: Intl.Collator): number { if (this.approved && !other.approved) { return 1; } if (!this.approved && other.approved) { return -1; } return collator.compare(this.key, other.key); } } export class NounTemplate { masc: string[]; fem: string[]; neutr: string[]; nb: string[]; mascPl: string[]; femPl: string[]; neutrPl: string[]; nbPl: string[]; constructor(masc: string[], fem: string[], neutr: string[], nb: string[], mascPl: string[], femPl: string[], neutrPl: string[], nbPl: string[]) { this.masc = masc; this.fem = fem; this.neutr = neutr; this.nb = nb; this.mascPl = mascPl; this.femPl = femPl; this.neutrPl = neutrPl; this.nbPl = nbPl; } static from(data: NounTemplatesData): NounTemplate { return new NounTemplate( data.masc?.split('/') ?? [], data.fem?.split('/') ?? [], data.neutr?.split('/') ?? [], data.nb?.split('/') ?? [], data.mascPl?.split('/') ?? [], data.femPl?.split('/') ?? [], data.neutrPl?.split('/') ?? [], data.nbPl?.split('/') ?? [], ); } fill(stem: string): Omit { return { key: stem, words: { masc: { singular: this.masc.map((e) => ({ spelling: e.replace('-', stem) })), plural: this.mascPl.map((e) => ({ spelling: e.replace('-', stem) })), }, fem: { singular: this.fem.map((e) => ({ spelling: e.replace('-', stem) })), plural: this.femPl.map((e) => ({ spelling: e.replace('-', stem) })), }, neutr: { singular: this.neutr.map((e) => ({ spelling: e.replace('-', stem) })), plural: this.neutrPl.map((e) => ({ spelling: e.replace('-', stem) })), }, nb: { singular: this.nb.map((e) => ({ spelling: e.replace('-', stem) })), plural: this.nbPl.map((e) => ({ spelling: e.replace('-', stem) })), }, }, categories: [], sources: [], base: null, }; } toString(): string { return [this.masc, this.fem, this.neutr, this.mascPl, this.femPl, this.neutrPl] .map((es) => es.join('/')) .join(', ') ; } } export interface InclusiveEntryRaw { id: string; insteadOf: string; say: string; because: string; author: string; approved?: boolean; base_id?: string | null; categories?: string | null; links?: string | null; clarification?: string | null; } export class InclusiveEntry implements Entry { id: string; insteadOf: string[]; say: string[]; because: string; author: string; approved: boolean; base: string | null; categories: string[]; links: string[]; clarification: string | null; constructor({ id, insteadOf, say, because, author, approved = true, base_id = null, categories = '', links = '[]', clarification = null, }: InclusiveEntryRaw) { this.id = id; this.insteadOf = insteadOf.split('|'); this.say = say.split('|'); this.because = because; this.author = author; this.approved = !!approved; this.base = base_id; this.categories = categories ? categories.split(',') : []; this.links = links ? JSON.parse(links) : []; this.clarification = clarification || null; } matches(filter: Filter) { return this.matchesText(filter.text) && (!filter.category || this.categories.includes(filter.category)) && this.matchesModeration(filter.moderation); } matchesText(filter: string): boolean { if (!filter) { return true; } for (const field of ['insteadOf', 'say'] as const) { for (const value of this[field]) { if (value.toLowerCase().indexOf(filter.toLowerCase()) > -1) { return true; } } } return false; } matchesModeration(moderationFilter: Filter['moderation']) { switch (moderationFilter) { case undefined: return true; case 'unapproved': return !this.approved; case 'no category': return this.categories.length === 0; default: return false; } } } export interface TermsEntryRaw { id: string; term: string; original: string | null; key?: string | null; definition: string; author: string | null; category?: string | null; flags?: string; images?: string; approved?: boolean; base_id?: string | null; locale: string; versions?: TermsEntryRaw[]; } export class TermsEntry implements Entry { id: string; term: string[]; original: string[]; key: string | null; definition: string; author: string | null; categories: string[]; flags: string[]; images: string[]; approved: boolean; base: string | null; locale: string; versions: TermsEntry[]; constructor({ id, term, original, key = null, definition, author, category = null, flags = '[]', images = '', approved = true, base_id = null, locale, versions = [], }: TermsEntryRaw) { this.id = id; this.term = term.split('|'); this.original = original ? original.split('|') : []; this.key = key || null; this.definition = definition; this.author = author; this.categories = category ? category.split(',') : []; this.flags = JSON.parse(flags); this.images = images ? images.split(',') : []; this.approved = !!approved; this.base = base_id; this.locale = locale; this.versions = versions.map((v) => new TermsEntry(v)); } matches(filter: Filter) { return this.matchesText(filter.text) && (!filter.category || this.categories.includes(filter.category)) && this.matchesModeration(filter.moderation); } matchesText(filter: string): boolean { if (!filter) { return true; } if (this.key && this.key.toLowerCase().indexOf(filter.toLowerCase()) > -1) { return true; } for (const field of ['term', 'original'] as const) { for (const value of this[field]) { if (value.toLowerCase().indexOf(filter.toLowerCase()) > -1) { return true; } } } return false; } matchesModeration(moderationFilter: Filter['moderation']) { switch (moderationFilter) { case undefined: return true; case 'unapproved': return !this.approved; case 'no key': return !this.key; case 'no image': return this.flags.length === 0 && this.images.length === 0; case 'no category': return this.categories.length === 0; default: return false; } } } export interface NameRaw { id: string; name: string; origin: string | null; meaning: string | null; usage: string | null; legally: string | null; pros: string | null; cons: string | null; notablePeople: string | null; links: string | null; namedays: string | null; namedaysComment: string | null; approved: boolean; base_id: string | null; author: string | null; } export class Name { id: string; name: string; origin: string | null; meaning: string | null; usage: string | null; legally: string | null; pros: string[]; cons: string[]; notablePeople: string[]; links: string[]; namedays: string[]; namedaysComment: string | null; approved: boolean; base: string | null; author: string | null; constructor({ id, name, origin, meaning, usage, legally, pros, cons, notablePeople, links, namedays, namedaysComment, approved, base_id = null, author = null, }: NameRaw) { this.id = id; this.name = name; this.origin = origin; this.meaning = meaning; this.usage = usage; this.legally = legally; this.pros = pros ? pros.split('|') : []; this.cons = cons ? cons.split('|') : []; this.notablePeople = notablePeople ? notablePeople.split('|') : []; this.links = links ? links.split('|') : []; this.namedays = namedays ? namedays.split('|') : []; this.namedaysComment = namedaysComment; this.approved = !!approved; this.base = base_id; this.author = author; } matches(filter: string): boolean { if (!filter) { return true; } for (const field of ['name', 'meaning'] as const) { if ((this[field] || '').toLowerCase().indexOf(filter.toLowerCase()) > -1) { return true; } } return false; } } export class Person { name: string; description: string; pronouns: Record; sources: string[]; constructor(name: string, description: string, pronouns: string[], sources: string[] = []) { this.name = name; this.description = description; this.pronouns = {}; for (const p of pronouns) { const [language, display, link] = p.split(':'); if (this.pronouns[language] === undefined) { this.pronouns[language] = []; } this.pronouns[language].push({ display, link }); } this.sources = sources; } }