diff --git a/source/constants/dictionaries.ts b/source/constants/dictionaries.ts index 9827b3dc4..495488b40 100644 --- a/source/constants/dictionaries.ts +++ b/source/constants/dictionaries.ts @@ -1,5 +1,23 @@ import type { LearningLanguage } from "logos:constants/languages/learning"; +const sections = [ + "partOfSpeech", + "definitions", + "translations", + "relations", + "syllables", + "pronunciation", + "rhymes", + "audio", + "expressions", + "examples", + "frequency", + "inflection", + "etymology", + "notes", +] as const; +type DictionarySection = (typeof sections)[number]; + type Dictionary = "dexonline" | "dicolink" | "wiktionary" | "wordnik" | "words-api"; const dictionariesByLanguage = Object.freeze({ @@ -25,4 +43,4 @@ const dictionariesByLanguage = Object.freeze({ } satisfies Record as Record); export default Object.freeze({ languages: dictionariesByLanguage }); -export type { Dictionary }; +export type { Dictionary, DictionarySection }; diff --git a/source/constants/emojis.ts b/source/constants/emojis.ts index f2d23944d..ca620053b 100644 --- a/source/constants/emojis.ts +++ b/source/constants/emojis.ts @@ -56,8 +56,13 @@ export default Object.freeze({ word: { word: "📜", definitions: "📚", + translations: "🌐", + relations: "🌳", + pronunciation: "🗣️", expressions: "💐", - etymology: "🌐", + examples: "🏷️", + etymology: "🌱", + notes: "📝", }, music: { song: "🎵", diff --git a/source/constants/parts-of-speech.ts b/source/constants/parts-of-speech.ts index b9d00100b..3bb3467cd 100644 --- a/source/constants/parts-of-speech.ts +++ b/source/constants/parts-of-speech.ts @@ -46,19 +46,20 @@ function isUnknownPartOfSpeech(partOfSpeech: PartOfSpeech): partOfSpeech is "unk return partOfSpeech === "unknown"; } +interface PartOfSpeechDetection { + readonly detected: PartOfSpeech; + readonly original: string; +} function getPartOfSpeech({ terms, learningLanguage, -}: { terms: { exact: string; approximate?: string }; learningLanguage: LearningLanguage }): [ - detected: PartOfSpeech, - original: string, -] { +}: { terms: { exact: string; approximate?: string }; learningLanguage: LearningLanguage }): PartOfSpeechDetection { if (isPartOfSpeech(terms.exact)) { - return [terms.exact, terms.exact]; + return { detected: terms.exact, original: terms.exact }; } if (!(learningLanguage in partsOfSpeechByLanguage)) { - return ["unknown", terms.exact]; + return { detected: "unknown", original: terms.exact }; } const partsOfSpeechLocalised = partsOfSpeechByLanguage[ @@ -66,14 +67,14 @@ function getPartOfSpeech({ ] as Record; if (terms.exact in partsOfSpeechLocalised) { - return [partsOfSpeechLocalised[terms.exact]!, terms.exact]; + return { detected: partsOfSpeechLocalised[terms.exact]!, original: terms.exact }; } if (terms.approximate !== undefined && terms.approximate in partsOfSpeechLocalised) { - return [partsOfSpeechLocalised[terms.approximate]!, terms.approximate]; + return { detected: partsOfSpeechLocalised[terms.approximate]!, original: terms.approximate }; } - return ["unknown", terms.exact]; + return { detected: "unknown", original: terms.exact }; } export { getPartOfSpeech, isUnknownPartOfSpeech }; diff --git a/source/library/adapters/dictionaries/adapter.ts b/source/library/adapters/dictionaries/adapter.ts index 248bf7582..0f310e627 100644 --- a/source/library/adapters/dictionaries/adapter.ts +++ b/source/library/adapters/dictionaries/adapter.ts @@ -1,81 +1,14 @@ +import type { DictionarySection } from "logos:constants/dictionaries.ts"; import type { LearningLanguage } from "logos:constants/languages/learning"; -import type { Licence } from "logos:constants/licences"; -import type { PartOfSpeech } from "logos:constants/parts-of-speech"; +import type { DictionaryEntry } from "logos/adapters/dictionaries/dictionary-entry"; import type { Client } from "logos/client"; import { Logger } from "logos/logger"; -type DictionaryProvisions = - /** Provides definitions of a lemma. */ - | "definitions" - /** Provides a lemma's etymology. */ - | "etymology" - /** Provides relations between a lemma and other words. */ - | "relations" - /** Provides words that rhyme with a given lemma. */ - | "rhymes"; - -interface TaggedValue { - tags?: string[]; - value: T; -} - -interface Expression extends TaggedValue {} - -interface Definition extends TaggedValue { - definitions?: Definition[]; - expressions?: Expression[]; - relations?: Relations; -} - -interface Relations { - synonyms?: string[]; - antonyms?: string[]; - diminutives?: string[]; - augmentatives?: string[]; -} - -interface Rhymes extends TaggedValue {} - -interface Etymology extends TaggedValue {} - -type InflectionTable = { title: string; fields: Discord.CamelizedDiscordEmbedField[] }[]; - -interface DictionaryEntry { - /** The topic word of an entry. */ - lemma: string; - - /** The part of speech of the lemma. */ - partOfSpeech: [detected: PartOfSpeech, text: string]; - - /** The definitions for the lemma in its native language. */ - nativeDefinitions?: Definition[]; - - /** The definitions for the lemma. */ - definitions?: Definition[]; - - /** Relations between the lemma and other words. */ - relations?: Relations; - - /** Rhythmic composition of the lemma. */ - rhymes?: Rhymes; - - /** The expressions for the lemma. */ - expressions?: Expression[]; - - /** The etymologies for the lemma. */ - etymologies?: Etymology[]; - - /** The inflection of the lemma. */ - inflectionTable?: InflectionTable; - - sources: [link: string, licence: Licence][]; -} - abstract class DictionaryAdapter { readonly log: Logger; readonly client: Client; readonly identifier: string; - readonly provides: DictionaryProvisions[]; + readonly provides: DictionarySection[]; readonly supports: LearningLanguage[]; readonly isFallback: boolean; @@ -86,7 +19,7 @@ abstract class DictionaryAdapter { provides, supports, isFallback = false, - }: { identifier: string; provides: DictionaryProvisions[]; supports: LearningLanguage[]; isFallback?: boolean }, + }: { identifier: string; provides: DictionarySection[]; supports: LearningLanguage[]; isFallback?: boolean }, ) { this.log = Logger.create({ identifier, isDebug: client.environment.isDebug }); this.client = client; @@ -137,4 +70,4 @@ abstract class DictionaryAdapter { } export { DictionaryAdapter }; -export type { Definition, DictionaryEntry, Relations, Rhymes, Expression, Etymology, DictionaryProvisions }; +export type { DictionaryEntry }; diff --git a/source/library/adapters/dictionaries/adapters/dexonline.ts b/source/library/adapters/dictionaries/adapters/dexonline.ts new file mode 100644 index 000000000..c33367204 --- /dev/null +++ b/source/library/adapters/dictionaries/adapters/dexonline.ts @@ -0,0 +1,570 @@ +import type { LearningLanguage } from "logos:constants/languages"; +import { type PartOfSpeech, getPartOfSpeech } from "logos:constants/parts-of-speech"; +import { code } from "logos:core/formatting.ts"; +import * as Dexonline from "dexonline-scraper"; +import { DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter"; +import type { + DefinitionField, + EtymologyField, + ExampleField, + ExpressionField, + InflectionField, + PartOfSpeechField, +} from "logos/adapters/dictionaries/dictionary-entry.ts"; +import type { Client } from "logos/client"; + +class DexonlineAdapter extends DictionaryAdapter { + static readonly #supportedPartsOfSpeech: PartOfSpeech[] = ["pronoun", "noun", "verb", "adjective", "determiner"]; + static readonly #futureAuxiliaryForms = ["voi", "vei", "va", "vom", "veți", "vor"]; + static readonly #presumptiveAuxiliaryForms = ["oi", "ăi", "o", "om", "ăți", "or"]; + static readonly #pastAuxiliaryForms = ["am", "ai", "a", "am", "ați", "au"]; + static readonly #presentHaveForms = ["am", "ai", "are", "avem", "aveți", "au"]; + static readonly #imperfectHaveForms = ["aveam", "aveai", "avea", "aveam", "aveați", "aveau"]; + static readonly #conditionalAuxiliaryForms = ["aș", "ai", "ar", "am", "ați", "ar"]; + + constructor(client: Client) { + super(client, { + identifier: "Dexonline", + provides: [ + "partOfSpeech", + "definitions", + "relations", + "expressions", + "examples", + "inflection", + "etymology", + ], + supports: ["Romanian"], + }); + } + + static #isSupported(partOfSpeech: PartOfSpeech): boolean { + return DexonlineAdapter.#supportedPartsOfSpeech.includes(partOfSpeech); + } + + fetch(lemma: string, _: LearningLanguage): Promise { + return Dexonline.get(lemma, { mode: "strict" }); + } + + parse( + interaction: Logos.Interaction, + _: string, + learningLanguage: LearningLanguage, + results: Dexonline.Results, + ): DictionaryEntry[] { + const entries: DictionaryEntry[] = []; + for (const result of results.synthesis) { + const [topicWord] = result.type.split(" "); + if (topicWord === undefined) { + continue; + } + + const partOfSpeech = getPartOfSpeech({ + terms: { exact: result.type, approximate: topicWord }, + learningLanguage: "Romanian", + }); + + entries.push({ + lemma: { value: result.lemma }, + partOfSpeech: { value: partOfSpeech.detected, detected: partOfSpeech.detected }, + definitions: result.definitions.map(DexonlineAdapter.#transformDefinition), + etymology: DexonlineAdapter.#transformEtymology(result.etymology), + expressions: result.expressions.map(DexonlineAdapter.#transformExpression), + examples: result.examples.map(DexonlineAdapter.#transformExample), + inflection: undefined, + sources: [ + { + link: constants.links.dexonlineDefinition(result.lemma), + licence: constants.licences.dictionaries.dexonline, + }, + ], + }); + } + + for (const { table } of results.inflection) { + const partsOfSpeech = table.at(0)?.at(0)?.split("(").at(0)?.trim().split(" / "); + if (partsOfSpeech === undefined) { + continue; + } + + const partOfSpeechFields: PartOfSpeechField[] = []; + for (const partOfSpeech of partsOfSpeech) { + const [partOfSpeechFuzzy] = partOfSpeech.split(" "); + const detection = getPartOfSpeech({ + terms: { exact: partOfSpeech, approximate: partOfSpeechFuzzy }, + learningLanguage, + }); + if (detection === undefined || !DexonlineAdapter.#isSupported(detection.detected)) { + continue; + } + + partOfSpeechFields.push({ value: partOfSpeech, detected: detection.detected }); + } + + if (partOfSpeechFields.length === 0) { + continue; + } + + const entry = entries.find((entry) => + partOfSpeechFields.some( + (partOfSpeechField) => + entry.partOfSpeech !== undefined && + (entry.partOfSpeech.detected === partOfSpeechField.detected || + entry.partOfSpeech.value === partOfSpeechField.value), + ), + ); + if (entry === undefined || entry.partOfSpeech === undefined) { + continue; + } + + const inflectionField = this.#transformInflection(interaction, { + partOfSpeech: entry.partOfSpeech.detected, + table, + }); + if (inflectionField === undefined) { + continue; + } + + entry.inflection = inflectionField; + } + + return entries; + } + + static #transformDefinition(definition: Dexonline.Synthesis.Definition): DefinitionField { + return { + labels: definition.tags, + value: definition.value, + definitions: definition.definitions.map((definition) => DexonlineAdapter.#transformDefinition(definition)), + expressions: definition.expressions.map((expression) => DexonlineAdapter.#transformExpression(expression)), + examples: definition.examples.map((example) => DexonlineAdapter.#transformExample(example)), + relations: definition.relations, + }; + } + + static #transformExpression(expression: Dexonline.Synthesis.Expression): ExpressionField { + return { + labels: expression.tags, + value: expression.value, + expressions: expression.expressions.map((expression) => DexonlineAdapter.#transformExpression(expression)), + examples: expression.examples.map((example) => DexonlineAdapter.#transformExample(example)), + relations: expression.relations, + }; + } + + static #transformExample(example: Dexonline.Synthesis.Example): ExampleField { + return { + labels: example.tags, + value: example.value, + }; + } + + static #transformEtymology(etymology: Dexonline.Synthesis.Etymology[]): EtymologyField { + return { + value: etymology + .map((etymology) => { + const labels = etymology.tags.map((tag) => code(tag)).join(" "); + return `${labels} ${etymology.value}`; + }) + .join("\n"), + }; + } + + #transformInflection( + interaction: Logos.Interaction, + { + partOfSpeech, + table, + }: { + partOfSpeech: PartOfSpeech; + table: string[][]; + }, + ): InflectionField | undefined { + switch (partOfSpeech) { + case "pronoun": { + return this.#pronounTableToFields(interaction, { table }); + } + case "noun": { + return this.#nounTableToFields(interaction, { table }); + } + case "verb": { + return this.#verbTableToFields(interaction, { table }); + } + case "adjective": + case "determiner": { + return this.#adjectiveTableToFields(interaction, { table }); + } + } + + return undefined; + } + + #pronounTableToFields( + interaction: Logos.Interaction, + { table }: { table: string[][] }, + ): InflectionField | undefined { + const [nominativeAccusative, genitiveDative] = table + .slice(1) + .map((columns) => columns.slice(2).join(", ")) + .toChunked(2); + if (nominativeAccusative === undefined || genitiveDative === undefined) { + return undefined; + } + + const strings = constants.contexts.dexonlinePronoun({ + localise: this.client.localise.bind(this.client), + locale: interaction.parameters.show ? interaction.guildLocale : interaction.locale, + }); + return { + tabs: [ + { + title: strings.title, + fields: [ + { + name: constants.special.meta.whitespace, + value: `**${strings.singular}**\n**${strings.plural}**`, + inline: true, + }, + { + name: strings.nominativeAccusative, + value: nominativeAccusative.join("\n"), + inline: true, + }, + { + name: strings.genitiveDative, + value: genitiveDative.map((part) => part.split(", ").at(0)).join("\n"), + inline: true, + }, + ], + }, + ], + }; + } + + #nounTableToFields(interaction: Logos.Interaction, { table }: { table: string[][] }): InflectionField | undefined { + const [nominativeAccusative, genitiveDative, vocative] = table + .slice(1) + .map((columns) => columns.slice(2)) + .toChunked(2); + if (nominativeAccusative === undefined || genitiveDative === undefined) { + return undefined; + } + + if (vocative !== undefined) { + for (const row of vocative) { + row.pop(); + } + + const vocativeForms = vocative[0]?.[0]?.split(", "); + if (vocativeForms !== undefined) { + vocative[0] = vocativeForms; + } + } + + const strings = constants.contexts.dexonlineNoun({ + localise: this.client.localise.bind(this.client), + locale: interaction.locale, + }); + const numberColumn = { + name: constants.special.meta.whitespace, + value: `**${strings.singular}**\n**${strings.plural}**`, + inline: true, + }; + return { + tabs: [ + { + title: strings.title, + fields: [ + numberColumn, + { + name: strings.nominativeAccusative, + value: nominativeAccusative.map((terms) => terms.join(", ")).join("\n"), + inline: true, + }, + { + name: strings.genitiveDative, + value: genitiveDative.map((terms) => terms.join(", ")).join("\n"), + inline: true, + }, + ...(vocative !== undefined + ? [ + numberColumn, + { + name: strings.vocative, + value: vocative.map((terms) => terms.join(", ")).join("\n"), + inline: true, + }, + ] + : []), + ], + }, + ], + }; + } + + #verbTableToFields(interaction: Logos.Interaction, { table }: { table: string[][] }): InflectionField | undefined { + const moods = table + .slice(2, 3) + .map((columns) => columns.slice(2)) + .at(0); + if (moods === undefined) { + return undefined; + } + + if (moods.length < 6 || table.length < 5) { + return undefined; + } + + const [infinitive, longInfinitive, pastParticiple, presentParticiple, imperativeSingle, imperativePlural] = + moods as [string, string, string, string, string, string]; + + const [present, subjunctive, imperfect, simplePerfect, pluperfect] = table + .slice(5) + .map((columns) => columns.slice(2)) + .reduce((columns, row) => { + for (const [element, index] of row.map<[string, number]>((r, i) => [r, i])) { + columns[index] = [...(columns[index] ?? []), element]; + } + return columns; + }, []) as [string[], string[], string[], string[], string[]]; + + const imperative = `${imperativeSingle}\n${imperativePlural}`; + const supine = `de ${pastParticiple}`; + + const subjunctivePresent = subjunctive.map((conjugation) => `să ${conjugation}`); + const subjunctivePerfect = `să fi ${pastParticiple}`; + + const presumptivePresent = DexonlineAdapter.#presumptiveAuxiliaryForms.map( + (auxiliary) => `${auxiliary} ${infinitive}`, + ); + const presumptivePresentProgressive = DexonlineAdapter.#presumptiveAuxiliaryForms.map( + (auxiliary) => `${auxiliary} fi ${presentParticiple}`, + ); + const presumptivePerfect = DexonlineAdapter.#presumptiveAuxiliaryForms.map( + (auxiliary) => `${auxiliary} fi ${pastParticiple}`, + ); + + const indicativePerfect = DexonlineAdapter.#pastAuxiliaryForms.map( + (auxiliary) => `${auxiliary} ${pastParticiple}`, + ); + const indicativeFutureCertain = DexonlineAdapter.#futureAuxiliaryForms.map( + (auxiliary) => `${auxiliary} ${infinitive}`, + ); + const indicativeFuturePlanned = subjunctivePresent.map((conjugation) => `o ${conjugation}`); + const indicativeFutureDecided = DexonlineAdapter.#presentHaveForms.map( + (auxiliary, index) => `${auxiliary} ${subjunctivePresent.at(index)}`, + ); + const indicativeFutureIntended = presumptivePresent; + const indicativeFutureInThePast = DexonlineAdapter.#imperfectHaveForms.map( + (auxiliary, index) => `${auxiliary} ${subjunctivePresent.at(index)}`, + ); + const indicativeFuturePerfect = DexonlineAdapter.#futureAuxiliaryForms.map( + (auxiliary) => `${auxiliary} fi ${pastParticiple}`, + ); + + const conditionalPresent = DexonlineAdapter.#conditionalAuxiliaryForms.map( + (auxiliary) => `${auxiliary} ${infinitive}`, + ); + const conditionalPerfect = DexonlineAdapter.#conditionalAuxiliaryForms.map( + (auxiliary) => `${auxiliary} fi ${pastParticiple}`, + ); + + const strings = constants.contexts.dexonlineVerb({ + localise: this.client.localise.bind(this.client), + locale: interaction.locale, + }); + return { + tabs: [ + { + title: strings.moodsAndParticiples.title, + fields: [ + { + name: strings.moodsAndParticiples.infinitive, + value: `a ${infinitive}`, + inline: true, + }, + { + name: strings.moodsAndParticiples.longInfinitive, + value: longInfinitive, + inline: true, + }, + { + name: strings.moodsAndParticiples.imperative, + value: imperative, + inline: true, + }, + { + name: strings.moodsAndParticiples.supine, + value: supine, + inline: true, + }, + { + name: strings.moodsAndParticiples.present, + value: presentParticiple, + inline: true, + }, + { + name: strings.moodsAndParticiples.past, + value: pastParticiple, + inline: true, + }, + ], + }, + { + title: strings.indicative.title, + fields: [ + { + name: strings.indicative.present, + value: present.join("\n"), + inline: true, + }, + { + name: strings.indicative.preterite, + value: simplePerfect.join("\n"), + inline: true, + }, + { + name: strings.indicative.imperfect, + value: imperfect.join("\n"), + inline: true, + }, + { + name: strings.indicative.pluperfect, + value: pluperfect.join("\n"), + inline: true, + }, + { + name: strings.indicative.perfect, + value: indicativePerfect.join("\n"), + inline: true, + }, + { + name: strings.indicative.futureCertain, + value: indicativeFutureCertain.join("\n"), + inline: true, + }, + { + name: strings.indicative.futurePlanned, + value: indicativeFuturePlanned.join("\n"), + inline: true, + }, + { + name: strings.indicative.futureDecided, + value: indicativeFutureDecided.join("\n"), + inline: true, + }, + { + name: `${strings.indicative.futureIntended} (${strings.indicative.popular})`, + value: indicativeFutureIntended.join("\n"), + inline: true, + }, + { + name: strings.indicative.futureInThePast, + value: indicativeFutureInThePast.join("\n"), + inline: true, + }, + { + name: strings.indicative.futurePerfect, + value: indicativeFuturePerfect.join("\n"), + inline: true, + }, + ], + }, + { + title: strings.subjunctive.title, + fields: [ + { + name: strings.subjunctive.present, + value: subjunctivePresent.join("\n"), + inline: true, + }, + { + name: strings.subjunctive.perfect, + value: subjunctivePerfect, + inline: true, + }, + ], + }, + { + title: strings.conditional.title, + fields: [ + { + name: strings.conditional.present, + value: conditionalPresent.join("\n"), + inline: true, + }, + { + name: strings.conditional.perfect, + value: conditionalPerfect.join("\n"), + inline: true, + }, + ], + }, + { + title: strings.presumptive.title, + fields: [ + { + name: strings.presumptive.present, + value: presumptivePresent.join("\n"), + inline: true, + }, + { + name: strings.presumptive.presentContinuous, + value: presumptivePresentProgressive.join("\n"), + inline: true, + }, + { + name: strings.presumptive.perfect, + value: presumptivePerfect.join("\n"), + inline: true, + }, + ], + }, + ], + }; + } + + #adjectiveTableToFields( + interaction: Logos.Interaction, + { table }: { table: string[][] }, + ): InflectionField | undefined { + const [nominativeAccusative, genitiveDative] = table + .slice(2) + .map((columns) => columns.slice(2, 8)) + .toChunked(2); + if (nominativeAccusative === undefined || genitiveDative === undefined) { + return undefined; + } + + const strings = constants.contexts.dexonlineAdjective({ + localise: this.client.localise.bind(this.client), + locale: interaction.locale, + }); + return { + tabs: [ + { + title: strings.title, + fields: [ + { + name: constants.special.meta.whitespace, + value: `**${strings.singular}**\n**${strings.plural}**`, + inline: true, + }, + { + name: strings.nominativeAccusative, + value: nominativeAccusative.map((terms) => terms.join(", ")).join("\n"), + inline: true, + }, + { + name: strings.genitiveDative, + value: genitiveDative.map((terms) => terms.join(", ")).join("\n"), + inline: true, + }, + ], + }, + ], + }; + } +} + +export { DexonlineAdapter }; diff --git a/source/library/adapters/dictionaries/dicolink.ts b/source/library/adapters/dictionaries/adapters/dicolink.ts similarity index 65% rename from source/library/adapters/dictionaries/dicolink.ts rename to source/library/adapters/dictionaries/adapters/dicolink.ts index 02c3adc73..2ea88eb19 100644 --- a/source/library/adapters/dictionaries/dicolink.ts +++ b/source/library/adapters/dictionaries/adapters/dicolink.ts @@ -1,6 +1,6 @@ import type { LearningLanguage } from "logos:constants/languages/learning"; import { getPartOfSpeech } from "logos:constants/parts-of-speech"; -import { type Definition, DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter"; +import { DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter.ts"; import type { Client } from "logos/client"; interface DicolinkResult { @@ -22,7 +22,7 @@ class DicolinkAdapter extends DictionaryAdapter { constructor(client: Client, { token }: { token: string }) { super(client, { identifier: "Dicolink", - provides: ["definitions"], + provides: ["partOfSpeech", "definitions"], supports: ["French"], }); @@ -55,7 +55,7 @@ class DicolinkAdapter extends DictionaryAdapter { } const data = (await response.json()) as Record[]; - const resultsAll = data.map((result: any) => ({ + const results = data.map((result: any) => ({ id: result.id, partOfSpeech: result.nature, source: result.source, @@ -66,58 +66,50 @@ class DicolinkAdapter extends DictionaryAdapter { dicolinkUrl: result.dicolinkUrl, })); - return this.#pickResultsFromBestSource(resultsAll); + return DicolinkAdapter.#pickResultsFromBestSource(results); } parse( _: Logos.Interaction, lemma: string, learningLanguage: LearningLanguage, - resultsAll: DicolinkResult[], + results: DicolinkResult[], ): DictionaryEntry[] { const entries: DictionaryEntry[] = []; - - const sources = resultsAll.map((result) => result.source); - const resultsDistributed = resultsAll.reduce( - (distribution, result) => { - distribution[result.source]?.push(result); - return distribution; - }, - Object.fromEntries(sources.map((source) => [source, []])) as Record, - ); - const results = Object.values(resultsDistributed).reduce((a, b) => { - return a.length > b.length ? a : b; - }); - - for (const result of results) { - const partOfSpeechTopicWord = result.partOfSpeech.split(" ").at(0) ?? result.partOfSpeech; - const partOfSpeech = getPartOfSpeech({ - terms: { exact: result.partOfSpeech, approximate: partOfSpeechTopicWord }, + for (const { partOfSpeech, definition } of results) { + const [partOfSpeechFuzzy] = partOfSpeech.split(" "); + const detection = getPartOfSpeech({ + terms: { exact: partOfSpeech, approximate: partOfSpeechFuzzy }, learningLanguage, }); - const definition: Definition = { value: result.definition }; - const lastEntry = entries.at(-1); - if ( - lastEntry !== undefined && - (lastEntry.partOfSpeech[0] === partOfSpeech[0] || lastEntry.partOfSpeech[1] === partOfSpeech[1]) - ) { - lastEntry.nativeDefinitions?.push(definition); + if (lastEntry !== undefined && lastEntry.partOfSpeech !== undefined) { + if ( + lastEntry.partOfSpeech.detected === detection.detected || + lastEntry.partOfSpeech.value === partOfSpeech + ) { + lastEntry.definitions?.push({ value: definition }); + } continue; } entries.push({ - lemma, - partOfSpeech, - nativeDefinitions: [definition], - sources: [[constants.links.dicolinkDefinition(lemma), constants.licences.dictionaries.dicolink]], + lemma: { value: lemma }, + partOfSpeech: { value: partOfSpeech, detected: detection.detected }, + definitions: [{ value: definition }], + sources: [ + { + link: constants.links.dicolinkDefinition(lemma), + licence: constants.licences.dictionaries.dicolink, + }, + ], }); } return entries; } - #pickResultsFromBestSource(resultsAll: DicolinkResult[]): DicolinkResult[] { + static #pickResultsFromBestSource(resultsAll: DicolinkResult[]): DicolinkResult[] { const sourcesAll = Array.from(new Set(resultsAll.map((result) => result.source)).values()); const sources = sourcesAll.filter((source) => !DicolinkAdapter.#excludedSources.includes(source)); diff --git a/source/library/adapters/dictionaries/wiktionary.ts b/source/library/adapters/dictionaries/adapters/wiktionary.ts similarity index 54% rename from source/library/adapters/dictionaries/wiktionary.ts rename to source/library/adapters/dictionaries/adapters/wiktionary.ts index 3221ae735..6df7d4b62 100644 --- a/source/library/adapters/dictionaries/wiktionary.ts +++ b/source/library/adapters/dictionaries/adapters/wiktionary.ts @@ -1,12 +1,7 @@ -import { getFeatureLanguage } from "logos:constants/languages"; -import { type LearningLanguage, getWiktionaryLanguageName } from "logos:constants/languages/learning"; +import type { LearningLanguage } from "logos:constants/languages/learning"; +import { getWiktionaryLanguageName } from "logos:constants/languages/learning"; import { getPartOfSpeech } from "logos:constants/parts-of-speech"; -import { - type Definition, - DictionaryAdapter, - type DictionaryEntry, - type Etymology, -} from "logos/adapters/dictionaries/adapter"; +import { DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter"; import type { Client } from "logos/client"; import * as Wiktionary from "wiktionary-scraper"; @@ -14,7 +9,7 @@ class WiktionaryAdapter extends DictionaryAdapter { constructor(client: Client) { super(client, { identifier: "Wiktionary", - provides: ["definitions", "etymology"], + provides: ["partOfSpeech", "definitions", "translations", "etymology"], supports: [ "Armenian/Eastern", "Armenian/Western", @@ -62,34 +57,36 @@ class WiktionaryAdapter extends DictionaryAdapter { parse( _: Logos.Interaction, - lemma: string, - language: LearningLanguage, + __: string, + learningLanguage: LearningLanguage, results: Wiktionary.Entry[], ): DictionaryEntry[] { + const targetLanguageWiktionary = getWiktionaryLanguageName(learningLanguage); + const entries: DictionaryEntry[] = []; - for (const result of results) { - if (result.partOfSpeech === undefined || result.definitions === undefined) { + for (const { lemma, partOfSpeech, etymology, definitions } of results) { + if (partOfSpeech === undefined || definitions === undefined) { continue; } - const partOfSpeech = getPartOfSpeech({ - terms: { exact: result.partOfSpeech }, - learningLanguage: "English/American", + const [partOfSpeechFuzzy] = partOfSpeech.split(" ").reverse(); + const detection = getPartOfSpeech({ + terms: { exact: partOfSpeech, approximate: partOfSpeechFuzzy }, + learningLanguage, }); - const etymologies: Etymology[] | undefined = - result.etymology !== undefined ? [{ value: result.etymology.paragraphs.join("\n\n") }] : undefined; - const definitions: Definition[] = result.definitions.flatMap((definition) => - definition.fields.map((field) => ({ value: field.value })), - ); + const etymologyContents = etymology !== undefined ? etymology.paragraphs.join("\n\n") : undefined; entries.push({ lemma, - partOfSpeech, - ...(getFeatureLanguage(language) !== "English" ? { definitions } : { nativeDefinitions: definitions }), - etymologies, + partOfSpeech: { value: partOfSpeech, detected: detection.detected }, + definitions: definitions.flatMap(({ fields }) => fields.map((field) => ({ value: field.value }))), + etymology: etymologyContents !== undefined ? { value: etymologyContents } : undefined, sources: [ - [constants.links.wiktionaryDefinition(lemma, language), constants.licences.dictionaries.wiktionary], + { + link: constants.links.wiktionaryDefinition(lemma.value, targetLanguageWiktionary), + licence: constants.licences.dictionaries.wiktionary, + }, ], }); } diff --git a/source/library/adapters/dictionaries/wordnik.ts b/source/library/adapters/dictionaries/adapters/wordnik.ts similarity index 70% rename from source/library/adapters/dictionaries/wordnik.ts rename to source/library/adapters/dictionaries/adapters/wordnik.ts index 182019338..887463bbc 100644 --- a/source/library/adapters/dictionaries/wordnik.ts +++ b/source/library/adapters/dictionaries/adapters/wordnik.ts @@ -1,18 +1,14 @@ import type { LearningLanguage } from "logos:constants/languages/learning"; -import { - DictionaryAdapter, - type DictionaryEntry, - type Relations, - type Rhymes, -} from "logos/adapters/dictionaries/adapter"; -import type { Client } from "logos/client"; - -interface Result { +import { DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter"; +import type { RelationField, RhymeField } from "logos/adapters/dictionaries/dictionary-entry"; +import type { Client } from "logos/client.ts"; + +interface WordnikResult { readonly relationshipType: string; readonly words: string[]; } -class WordnikAdapter extends DictionaryAdapter { +class WordnikAdapter extends DictionaryAdapter { readonly token: string; constructor(client: Client, { token }: { token: string }) { @@ -34,7 +30,7 @@ class WordnikAdapter extends DictionaryAdapter { return new WordnikAdapter(client, { token: client.environment.wordnikSecret }); } - async fetch(lemma: string, _: LearningLanguage): Promise { + async fetch(lemma: string, _: LearningLanguage): Promise { const response = await fetch( `${constants.endpoints.wordnik.relatedWords(lemma)}?useCanonical=true&api_key=${this.token}`, { @@ -47,10 +43,10 @@ class WordnikAdapter extends DictionaryAdapter { return undefined; } - return (await response.json()) as Result[]; + return (await response.json()) as WordnikResult[]; } - parse(_: Logos.Interaction, lemma: string, __: LearningLanguage, results: Result[]): DictionaryEntry[] { + parse(_: Logos.Interaction, lemma: string, __: LearningLanguage, results: WordnikResult[]): DictionaryEntry[] { const synonyms: string[] = []; const antonyms: string[] = []; const rhymes: string[] = []; @@ -73,7 +69,7 @@ class WordnikAdapter extends DictionaryAdapter { } } - let relationField: Relations | undefined; + let relationField: RelationField | undefined; if (synonyms.length > 0 || antonyms.length > 0) { relationField = {}; @@ -86,7 +82,7 @@ class WordnikAdapter extends DictionaryAdapter { } } - let rhymeField: Rhymes | undefined; + let rhymeField: RhymeField | undefined; if (rhymes.length > 0) { rhymeField = { value: rhymes.join(", ") }; } @@ -97,11 +93,15 @@ class WordnikAdapter extends DictionaryAdapter { return [ { - lemma, - partOfSpeech: ["unknown", "unknown"], + lemma: { value: lemma }, relations: relationField, rhymes: rhymeField, - sources: [[constants.links.wordnikDefinitionLink(lemma), constants.licences.dictionaries.wordnik]], + sources: [ + { + link: constants.links.wordnikDefinitionLink(lemma), + licence: constants.licences.dictionaries.wordnik, + }, + ], }, ]; } diff --git a/source/library/adapters/dictionaries/words-api.ts b/source/library/adapters/dictionaries/adapters/words-api.ts similarity index 53% rename from source/library/adapters/dictionaries/words-api.ts rename to source/library/adapters/dictionaries/adapters/words-api.ts index 057ce6d83..df82be292 100644 --- a/source/library/adapters/dictionaries/words-api.ts +++ b/source/library/adapters/dictionaries/adapters/words-api.ts @@ -1,23 +1,24 @@ import type { LearningLanguage } from "logos:constants/languages/learning"; import { getPartOfSpeech } from "logos:constants/parts-of-speech"; -import { type Definition, DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter"; +import { DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter"; import type { Client } from "logos/client"; type SearchResult = { readonly results: { - readonly definition: string; readonly partOfSpeech: string; + readonly definition: string; readonly synonyms?: string[]; readonly typeof?: string[]; readonly derivation?: string[]; }[]; - readonly syllables: { + readonly syllables?: { readonly count: number; readonly list: string[]; }; - readonly pronunciation: { + readonly pronunciation?: Record & { readonly all: string; }; + readonly frequency?: number; }; class WordsAPIAdapter extends DictionaryAdapter { @@ -26,7 +27,7 @@ class WordsAPIAdapter extends DictionaryAdapter { constructor(client: Client, { token }: { token: string }) { super(client, { identifier: "WordsAPI", - provides: ["definitions"], + provides: ["partOfSpeech", "definitions", "relations", "syllables", "pronunciation", "frequency"], supports: ["English/American", "English/British"], isFallback: true, }); @@ -63,32 +64,46 @@ class WordsAPIAdapter extends DictionaryAdapter { learningLanguage: LearningLanguage, searchResult: SearchResult, ): DictionaryEntry[] { + const { results, pronunciation, syllables, frequency } = searchResult; + const entries: DictionaryEntry[] = []; - for (const result of searchResult.results) { - const partOfSpeech = getPartOfSpeech({ - terms: { exact: result.partOfSpeech, approximate: result.partOfSpeech }, + for (const { partOfSpeech, definition, synonyms } of results) { + const [partOfSpeechFuzzy] = partOfSpeech.split(" ").reverse(); + const detection = getPartOfSpeech({ + terms: { exact: partOfSpeech, approximate: partOfSpeechFuzzy }, learningLanguage, }); - const definition: Definition = { value: result.definition }; - if (result.synonyms !== undefined && result.synonyms.length > 0) { - definition.relations = { synonyms: result.synonyms }; - } - const lastEntry = entries.at(-1); - if ( - lastEntry !== undefined && - (lastEntry.partOfSpeech[0] === partOfSpeech[0] || lastEntry.partOfSpeech[1] === partOfSpeech[1]) - ) { - lastEntry.nativeDefinitions?.push(definition); + if (lastEntry !== undefined && lastEntry.partOfSpeech !== undefined) { + if ( + lastEntry.partOfSpeech.detected === detection.detected || + lastEntry.partOfSpeech.value === partOfSpeech + ) { + lastEntry.definitions?.push({ value: definition }); + } continue; } entries.push({ - lemma, - partOfSpeech, - nativeDefinitions: [definition], - sources: [[constants.links.wordsAPIDefinition(), constants.licences.dictionaries.wordsApi]], + lemma: { value: lemma }, + partOfSpeech: { value: partOfSpeech, detected: detection.detected }, + definitions: [{ value: definition, relations: { synonyms } }], + syllables: + syllables !== undefined + ? { labels: [syllables.count.toString()], value: syllables.list.join("|") } + : undefined, + pronunciation: + pronunciation !== undefined && partOfSpeech in pronunciation + ? { labels: ["IPA"], value: pronunciation[partOfSpeech]! } + : undefined, + frequency: frequency !== undefined ? { value: frequency / 5 } : undefined, + sources: [ + { + link: constants.links.wordsAPIDefinition(), + licence: constants.licences.dictionaries.wordsApi, + }, + ], }); } return entries; diff --git a/source/library/adapters/dictionaries/dexonline.ts b/source/library/adapters/dictionaries/dexonline.ts deleted file mode 100644 index 613c5cf48..000000000 --- a/source/library/adapters/dictionaries/dexonline.ts +++ /dev/null @@ -1,531 +0,0 @@ -import type { LearningLanguage } from "logos:constants/languages/learning"; -import { type PartOfSpeech, getPartOfSpeech } from "logos:constants/parts-of-speech"; -import * as Dexonline from "dexonline-scraper"; -import { DictionaryAdapter, type DictionaryEntry } from "logos/adapters/dictionaries/adapter"; -import type { Client } from "logos/client"; - -type InflectionTable = NonNullable; - -class DexonlineAdapter extends DictionaryAdapter { - static readonly #classesWithInflections: PartOfSpeech[] = ["pronoun", "noun", "verb", "adjective", "determiner"]; - static readonly #futureAuxiliaryForms = ["voi", "vei", "va", "vom", "veți", "vor"]; - static readonly #presumptiveAuxiliaryForms = ["oi", "ăi", "o", "om", "ăți", "or"]; - static readonly #pastAuxiliaryForms = ["am", "ai", "a", "am", "ați", "au"]; - static readonly #presentHaveForms = ["am", "ai", "are", "avem", "aveți", "au"]; - static readonly #imperfectHaveForms = ["aveam", "aveai", "avea", "aveam", "aveați", "aveau"]; - static readonly #conditionalAuxiliaryForms = ["aș", "ai", "ar", "am", "ați", "ar"]; - - constructor(client: Client) { - super(client, { - identifier: "Dexonline", - provides: ["definitions", "etymology"], - supports: ["Romanian"], - }); - } - - static #hasInflections(partOfSpeech: PartOfSpeech): boolean { - return DexonlineAdapter.#classesWithInflections.includes(partOfSpeech); - } - - fetch(lemma: string, _: LearningLanguage): Promise { - return Dexonline.get(lemma, { mode: "strict" }); - } - - parse( - interaction: Logos.Interaction, - _: string, - __: LearningLanguage, - results: Dexonline.Results, - ): DictionaryEntry[] { - const entries: DictionaryEntry[] = []; - for (const result of results.synthesis) { - const [topicWord] = result.type.split(" "); - if (topicWord === undefined) { - continue; - } - - const partOfSpeech = getPartOfSpeech({ - terms: { exact: result.type, approximate: topicWord }, - learningLanguage: "Romanian", - }); - - entries.push({ - lemma: result.lemma, - partOfSpeech, - nativeDefinitions: result.definitions, - etymologies: result.etymology, - expressions: result.expressions, - inflectionTable: undefined, - sources: [ - [constants.links.dexonlineDefinition(result.lemma), constants.licences.dictionaries.dexonline], - ], - }); - } - - for (const { table } of results.inflection) { - const partsOfSpeechRaw = table.at(0)?.at(0)?.split("(").at(0)?.trim().split(" / "); - if (partsOfSpeechRaw === undefined) { - continue; - } - - const partsOfSpeech: [detected: PartOfSpeech, original: string][] = []; - for (const partOfSpeechRaw of partsOfSpeechRaw) { - const [topicWord] = partOfSpeechRaw.split(" "); - if (topicWord === undefined) { - continue; - } - - const partOfSpeech = getPartOfSpeech({ - terms: { exact: partOfSpeechRaw, approximate: topicWord }, - learningLanguage: "Romanian", - }); - if (partOfSpeech === undefined) { - continue; - } - - const [detected, _] = partOfSpeech; - if (!DexonlineAdapter.#hasInflections(detected)) { - continue; - } - - partsOfSpeech.push(partOfSpeech); - } - - if (partsOfSpeech.length === 0) { - continue; - } - - const entry = entries.find((entry) => - partsOfSpeech.some( - (partOfSpeech) => - entry.partOfSpeech[1] === partOfSpeech[1] || entry.partOfSpeech[0] === partOfSpeech[0], - ), - ); - if (entry === undefined) { - continue; - } - - entry.inflectionTable = this.tableRowsToFields(interaction, entry.partOfSpeech[0], table); - } - - return entries; - } - - private tableRowsToFields( - interaction: Logos.Interaction, - partOfSpeech: PartOfSpeech, - table: string[][], - ): InflectionTable | undefined { - switch (partOfSpeech) { - case "pronoun": { - return this.pronounTableToFields(interaction, table); - } - case "noun": { - return this.nounTableToFields(interaction, table); - } - case "verb": { - return this.verbTableToFields(interaction, table); - } - case "adjective": { - return this.adjectiveTableToFields(interaction, table); - } - case "determiner": { - return this.determinerTableToFields(interaction, table); - } - } - - return []; - } - - private pronounTableToFields(interaction: Logos.Interaction, table: string[][]): InflectionTable | undefined { - const [nominativeAccusative, genitiveDative] = table - .slice(1) - .map((columns) => columns.slice(2).join(", ")) - .toChunked(2); - if (nominativeAccusative === undefined || genitiveDative === undefined) { - return undefined; - } - - const strings = constants.contexts.dexonlinePronoun({ - localise: this.client.localise, - locale: interaction.parameters.show ? interaction.guildLocale : interaction.locale, - }); - return [ - { - title: strings.title, - fields: [ - { - name: constants.special.meta.whitespace, - value: `**${strings.singular}**\n**${strings.plural}**`, - inline: true, - }, - { - name: strings.nominativeAccusative, - value: nominativeAccusative.join("\n"), - inline: true, - }, - { - name: strings.genitiveDative, - value: genitiveDative.map((part) => part.split(", ").at(0)).join("\n"), - inline: true, - }, - ], - }, - ]; - } - - private nounTableToFields(interaction: Logos.Interaction, table: string[][]): InflectionTable | undefined { - const [nominativeAccusative, genitiveDative, vocative] = table - .slice(1) - .map((columns) => columns.slice(2)) - .toChunked(2); - if (nominativeAccusative === undefined || genitiveDative === undefined) { - return undefined; - } - - if (vocative !== undefined) { - for (const row of vocative) { - row.pop(); - } - - const vocativeForms = vocative[0]?.[0]?.split(", "); - if (vocativeForms !== undefined) { - vocative[0] = vocativeForms; - } - } - - const strings = constants.contexts.dexonlineNoun({ - localise: this.client.localise, - locale: interaction.locale, - }); - const numberColumn = { - name: constants.special.meta.whitespace, - value: `**${strings.singular}**\n**${strings.plural}**`, - inline: true, - }; - return [ - { - title: strings.title, - fields: [ - numberColumn, - { - name: strings.nominativeAccusative, - value: nominativeAccusative.map((terms) => terms.join(", ")).join("\n"), - inline: true, - }, - { - name: strings.genitiveDative, - value: genitiveDative.map((terms) => terms.join(", ")).join("\n"), - inline: true, - }, - ...(vocative !== undefined - ? [ - numberColumn, - { - name: strings.vocative, - value: vocative.map((terms) => terms.join(", ")).join("\n"), - inline: true, - }, - ] - : []), - ], - }, - ]; - } - - private verbTableToFields(interaction: Logos.Interaction, table: string[][]): InflectionTable | undefined { - const moods = table - .slice(2, 3) - .map((columns) => columns.slice(2)) - .at(0); - if (moods === undefined) { - return undefined; - } - - if (moods.length < 6 || table.length < 5) { - return undefined; - } - - const [infinitive, longInfinitive, pastParticiple, presentParticiple, imperativeSingle, imperativePlural] = - moods as [string, string, string, string, string, string]; - - const [present, subjunctive, imperfect, simplePerfect, pluperfect] = table - .slice(5) - .map((columns) => columns.slice(2)) - .reduce((columns, row) => { - for (const [element, index] of row.map<[string, number]>((r, i) => [r, i])) { - columns[index] = [...(columns[index] ?? []), element]; - } - return columns; - }, []) as [string[], string[], string[], string[], string[]]; - - const imperative = `${imperativeSingle}\n${imperativePlural}`; - const supine = `de ${pastParticiple}`; - - const subjunctivePresent = subjunctive.map((conjugation) => `să ${conjugation}`); - const subjunctivePerfect = `să fi ${pastParticiple}`; - - const presumptivePresent = DexonlineAdapter.#presumptiveAuxiliaryForms.map( - (auxiliary) => `${auxiliary} ${infinitive}`, - ); - const presumptivePresentProgressive = DexonlineAdapter.#presumptiveAuxiliaryForms.map( - (auxiliary) => `${auxiliary} fi ${presentParticiple}`, - ); - const presumptivePerfect = DexonlineAdapter.#presumptiveAuxiliaryForms.map( - (auxiliary) => `${auxiliary} fi ${pastParticiple}`, - ); - - const indicativePerfect = DexonlineAdapter.#pastAuxiliaryForms.map( - (auxiliary) => `${auxiliary} ${pastParticiple}`, - ); - const indicativeFutureCertain = DexonlineAdapter.#futureAuxiliaryForms.map( - (auxiliary) => `${auxiliary} ${infinitive}`, - ); - const indicativeFuturePlanned = subjunctivePresent.map((conjugation) => `o ${conjugation}`); - const indicativeFutureDecided = DexonlineAdapter.#presentHaveForms.map( - (auxiliary, index) => `${auxiliary} ${subjunctivePresent.at(index)}`, - ); - const indicativeFutureIntended = presumptivePresent; - const indicativeFutureInThePast = DexonlineAdapter.#imperfectHaveForms.map( - (auxiliary, index) => `${auxiliary} ${subjunctivePresent.at(index)}`, - ); - const indicativeFuturePerfect = DexonlineAdapter.#futureAuxiliaryForms.map( - (auxiliary) => `${auxiliary} fi ${pastParticiple}`, - ); - - const conditionalPresent = DexonlineAdapter.#conditionalAuxiliaryForms.map( - (auxiliary) => `${auxiliary} ${infinitive}`, - ); - const conditionalPerfect = DexonlineAdapter.#conditionalAuxiliaryForms.map( - (auxiliary) => `${auxiliary} fi ${pastParticiple}`, - ); - - const strings = constants.contexts.dexonlineVerb({ - localise: this.client.localise, - locale: interaction.locale, - }); - return [ - { - title: strings.moodsAndParticiples.title, - fields: [ - { - name: strings.moodsAndParticiples.infinitive, - value: `a ${infinitive}`, - inline: true, - }, - { - name: strings.moodsAndParticiples.longInfinitive, - value: longInfinitive, - inline: true, - }, - { - name: strings.moodsAndParticiples.imperative, - value: imperative, - inline: true, - }, - { - name: strings.moodsAndParticiples.supine, - value: supine, - inline: true, - }, - { - name: strings.moodsAndParticiples.present, - value: presentParticiple, - inline: true, - }, - { - name: strings.moodsAndParticiples.past, - value: pastParticiple, - inline: true, - }, - ], - }, - { - title: strings.indicative.title, - fields: [ - { - name: strings.indicative.present, - value: present.join("\n"), - inline: true, - }, - { - name: strings.indicative.preterite, - value: simplePerfect.join("\n"), - inline: true, - }, - { - name: strings.indicative.imperfect, - value: imperfect.join("\n"), - inline: true, - }, - { - name: strings.indicative.pluperfect, - value: pluperfect.join("\n"), - inline: true, - }, - { - name: strings.indicative.perfect, - value: indicativePerfect.join("\n"), - inline: true, - }, - { - name: strings.indicative.futureCertain, - value: indicativeFutureCertain.join("\n"), - inline: true, - }, - { - name: strings.indicative.futurePlanned, - value: indicativeFuturePlanned.join("\n"), - inline: true, - }, - { - name: strings.indicative.futureDecided, - value: indicativeFutureDecided.join("\n"), - inline: true, - }, - { - name: `${strings.indicative.futureIntended} (${strings.indicative.popular})`, - value: indicativeFutureIntended.join("\n"), - inline: true, - }, - { - name: strings.indicative.futureInThePast, - value: indicativeFutureInThePast.join("\n"), - inline: true, - }, - { - name: strings.indicative.futurePerfect, - value: indicativeFuturePerfect.join("\n"), - inline: true, - }, - ], - }, - { - title: strings.subjunctive.title, - fields: [ - { - name: strings.subjunctive.present, - value: subjunctivePresent.join("\n"), - inline: true, - }, - { - name: strings.subjunctive.perfect, - value: subjunctivePerfect, - inline: true, - }, - ], - }, - { - title: strings.conditional.title, - fields: [ - { - name: strings.conditional.present, - value: conditionalPresent.join("\n"), - inline: true, - }, - { - name: strings.conditional.perfect, - value: conditionalPerfect.join("\n"), - inline: true, - }, - ], - }, - { - title: strings.presumptive.title, - fields: [ - { - name: strings.presumptive.present, - value: presumptivePresent.join("\n"), - inline: true, - }, - { - name: strings.presumptive.presentContinuous, - value: presumptivePresentProgressive.join("\n"), - inline: true, - }, - { - name: strings.presumptive.perfect, - value: presumptivePerfect.join("\n"), - inline: true, - }, - ], - }, - ]; - } - - private adjectiveTableToFields(interaction: Logos.Interaction, table: string[][]): InflectionTable | undefined { - const [nominativeAccusative, genitiveDative] = table - .slice(2) - .map((columns) => columns.slice(2, 8)) - .toChunked(2); - if (nominativeAccusative === undefined || genitiveDative === undefined) { - return undefined; - } - - const strings = constants.contexts.dexonlineAdjective({ - localise: this.client.localise, - locale: interaction.locale, - }); - return [ - { - title: strings.title, - fields: [ - { - name: constants.special.meta.whitespace, - value: `**${strings.singular}**\n**${strings.plural}**`, - inline: true, - }, - { - name: strings.nominativeAccusative, - value: nominativeAccusative.map((terms) => terms.join(", ")).join("\n"), - inline: true, - }, - { - name: strings.genitiveDative, - value: genitiveDative.map((terms) => terms.join(", ")).join("\n"), - inline: true, - }, - ], - }, - ]; - } - - private determinerTableToFields(interaction: Logos.Interaction, table: string[][]): InflectionTable | undefined { - const [nominativeAccusative, genitiveDative] = table - .slice(2) - .map((columns) => columns.slice(2, 8)) - .toChunked(2); - if (nominativeAccusative === undefined || genitiveDative === undefined) { - return undefined; - } - - const strings = constants.contexts.dexonlineDeterminer({ - localise: this.client.localise, - locale: interaction.locale, - }); - return [ - { - title: strings.title, - fields: [ - { - name: constants.special.meta.whitespace, - value: `**${strings.singular}**\n**${strings.plural}**`, - inline: true, - }, - { - name: strings.nominativeAccusative, - value: nominativeAccusative.map((terms) => terms.join(", ")).join("\n"), - inline: true, - }, - { - name: strings.genitiveDative, - value: genitiveDative.map((terms) => terms.join(", ")).join("\n"), - inline: true, - }, - ], - }, - ]; - } -} - -export { DexonlineAdapter }; diff --git a/source/library/adapters/dictionaries/dictionary-entry.ts b/source/library/adapters/dictionaries/dictionary-entry.ts new file mode 100644 index 000000000..c3e37bb41 --- /dev/null +++ b/source/library/adapters/dictionaries/dictionary-entry.ts @@ -0,0 +1,131 @@ +import type { DictionarySection } from "logos:constants/dictionaries.ts"; +import type { Licence } from "logos:constants/licences.ts"; +import type { PartOfSpeech } from "logos:constants/parts-of-speech.ts"; + +type LabelledField = { + labels?: string[]; + value: string; +}; + +type LemmaField = LabelledField; +type PartOfSpeechField = LabelledField & { + detected: PartOfSpeech; +}; +type MeaningField = LabelledField & + Partial<{ + relations: RelationField; + definitions: DefinitionField[]; + expressions: ExpressionField[]; + examples: ExampleField[]; + }>; +type DefinitionField = MeaningField; +type TranslationField = MeaningField; +type RelationField = Partial<{ + synonyms: string[]; + antonyms: string[]; + diminutives: string[]; + augmentatives: string[]; +}>; +type SyllableField = LabelledField; +type PronunciationField = LabelledField; +type AudioField = LabelledField; +type RhymeField = LabelledField; +type ExpressionField = LabelledField & + Partial<{ + relations: RelationField; + expressions: ExpressionField[]; + examples: ExampleField[]; + }>; +type ExampleField = LabelledField & Partial<{ expressions: ExpressionField[] }>; +type FrequencyField = { value: number }; +type InflectionField = { + tabs: Discord.CamelizedDiscordEmbed[]; +}; +type EtymologyField = LabelledField; +type NoteField = LabelledField; + +interface DictionaryEntrySource { + /** Direct link to the lemma page. */ + link: string; + + /** Licence under which information about the lemma was obtained. */ + licence: Licence; +} + +interface DictionaryEntry extends Partial> { + /** Sources of information about the lemma. */ + sources: DictionaryEntrySource[]; + + /** Topic word of the dictionary entry. */ + lemma: LemmaField; + + /** Part of speech of the lemma. */ + partOfSpeech?: PartOfSpeechField; + + /** Definitions belonging to the lemma. */ + definitions?: DefinitionField[]; + + /** Translations of the lemma. */ + translations?: TranslationField[]; + + /** Relations between the lemma and other words. */ + relations?: RelationField; + + /** Syllable composition of the lemma. */ + syllables?: SyllableField; + + /** Pronunciation of the lemma. */ + pronunciation?: PronunciationField; + + /** Rhythmic composition of the lemma. */ + rhymes?: RhymeField; + + /** Audio example of pronunciation of the lemma. */ + audio?: AudioField[]; + + /** Expressions featuring the lemma. */ + expressions?: ExpressionField[]; + + /** Examples of the lemma used in a sentence. */ + examples?: ExampleField[]; + + /** Indication of how frequently the lemma is used. */ + frequency?: FrequencyField; + + /** Inflection of the lemma. */ + inflection?: InflectionField; + + /** Origin of the lemma. */ + etymology?: EtymologyField; + + /** Additional notes on usage, prevalence, etc. */ + notes?: NoteField; +} + +// TODO(vxern): Include. +// type DictionaryEntryField = keyof DictionaryEntry; +// +// const requiredDictionaryEntryFields = ["sources", "lemma"] satisfies DictionaryEntryField[]; +// type RequiredDictionaryEntryFields = (typeof requiredDictionaryEntryFields)[number]; + +export type { + LemmaField, + PartOfSpeechField, + MeaningField, + DefinitionField, + TranslationField, + RelationField, + SyllableField, + PronunciationField, + RhymeField, + AudioField, + ExpressionField, + ExampleField, + FrequencyField, + InflectionField, + EtymologyField, + NoteField, + DictionaryEntrySource, + DictionaryEntry, + LabelledField, +}; diff --git a/source/library/commands/handlers/word.ts b/source/library/commands/handlers/word.ts index 736079177..5b103aec6 100644 --- a/source/library/commands/handlers/word.ts +++ b/source/library/commands/handlers/word.ts @@ -1,10 +1,11 @@ import { isLocalisationLanguage } from "logos:constants/languages/localisation"; import { type PartOfSpeech, isUnknownPartOfSpeech } from "logos:constants/parts-of-speech"; import { code, trim } from "logos:core/formatting"; -import type { Definition, DictionaryEntry, Expression } from "logos/adapters/dictionaries/adapter"; +import type { DictionaryEntry } from "logos/adapters/dictionaries/adapter"; import type { Client } from "logos/client"; import { InteractionCollector } from "logos/collectors"; import { WordSourceNotice } from "logos/commands/components/source-notices/word-source-notice"; +import type { DefinitionField, ExpressionField } from "logos/adapters/dictionaries/dictionary-entry"; import { autocompleteLanguage } from "logos/commands/fragments/autocomplete/language"; async function handleFindWordAutocomplete( @@ -87,7 +88,12 @@ async function handleFindWord( const organised = new Map(); for (const entry of entries) { - const [partOfSpeech, _] = entry.partOfSpeech; + if (entry.partOfSpeech === undefined) { + continue; + } + + const partOfSpeech = entry.partOfSpeech.detected; + if (partOfSpeech === "unknown") { unclassifiedEntries.push(entry); continue; @@ -171,9 +177,11 @@ async function handleFindWord( function sanitiseEntries(entries: DictionaryEntry[]): DictionaryEntry[] { for (const entry of entries) { - for (const etymology of entry.etymologies ?? []) { - etymology.value = etymology.value?.replaceAll("*", "\\*"); + if (entry.etymology === undefined) { + continue; } + + entry.etymology.value = entry.etymology.value?.replaceAll("*", "\\*"); } return entries; } @@ -215,7 +223,7 @@ function generateEmbeds( return entryToEmbeds(client, interaction, entry, data.verbose); } case ContentTabs.Inflection: { - const inflectionTable = entry.inflectionTable?.at(data.inflectionTableIndex); + const inflectionTable = entry.inflection?.tabs?.at(data.inflectionTableIndex); if (inflectionTable === undefined) { return []; } @@ -305,11 +313,11 @@ async function generateButtons( break; } case ContentTabs.Inflection: { - if (entry.inflectionTable === undefined) { + if (entry.inflection === undefined) { return []; } - const rows = entry.inflectionTable.toChunked(5).reverse(); + const rows = entry.inflection.tabs.toChunked(5).reverse(); const button = new InteractionCollector(client, { only: interaction.parameters.show ? [interaction.user.id] : undefined, @@ -318,7 +326,7 @@ async function generateButtons( button.onInteraction(async (buttonPress) => { await client.acknowledge(buttonPress); - if (entry.inflectionTable === undefined) { + if (entry.inflection === undefined) { await displayMenu(client, interaction, data); return; } @@ -331,7 +339,7 @@ async function generateButtons( const [_, indexString] = InteractionCollector.decodeId(customId); const index = Number(indexString); - if (index >= 0 && index <= entry.inflectionTable?.length) { + if (index >= 0 && index <= entry.inflection?.tabs?.length) { data.inflectionTableIndex = index; } @@ -340,7 +348,7 @@ async function generateButtons( await client.registerInteractionCollector(button); - for (const [row, rowIndex] of rows.map<[typeof entry.inflectionTable, number]>((r, i) => [r, i])) { + for (const [row, rowIndex] of rows.map<[typeof entry.inflection.tabs, number]>((r, i) => [r, i])) { const buttons = row.map((table, index) => { const index_ = rowIndex * 5 + index; @@ -404,7 +412,7 @@ async function generateButtons( }); } - if (entry.inflectionTable !== undefined) { + if (entry.inflection !== undefined) { const strings = constants.contexts.inflectionView({ localise: client.localise, locale: interaction.displayLocale, @@ -424,7 +432,7 @@ async function generateButtons( const sourceNotice = new WordSourceNotice(client, { interaction, - sources: entry.sources.map(([link, licence]) => `[${licence.name}](${link})`), + sources: entry.sources.map(({ link, licence }) => `[${licence.name}](${link})`), }); await sourceNotice.register(); @@ -455,30 +463,29 @@ function entryToEmbeds( }); partOfSpeechDisplayed = strings.unknown; } else { - const [detected, original] = entry.partOfSpeech; - - if (detected === "unknown") { - partOfSpeechDisplayed = original; + const partOfSpeech = entry.partOfSpeech.detected; + if (partOfSpeech === "unknown") { + partOfSpeechDisplayed = partOfSpeech; } else { const strings = constants.contexts.partOfSpeech({ localise: client.localise, locale: interaction.displayLocale, }); - partOfSpeechDisplayed = strings.partOfSpeech(detected); - if (isUnknownPartOfSpeech(detected)) { - partOfSpeechDisplayed += ` — '${original}'`; + partOfSpeechDisplayed = strings.partOfSpeech(partOfSpeech); + if (isUnknownPartOfSpeech(partOfSpeech)) { + partOfSpeechDisplayed += ` — '${partOfSpeech}'`; } } } const partOfSpeechFormatted = `***${partOfSpeechDisplayed}***`; - const word = entry.lemma; + const word = entry.lemma.value; const embeds: Discord.CamelizedDiscordEmbed[] = []; const fields: Discord.CamelizedDiscordEmbedField[] = []; - if (entry.nativeDefinitions !== undefined && entry.nativeDefinitions.length > 0) { - const definitionsStringified = stringifyEntries(client, interaction, entry.nativeDefinitions, "definitions"); + if (entry.definitions !== undefined && entry.definitions.length > 0) { + const definitionsStringified = stringifyEntries(client, interaction, entry.definitions, "definitions"); const definitionsFitted = fitTextToFieldSize(client, interaction, definitionsStringified, verbose); if (verbose) { @@ -503,8 +510,8 @@ function entryToEmbeds( } } - if (entry.definitions !== undefined && entry.definitions.length > 0) { - const definitionsStringified = stringifyEntries(client, interaction, entry.definitions, "definitions"); + if (entry.translations !== undefined && entry.translations.length > 0) { + const definitionsStringified = stringifyEntries(client, interaction, entry.translations, "definitions"); const definitionsFitted = fitTextToFieldSize(client, interaction, definitionsStringified, verbose); if (verbose) { @@ -551,20 +558,15 @@ function entryToEmbeds( } } - if (entry.etymologies !== undefined && entry.etymologies.length > 0) { - const etymology = entry.etymologies - .map((etymology) => { - if (etymology.tags === undefined) { - return etymology.value; - } - - if (etymology.value === undefined || etymology.value.length === 0) { - return tagsToString(etymology.tags); - } - - return `${tagsToString(etymology.tags)} ${etymology.value}`; - }) - .join("\n"); + if (entry.etymology !== undefined) { + let etymology: string; + if (entry.etymology.labels === undefined) { + etymology = entry.etymology.value; + } else if (entry.etymology.value === undefined || entry.etymology.value.length === 0) { + etymology = tagsToString(entry.etymology.labels); + } else { + etymology = `${tagsToString(entry.etymology.labels)} ${entry.etymology.value}`; + } const strings = constants.contexts.etymology({ localise: client.localise, @@ -604,7 +606,7 @@ function tagsToString(tags: string[]): string { type EntryType = "definitions" | "expressions"; -function isDefinition(_entry: Definition | Expression, entryType: EntryType): _entry is Definition { +function isDefinition(_entry: DefinitionField | ExpressionField, entryType: EntryType): _entry is DefinitionField { return entryType === "definitions"; } @@ -612,7 +614,7 @@ const parenthesesExpression = /\((.+?)\)/g; function stringifyEntries< T extends EntryType, - E extends Definition[] | Expression[] = T extends "definitions" ? Definition[] : Expression[], + E extends DefinitionField[] | ExpressionField[] = T extends "definitions" ? DefinitionField[] : ExpressionField[], >( client: Client, interaction: Logos.Interaction, @@ -641,7 +643,7 @@ function stringifyEntries< entry.value, ); - let anchor = entry.tags === undefined ? value : `${tagsToString(entry.tags)} ${value}`; + let anchor = entry.labels === undefined ? value : `${tagsToString(entry.labels)} ${value}`; if (isDefinition(entry, entryType)) { if (entry.relations !== undefined) { const strings = constants.contexts.wordRelations({ diff --git a/source/library/stores/adapters/dictionaries.ts b/source/library/stores/adapters/dictionaries.ts index 423303479..1f2557479 100644 --- a/source/library/stores/adapters/dictionaries.ts +++ b/source/library/stores/adapters/dictionaries.ts @@ -2,11 +2,11 @@ import type { Dictionary } from "logos:constants/dictionaries"; import type { LearningLanguage } from "logos:constants/languages/learning"; import { isDefined } from "logos:core/utilities"; import type { DictionaryAdapter } from "logos/adapters/dictionaries/adapter"; -import { DexonlineAdapter } from "logos/adapters/dictionaries/dexonline"; -import { DicolinkAdapter } from "logos/adapters/dictionaries/dicolink"; -import { WiktionaryAdapter } from "logos/adapters/dictionaries/wiktionary"; -import { WordnikAdapter } from "logos/adapters/dictionaries/wordnik"; -import { WordsAPIAdapter } from "logos/adapters/dictionaries/words-api"; +import { DexonlineAdapter } from "logos/adapters/dictionaries/adapters/dexonline"; +import { DicolinkAdapter } from "logos/adapters/dictionaries/adapters/dicolink"; +import { WiktionaryAdapter } from "logos/adapters/dictionaries/adapters/wiktionary"; +import { WordnikAdapter } from "logos/adapters/dictionaries/adapters/wordnik.ts"; +import { WordsAPIAdapter } from "logos/adapters/dictionaries/adapters/words-api"; import type { Client } from "logos/client"; import { Logger } from "logos/logger"; diff --git a/test/source/constants/parts-of-speech.spec.ts b/test/source/constants/parts-of-speech.spec.ts index 09d1465d8..e8348f5e9 100644 --- a/test/source/constants/parts-of-speech.spec.ts +++ b/test/source/constants/parts-of-speech.spec.ts @@ -14,7 +14,7 @@ describe("isUnknownPartOfSpeech()", () => { describe("getPartOfSpeech()", () => { it("returns the part of speech if the passed exact term is a resolvable part of speech.", () => { - const [detected, original] = getPartOfSpeech({ + const { detected, original } = getPartOfSpeech({ terms: { exact: "proper-noun" satisfies PartOfSpeech }, learningLanguage: "Romanian", }); @@ -23,7 +23,7 @@ describe("getPartOfSpeech()", () => { }); it("returns 'unknown' if the learning language is not supported.", () => { - const [detected, original] = getPartOfSpeech({ + const { detected, original } = getPartOfSpeech({ terms: { exact: "գոյական" }, // 'noun' in Armenian learningLanguage: "Armenian/Eastern", }); @@ -32,9 +32,9 @@ describe("getPartOfSpeech()", () => { }); it("returns the part of speech matched to the exact term in the given language.", () => { - const [detected, original] = getPartOfSpeech({ - terms: { exact: "substantiv" }, - learningLanguage: "Romanian", + const { detected, original } = getPartOfSpeech({ + terms: { exact: "rzeczownik" }, + learningLanguage: "Polish", }); expect(detected).to.equal("noun" satisfies PartOfSpeech); expect(original).to.equal("substantiv"); @@ -44,7 +44,7 @@ describe("getPartOfSpeech()", () => { "returns the part of speech matched to the approximate term in the given language if the exact term has no" + " match.", () => { - const [detected, original] = getPartOfSpeech({ + const { detected, original } = getPartOfSpeech({ terms: { exact: "this.will.not.match", approximate: "proper noun" }, learningLanguage: "English/American", }); @@ -54,7 +54,7 @@ describe("getPartOfSpeech()", () => { ); it("returns 'unknown' if there is no match", () => { - const [detected, original] = getPartOfSpeech({ + const { detected, original } = getPartOfSpeech({ terms: { exact: "this.will.not.match" }, learningLanguage: "English/American", });