From fc2123a45b3ceacc2ec887d24e5e752dca59bb4f Mon Sep 17 00:00:00 2001 From: StefanVukovic99 Date: Thu, 28 Dec 2023 06:39:19 +0100 Subject: add phonetic transcriptions term meta type (#434) * move dictionary files to dictionary folder * wip * move dictionary files to dictionary folder * add ipa term meta * wip * fixing comments wip * fixing comments wip * fixing comments wip * fixing comments wip * fixing comments wip * fixing comments wip * fix comments * fix comments * update test data * fix gitignore * engines * add tests * update database test * fix test --- ext/js/data/sandbox/anki-note-data-creator.js | 102 +++++++++++++++++++-- ext/js/dictionary/dictionary-data-util.js | 93 +++++++++++++------ ext/js/dictionary/dictionary-database.js | 2 + ext/js/display/display-generator.js | 64 +++++++++++-- ext/js/language/translator.js | 44 ++++++++- ext/js/pages/settings/anki-controller.js | 1 + ext/js/templates/sandbox/anki-template-renderer.js | 5 +- 7 files changed, 258 insertions(+), 53 deletions(-) (limited to 'ext/js') diff --git a/ext/js/data/sandbox/anki-note-data-creator.js b/ext/js/data/sandbox/anki-note-data-creator.js index 9d93b497..c0a11869 100644 --- a/ext/js/data/sandbox/anki-note-data-creator.js +++ b/ext/js/data/sandbox/anki-note-data-creator.js @@ -55,6 +55,8 @@ export class AnkiNoteDataCreator { const context2 = this.createCachedValue(this._getPublicContext.bind(this, context)); const pitches = this.createCachedValue(this._getPitches.bind(this, dictionaryEntry)); const pitchCount = this.createCachedValue(this._getPitchCount.bind(this, pitches)); + const phoneticTranscriptions = this.createCachedValue(this._getPhoneticTranscriptions.bind(this, dictionaryEntry)); + if (typeof media !== 'object' || media === null || Array.isArray(media)) { media = { audio: void 0, @@ -82,6 +84,7 @@ export class AnkiNoteDataCreator { get uniqueReadings() { return self.getCachedValue(uniqueReadings); }, get pitches() { return self.getCachedValue(pitches); }, get pitchCount() { return self.getCachedValue(pitchCount); }, + get phoneticTranscriptions() { return self.getCachedValue(phoneticTranscriptions); }, get context() { return self.getCachedValue(context2); }, media, dictionaryEntry @@ -193,7 +196,11 @@ export class AnkiNoteDataCreator { for (const {dictionary, pronunciations} of DictionaryDataUtil.getGroupedPronunciations(dictionaryEntry)) { /** @type {import('anki-templates').Pitch[]} */ const pitches = []; - for (const {terms, reading, position, nasalPositions, devoicePositions, tags, exclusiveTerms, exclusiveReadings} of pronunciations) { + for (const groupedPronunciation of pronunciations) { + const {pronunciation} = groupedPronunciation; + if (pronunciation.type !== 'pitch-accent') { continue; } + const {position, nasalPositions, devoicePositions, tags} = pronunciation; + const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation; pitches.push({ expressions: terms, reading, @@ -211,6 +218,35 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TranscriptionGroup[]} + */ + _getPhoneticTranscriptions(dictionaryEntry) { + const results = []; + if (dictionaryEntry.type === 'term') { + for (const {dictionary, pronunciations} of DictionaryDataUtil.getGroupedPronunciations(dictionaryEntry)) { + const phoneticTranscriptions = []; + for (const groupedPronunciation of pronunciations) { + const {pronunciation} = groupedPronunciation; + if (pronunciation.type !== 'phonetic-transcription') { continue; } + const {ipa, tags} = pronunciation; + const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation; + phoneticTranscriptions.push({ + expressions: terms, + reading, + ipa, + tags, + exclusiveExpressions: exclusiveTerms, + exclusiveReadings + }); + } + results.push({dictionary, phoneticTranscriptions}); + } + } + return results; + } + /** * @param {import('anki-templates-internal').CachedValue} cachedPitches * @returns {number} @@ -353,6 +389,7 @@ export class AnkiNoteDataCreator { const expressions = this.createCachedValue(this._getTermExpressions.bind(this, dictionaryEntry)); const frequencies = this.createCachedValue(this._getTermFrequencies.bind(this, dictionaryEntry)); const pitches = this.createCachedValue(this._getTermPitches.bind(this, dictionaryEntry)); + const phoneticTranscriptions = this.createCachedValue(this._getTermPhoneticTranscriptions.bind(this, dictionaryEntry)); const glossary = this.createCachedValue(this._getTermGlossaryArray.bind(this, dictionaryEntry, type)); const cloze = this.createCachedValue(this._getCloze.bind(this, dictionaryEntry, context)); const furiganaSegments = this.createCachedValue(this._getTermFuriganaSegments.bind(this, dictionaryEntry, type)); @@ -389,6 +426,7 @@ export class AnkiNoteDataCreator { get definitions() { return self.getCachedValue(commonInfo).definitions; }, get frequencies() { return self.getCachedValue(frequencies); }, get pitches() { return self.getCachedValue(pitches); }, + get phoneticTranscriptions() { return self.getCachedValue(phoneticTranscriptions); }, sourceTermExactMatchCount, url, get cloze() { return self.getCachedValue(cloze); }, @@ -485,15 +523,16 @@ export class AnkiNoteDataCreator { /** * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry - * @returns {import('anki-templates').TermPronunciation[]} + * @returns {import('anki-templates').TermPitchAccent[]} */ _getTermPitches(dictionaryEntry) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; const {headwords} = dictionaryEntry; - for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches} of dictionaryEntry.pronunciations) { + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of dictionaryEntry.pronunciations) { const {term, reading} = headwords[headwordIndex]; + const pitches = DictionaryDataUtil.getPronunciationsOfType(pronunciations, 'pitch-accent'); const cachedPitches = this.createCachedValue(this._getTermPitchesInner.bind(this, pitches)); results.push({ index: results.length, @@ -512,8 +551,8 @@ export class AnkiNoteDataCreator { } /** - * @param {import('dictionary').TermPitch[]} pitches - * @returns {import('anki-templates').TermPitch[]} + * @param {import('dictionary').PitchAccent[]} pitches + * @returns {import('anki-templates').PitchAccent[]} */ _getTermPitchesInner(pitches) { // eslint-disable-next-line @typescript-eslint/no-this-alias @@ -529,6 +568,52 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermPhoneticTranscription[]} + */ + _getTermPhoneticTranscriptions(dictionaryEntry) { + const results = []; + const {headwords} = dictionaryEntry; + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of dictionaryEntry.pronunciations) { + const {term, reading} = headwords[headwordIndex]; + const phoneticTranscriptions = DictionaryDataUtil.getPronunciationsOfType(pronunciations, 'phonetic-transcription'); + const termPhoneticTranscriptions = this._getTermPhoneticTranscriptionsInner(phoneticTranscriptions); + results.push({ + index: results.length, + expressionIndex: headwordIndex, + dictionary, + dictionaryOrder: { + index: dictionaryIndex, + priority: dictionaryPriority + }, + expression: term, + reading, + get phoneticTranscriptions() { return termPhoneticTranscriptions; } + }); + } + + return results; + } + + /** + * @param {import('dictionary').PhoneticTranscription[]} phoneticTranscriptions + * @returns {import('anki-templates').PhoneticTranscription[]} + */ + _getTermPhoneticTranscriptionsInner(phoneticTranscriptions) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + const results = []; + for (const {ipa, tags} of phoneticTranscriptions) { + const cachedTags = this.createCachedValue(this._convertTags.bind(this, tags)); + results.push({ + ipa, + get tags() { return self.getCachedValue(cachedTags); } + }); + } + return results; + } + /** * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry * @returns {import('anki-templates').TermHeadword[]} @@ -592,16 +677,17 @@ export class AnkiNoteDataCreator { /** * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry * @param {number} i - * @returns {import('anki-templates').TermPronunciation[]} + * @returns {import('anki-templates').TermPitchAccent[]} */ _getTermExpressionPitches(dictionaryEntry, i) { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; - const {headwords, pronunciations} = dictionaryEntry; - for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches} of pronunciations) { + const {headwords, pronunciations: termPronunciations} = dictionaryEntry; + for (const {headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations} of termPronunciations) { if (headwordIndex !== i) { continue; } const {term, reading} = headwords[headwordIndex]; + const pitches = DictionaryDataUtil.getPronunciationsOfType(pronunciations, 'pitch-accent'); const cachedPitches = this.createCachedValue(this._getTermPitchesInner.bind(this, pitches)); results.push({ index: results.length, diff --git a/ext/js/dictionary/dictionary-data-util.js b/ext/js/dictionary/dictionary-data-util.js index a54b043b..50ae4b11 100644 --- a/ext/js/dictionary/dictionary-data-util.js +++ b/ext/js/dictionary/dictionary-data-util.js @@ -135,7 +135,7 @@ export class DictionaryDataUtil { * @returns {import('dictionary-data-util').DictionaryGroupedPronunciations[]} */ static getGroupedPronunciations(dictionaryEntry) { - const {headwords, pronunciations} = dictionaryEntry; + const {headwords, pronunciations: termPronunciations} = dictionaryEntry; const allTerms = new Set(); const allReadings = new Set(); @@ -146,23 +146,20 @@ export class DictionaryDataUtil { /** @type {Map} */ const groupedPronunciationsMap = new Map(); - for (const {headwordIndex, dictionary, pitches} of pronunciations) { + for (const {headwordIndex, dictionary, pronunciations} of termPronunciations) { const {term, reading} = headwords[headwordIndex]; let dictionaryGroupedPronunciationList = groupedPronunciationsMap.get(dictionary); if (typeof dictionaryGroupedPronunciationList === 'undefined') { dictionaryGroupedPronunciationList = []; groupedPronunciationsMap.set(dictionary, dictionaryGroupedPronunciationList); } - for (const {position, nasalPositions, devoicePositions, tags} of pitches) { - let groupedPronunciation = this._findExistingGroupedPronunciation(reading, position, nasalPositions, devoicePositions, tags, dictionaryGroupedPronunciationList); + for (const pronunciation of pronunciations) { + let groupedPronunciation = this._findExistingGroupedPronunciation(reading, pronunciation, dictionaryGroupedPronunciationList); if (groupedPronunciation === null) { groupedPronunciation = { + pronunciation, terms: new Set(), - reading, - position, - nasalPositions, - devoicePositions, - tags + reading }; dictionaryGroupedPronunciationList.push(groupedPronunciation); } @@ -177,28 +174,43 @@ export class DictionaryDataUtil { /** @type {import('dictionary-data-util').GroupedPronunciation[]} */ const pronunciations2 = []; for (const groupedPronunciation of dictionaryGroupedPronunciationList) { - const {terms, reading, position, nasalPositions, devoicePositions, tags} = groupedPronunciation; + const {pronunciation, terms, reading} = groupedPronunciation; const exclusiveTerms = !this._areSetsEqual(terms, allTerms) ? this._getSetIntersection(terms, allTerms) : []; const exclusiveReadings = []; if (multipleReadings) { exclusiveReadings.push(reading); } pronunciations2.push({ + pronunciation, terms: [...terms], reading, - position, - nasalPositions, - devoicePositions, - tags, exclusiveTerms, exclusiveReadings }); } + results2.push({dictionary, pronunciations: pronunciations2}); } return results2; } + /** + * @template {import('dictionary').PronunciationType} T + * @param {import('dictionary').Pronunciation[]} pronunciations + * @param {T} type + * @returns {import('dictionary').PronunciationGeneric[]} + */ + static getPronunciationsOfType(pronunciations, type) { + /** @type {import('dictionary').PronunciationGeneric[]} */ + const results = []; + for (const pronunciation of pronunciations) { + if (pronunciation.type !== type) { continue; } + // This is type safe, but for some reason the cast is needed. + results.push(/** @type {import('dictionary').PronunciationGeneric} */ (pronunciation)); + } + return results; + } + /** * @param {import('dictionary').Tag[]|import('anki-templates').Tag[]} termTags * @returns {import('dictionary-data-util').TermFrequencyType} @@ -288,26 +300,49 @@ export class DictionaryDataUtil { /** * @param {string} reading - * @param {number} position - * @param {number[]} nasalPositions - * @param {number[]} devoicePositions - * @param {import('dictionary').Tag[]} tags + * @param {import('dictionary').Pronunciation} pronunciation * @param {import('dictionary-data-util').GroupedPronunciationInternal[]} groupedPronunciationList * @returns {?import('dictionary-data-util').GroupedPronunciationInternal} */ - static _findExistingGroupedPronunciation(reading, position, nasalPositions, devoicePositions, tags, groupedPronunciationList) { - for (const pitchInfo of groupedPronunciationList) { - if ( - pitchInfo.reading === reading && - pitchInfo.position === position && - this._areArraysEqual(pitchInfo.nasalPositions, nasalPositions) && - this._areArraysEqual(pitchInfo.devoicePositions, devoicePositions) && - this._areTagListsEqual(pitchInfo.tags, tags) - ) { - return pitchInfo; + static _findExistingGroupedPronunciation(reading, pronunciation, groupedPronunciationList) { + const existingGroupedPronunciation = groupedPronunciationList.find((groupedPronunciation) => { + return groupedPronunciation.reading === reading && this._arePronunciationsEquivalent(groupedPronunciation, pronunciation); + }); + + return existingGroupedPronunciation || null; + } + + /** + * @param {import('dictionary-data-util').GroupedPronunciationInternal} groupedPronunciation + * @param {import('dictionary').Pronunciation} pronunciation2 + * @returns {boolean} + */ + static _arePronunciationsEquivalent({pronunciation: pronunciation1}, pronunciation2) { + if ( + pronunciation1.type !== pronunciation2.type || + !this._areTagListsEqual(pronunciation1.tags, pronunciation2.tags) + ) { + return false; + } + switch (pronunciation1.type) { + case 'pitch-accent': + { + // This cast is valid based on the type check at the start of the function. + const pitchAccent2 = /** @type {import('dictionary').PitchAccent} */ (pronunciation2); + return ( + pronunciation1.position === pitchAccent2.position && + this._areArraysEqual(pronunciation1.nasalPositions, pitchAccent2.nasalPositions) && + this._areArraysEqual(pronunciation1.devoicePositions, pitchAccent2.devoicePositions) + ); + } + case 'phonetic-transcription': + { + // This cast is valid based on the type check at the start of the function. + const phoneticTranscription2 = /** @type {import('dictionary').PhoneticTranscription} */ (pronunciation2); + return pronunciation1.ipa === phoneticTranscription2.ipa; } } - return null; + return true; } /** diff --git a/ext/js/dictionary/dictionary-database.js b/ext/js/dictionary/dictionary-database.js index 45c5c6fd..02db6322 100644 --- a/ext/js/dictionary/dictionary-database.js +++ b/ext/js/dictionary/dictionary-database.js @@ -627,6 +627,8 @@ export class DictionaryDatabase { return {index, term, mode, data, dictionary}; case 'pitch': return {index, term, mode, data, dictionary}; + case 'ipa': + return {index, term, mode, data, dictionary}; default: throw new Error(`Unknown mode: ${mode}`); } diff --git a/ext/js/display/display-generator.js b/ext/js/display/display-generator.js index b91d0ce9..3a2a5621 100644 --- a/ext/js/display/display-generator.js +++ b/ext/js/display/display-generator.js @@ -626,7 +626,7 @@ export class DisplayGenerator { n1.appendChild(tag); let hasTags = false; - for (const {tags} of pronunciations) { + for (const {pronunciation: {tags}} of pronunciations) { if (tags.length > 0) { hasTags = true; break; @@ -645,8 +645,52 @@ export class DisplayGenerator { * @returns {HTMLElement} */ _createPronunciation(details) { + const {pronunciation} = details; + switch (pronunciation.type) { + case 'pitch-accent': + return this._createPronunciationPitchAccent(pronunciation, details); + case 'phonetic-transcription': + return this._createPronunciationPhoneticTranscription(pronunciation, details); + } + } + + + /** + * @param {import('dictionary').PhoneticTranscription} pronunciation + * @param {import('dictionary-data-util').GroupedPronunciation} details + * @returns {HTMLElement} + */ + _createPronunciationPhoneticTranscription(pronunciation, details) { + const {ipa, tags} = pronunciation; + const {exclusiveTerms, exclusiveReadings} = details; + + const node = this._instantiate('pronunciation'); + + node.dataset.tagCount = `${tags.length}`; + + let n = this._querySelector(node, '.pronunciation-tag-list'); + this._appendMultiple(n, this._createTag.bind(this), tags); + + n = this._querySelector(node, '.pronunciation-disambiguation-list'); + this._createPronunciationDisambiguations(n, exclusiveTerms, exclusiveReadings); + + n = this._querySelector(node, '.pronunciation-text-container'); + + this._setTextContent(n, ipa); + + return node; + } + + /** + * @param {import('dictionary').PitchAccent} pitchAccent + * @param {import('dictionary-data-util').GroupedPronunciation} details + * @returns {HTMLElement} + */ + _createPronunciationPitchAccent(pitchAccent, details) { const jp = this._japaneseUtil; - const {reading, position, nasalPositions, devoicePositions, tags, exclusiveTerms, exclusiveReadings} = details; + + const {position, nasalPositions, devoicePositions, tags} = pitchAccent; + const {reading, exclusiveTerms, exclusiveReadings} = details; const morae = jp.getKanaMorae(reading); const node = this._instantiate('pronunciation'); @@ -666,6 +710,7 @@ export class DisplayGenerator { n.appendChild(this._pronunciationGenerator.createPronunciationDownstepPosition(position)); n = this._querySelector(node, '.pronunciation-text-container'); + n.lang = 'ja'; n.appendChild(this._pronunciationGenerator.createPronunciationText(morae, position, nasalPositions, devoicePositions)); @@ -954,20 +999,21 @@ export class DisplayGenerator { /** * @param {string} reading - * @param {import('dictionary').TermPronunciation[]} pronunciations + * @param {import('dictionary').TermPronunciation[]} termPronunciations * @param {string[]} wordClasses * @param {number} headwordIndex * @returns {?string} */ - _getPronunciationCategories(reading, pronunciations, wordClasses, headwordIndex) { - if (pronunciations.length === 0) { return null; } + _getPronunciationCategories(reading, termPronunciations, wordClasses, headwordIndex) { + if (termPronunciations.length === 0) { return null; } const isVerbOrAdjective = DictionaryDataUtil.isNonNounVerbOrAdjective(wordClasses); /** @type {Set} */ const categories = new Set(); - for (const pronunciation of pronunciations) { - if (pronunciation.headwordIndex !== headwordIndex) { continue; } - for (const {position} of pronunciation.pitches) { - const category = this._japaneseUtil.getPitchCategory(reading, position, isVerbOrAdjective); + for (const termPronunciation of termPronunciations) { + if (termPronunciation.headwordIndex !== headwordIndex) { continue; } + for (const pronunciation of termPronunciation.pronunciations) { + if (pronunciation.type !== 'pitch-accent') { continue; } + const category = this._japaneseUtil.getPitchCategory(reading, pronunciation.position, isVerbOrAdjective); if (category !== null) { categories.add(category); } diff --git a/ext/js/language/translator.js b/ext/js/language/translator.js index 45909940..733955c2 100644 --- a/ext/js/language/translator.js +++ b/ext/js/language/translator.js @@ -964,7 +964,7 @@ export class Translator { case 'pitch': { if (data.reading !== reading) { continue; } - /** @type {import('dictionary').TermPitch[]} */ + /** @type {import('dictionary').PitchAccent[]} */ const pitches = []; for (const {position, tags, nasal, devoice} of data.pitches) { /** @type {import('dictionary').Tag[]} */ @@ -974,7 +974,13 @@ export class Translator { } const nasalPositions = this._toNumberArray(nasal); const devoicePositions = this._toNumberArray(devoice); - pitches.push({position, nasalPositions, devoicePositions, tags: tags2}); + pitches.push({ + type: 'pitch-accent', + position, + nasalPositions, + devoicePositions, + tags: tags2 + }); } for (const {pronunciations, headwordIndex} of targets) { pronunciations.push(this._createTermPronunciation( @@ -988,6 +994,34 @@ export class Translator { } } break; + case 'ipa': + { + if (data.reading !== reading) { continue; } + /** @type {import('dictionary').PhoneticTranscription[]} */ + const phoneticTranscriptions = []; + for (const {ipa, tags} of data.transcriptions) { + /** @type {import('dictionary').Tag[]} */ + const tags2 = []; + if (Array.isArray(tags)) { + tagAggregator.addTags(tags2, dictionary, tags); + } + phoneticTranscriptions.push({ + type: 'phonetic-transcription', + ipa, + tags: tags2 + }); + } + for (const {pronunciations, headwordIndex} of targets) { + pronunciations.push(this._createTermPronunciation( + pronunciations.length, + headwordIndex, + dictionary, + dictionaryIndex, + dictionaryPriority, + phoneticTranscriptions + )); + } + } } } } @@ -1341,11 +1375,11 @@ export class Translator { * @param {string} dictionary * @param {number} dictionaryIndex * @param {number} dictionaryPriority - * @param {import('dictionary').TermPitch[]} pitches + * @param {import('dictionary').Pronunciation[]} pronunciations * @returns {import('dictionary').TermPronunciation} */ - _createTermPronunciation(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches) { - return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches}; + _createTermPronunciation(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations) { + return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pronunciations}; } /** diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js index 6f357680..aea94b65 100644 --- a/ext/js/pages/settings/anki-controller.js +++ b/ext/js/pages/settings/anki-controller.js @@ -145,6 +145,7 @@ export class AnkiController { 'pitch-accents', 'pitch-accent-graphs', 'pitch-accent-positions', + 'phonetic-transcriptions', 'reading', 'screenshot', 'search-query', diff --git a/ext/js/templates/sandbox/anki-template-renderer.js b/ext/js/templates/sandbox/anki-template-renderer.js index 57725bcb..15810239 100644 --- a/ext/js/templates/sandbox/anki-template-renderer.js +++ b/ext/js/templates/sandbox/anki-template-renderer.js @@ -543,12 +543,13 @@ export class AnkiTemplateRenderer { const [data] = /** @type {[data: import('anki-templates').NoteData]} */ (args); const {dictionaryEntry} = data; if (dictionaryEntry.type !== 'term') { return []; } - const {pronunciations, headwords} = dictionaryEntry; + const {pronunciations: termPronunciations, headwords} = dictionaryEntry; /** @type {Set} */ const categories = new Set(); - for (const {headwordIndex, pitches} of pronunciations) { + for (const {headwordIndex, pronunciations} of termPronunciations) { const {reading, wordClasses} = headwords[headwordIndex]; const isVerbOrAdjective = DictionaryDataUtil.isNonNounVerbOrAdjective(wordClasses); + const pitches = DictionaryDataUtil.getPronunciationsOfType(pronunciations, 'pitch-accent'); for (const {position} of pitches) { const category = this._japaneseUtil.getPitchCategory(reading, position, isVerbOrAdjective); if (category !== null) { -- cgit v1.2.3