mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-23 04:34:15 -04:00
1337 lines
43 KiB
TypeScript
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;
|
|
}
|
|
}
|