PronounsPage/shared/classes.ts

1337 lines
43 KiB
TypeScript

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';
import type { LocaleCode } from '~~/locale/locales.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<T extends object>(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?: LocaleCode;
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: LocaleCode;
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<SourceType, string> {
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<SourceType, number> {
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<string, Source[]>;
countApproved: number;
countPending: number;
pronouns: string[];
multiple: string[];
cache: Record<string, Source[]>;
constructor(config: Config, rawSources: SourceRaw[]) {
this.sources = rawSources.map((s) => new Source(config, s));
this.map = {};
const multiple = new Set<string>();
const pronouns = new Set<string>();
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<string, Source[] | undefined> {
const sources: Record<string, Source[] | undefined> = {};
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<string, string | null>;
pronunciations: Record<string, string | null>;
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<string, string | null>,
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<string> = 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<string, string> = {};
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<string, Pronoun> }[];
constructor(key: string, groups: { group: PronounGroup; groupPronouns: Record<string, Pronoun> }[]) {
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<string, Pronoun>;
canonicalNames: string[];
constructor(config: Config, groups: PronounGroup[], pronouns: Record<string, Pronoun>) {
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<string, MergedPronounGroup> {
const ret: Record<string, MergedPronounGroup> = {};
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<string, Pronoun> = {};
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<NounRaw, 'words'> {
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<NounConvention> | 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<NounRawWithLoadedWords, 'id'> {
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: LocaleCode;
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: LocaleCode;
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: Partial<Record<LocaleCode, { display: string; link: string }[]>>;
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 [locale, display, link] = p.split(':') as [LocaleCode, string, string];
if (this.pronouns[locale] === undefined) {
this.pronouns[locale] = [];
}
this.pronouns[locale].push({ display, link });
}
this.sources = sources;
}
}