jszhuyin
Version:
Smart Chinese Zhuyin Input Method in JavaScript.
1,241 lines (1,102 loc) • 38.9 kB
JavaScript
'use strict';
(function(factory) {
if (typeof module === 'object' && module.exports) {
// CommonJS
factory(module.exports, {
BopomofoEncoder: require('./bopomofo_encoder.js'),
JSZhuyinDataPackStorage: require('./storage.js').JSZhuyinDataPackStorage,
CacheStore: require('./storage.js').CacheStore
});
} else if (typeof self === 'object') {
// Window or WorkerGlobalScope
if (typeof self.BopomofoEncoder === 'undefined' ||
typeof self.JSZhuyinDataPackStorage === 'undefined' ||
typeof self.CacheStore === 'undefined') {
throw new Error('Dependency not found.');
}
factory(self, self);
}
}(function(exports, required) {
/* global DataLoader */
var BopomofoEncoder = required.BopomofoEncoder;
var JSZhuyinDataPackStorage = required.JSZhuyinDataPackStorage;
var CacheStore = required.CacheStore;
/**
* A queue to run one action at a time.
* @this {object} ActionQueue instance.
*/
var ActionQueue = exports.ActionQueue = function ActionQueue() {
this.pendingActions = [];
this.waiting = false;
};
/**
* Function to call to run each action.
* @type {function}
*/
ActionQueue.prototype.handle = null;
/**
* Queue and action. The arguments will be kept.
* @this {object} ActionQueue instance.
*/
ActionQueue.prototype.queue = function aq_queue(type, data, reqId) {
if (this.waiting) {
this.pendingActions.push([type, data, reqId]);
return;
}
this.waiting = true;
this.handle(type, data, reqId);
};
/**
* handle() are suppose to call done() when it finishes.
* @this {object} ActionQueue instance.
*/
ActionQueue.prototype.done = function aq_done() {
if (!this.waiting) {
throw new Error('ActionQueue: ' +
'Calling queue.done() when we are not waiting.');
}
var args = this.pendingActions.shift();
if (!args) {
this.waiting = false;
return;
}
this.handle.apply(this, args);
};
var JSZhuyinCandidateMetadata = function() {
this.dataMap = new Map();
// Start with a number other than any obvious ones to remove the meaning of
// this incremental ID.
this.nextId = 42;
this.NULL_DATA = [0, 0];
};
JSZhuyinCandidateMetadata.prototype.NULL_ID = 0;
JSZhuyinCandidateMetadata.prototype.saveData =
function jcm_saveData(encodedSoundsLength, index) {
this.dataMap.set(this.nextId, [ encodedSoundsLength, index ]);
return this.nextId++;
};
JSZhuyinCandidateMetadata.prototype.getData = function(id) {
if (id === this.NULL_ID) {
return this.NULL_DATA;
}
var data = this.dataMap.get(id);
if (!data) {
throw new Error('JSZhuyinCandidateMetadata: ' +
'Inexistent or outdated candidate.');
}
return data;
};
JSZhuyinCandidateMetadata.prototype.clear = function() {
this.dataMap.clear();
};
var JSZhuyinComposedResultData = function(result, overflowData) {
result = result || {};
this.str = result.str || '';
this.score = result.score || 0;
this.index = result.index || 0;
this.overflowData = overflowData;
};
JSZhuyinComposedResultData.prototype.copy = function() {
return new JSZhuyinComposedResultData(this, this.overflowData);
};
var JSZhuyinQueryData = function(symbols, longestPhraseLength) {
this.symbols = symbols;
this.expendedEncodedSounds = BopomofoEncoder.encodeExpended(this.symbols);
this.longestPhraseLength = longestPhraseLength;
this._trimmedSymbols = undefined;
this._trimmedFromEndSymbols = undefined;
this._trimmedSymbolsCombinations = undefined;
this._trimmedFromEndSymbolsCombinations = undefined;
};
JSZhuyinQueryData.prototype.getTrimmedSymbols = function() {
if (this._trimmedSymbols) {
return this._trimmedSymbols;
}
return (this._trimmedSymbols =
BopomofoEncoder.trimToLength(this.symbols, this.longestPhraseLength));
};
JSZhuyinQueryData.prototype.getTrimmedFromEndSymbols = function() {
if (this._trimmedFromEndSymbols) {
return this._trimmedFromEndSymbols;
}
return (this._trimmedFromEndSymbols = BopomofoEncoder.trimToLengthFromEnd(
this.symbols, this.longestPhraseLength));
};
JSZhuyinQueryData.prototype.getTrimmedSymbolsCombinations = function() {
if (this._trimmedSymbolsCombinations) {
return this._trimmedSymbolsCombinations;
}
return (this._trimmedSymbolsCombinations =
BopomofoEncoder.getSymbolCombinations(this.getTrimmedSymbols()));
};
JSZhuyinQueryData.prototype.getTrimmedFromEndSymbolsCombinations = function() {
if (this._trimmedFromEndSymbolsCombinations) {
return this._trimmedFromEndSymbolsCombinations;
}
return (this._trimmedFromEndSymbolsCombinations =
BopomofoEncoder.getSymbolCombinations(this.getTrimmedFromEndSymbols()));
};
var JSZhuyinComposedCandidatesBuilder =
function JSZhuyinComposedCandidatesBuilder() {
this.cache = null;
};
JSZhuyinComposedCandidatesBuilder.prototype.LONGEST_PHRASE_LENGTH = undefined;
JSZhuyinComposedCandidatesBuilder.prototype.load = function(storage) {
this.storage = storage;
this.cache = new CacheStore();
};
JSZhuyinComposedCandidatesBuilder.prototype.unload = function() {
this.cache = null;
this.storage = null;
};
JSZhuyinComposedCandidatesBuilder.prototype.addToCache =
function(expendedEncodedSounds, data) {
this.cache.add(expendedEncodedSounds, data);
return data;
};
JSZhuyinComposedCandidatesBuilder.prototype.cleanupCache =
function(supersetCodes) {
this.cache.cleanup(supersetCodes);
};
JSZhuyinComposedCandidatesBuilder.prototype.getComposedCandidates =
function(queryData) {
var expendedEncodedSounds = queryData.expendedEncodedSounds;
// The optimization here is based on the fact that the newly appended symbols
// always forms compositions beginning with the previous results.
// Remaining compositions with lower scores cannot possibly results
// in higher score so it does not make sense to calculate them all and
// rank them for every new symbols. Rather, it make more sense to cache the
// results and only create possible compositions and results from them and
// rank them.
// The |previousFirstResults| array contains JSZhuyinCachedResult instances
// from the cache, or, if the sliced expended encoded sounds has not be
// calculated, we would calculate it here and put it into the cache.
//
// The final result will be the matched first composed result of the
// unsliced expended encoded sound.
//
// Note that the result data of the final result and in the cache are both
// calculated with |_getFirstComposedResult()|.
var previousFirstResults = [ undefined ];
var lastComposedResultData;
var i = 0;
var n = expendedEncodedSounds.length;
var slicedExpendedEncodedSounds;
// This loop will ensure previousFirstResults is populated with
// results from length = 1 to n, where length = n will happen to be the one
// result we would need to output.
while (++i <= n) {
slicedExpendedEncodedSounds = expendedEncodedSounds.slice(0, i);
lastComposedResultData = this.cache.get(slicedExpendedEncodedSounds);
// Empty result will be cached as a |null|. A "not found" result is
// explicitly an undefined.
if (lastComposedResultData === undefined) {
lastComposedResultData = this._getFirstComposedResult(
slicedExpendedEncodedSounds, previousFirstResults);
this.addToCache(slicedExpendedEncodedSounds, lastComposedResultData);
}
previousFirstResults[i] = lastComposedResultData;
}
return lastComposedResultData;
};
JSZhuyinComposedCandidatesBuilder.prototype._getFirstComposedResult =
function(expendedEncodedSounds, previousFirstResults) {
var storage = this.storage;
// We would only need to worry about an element in previousFirstResults
// follow by exactly one phrase, instead of getting all possible
// compositions, because it will always be ruled out.
var composedResultDataArr = previousFirstResults
.reduce(function(composedResultDataArr, previousFirstResult, i) {
if (i !== 0 && previousFirstResult === null) {
return composedResultDataArr;
}
var currentComposedResultDataArr = BopomofoEncoder
.getSymbolCombinations(
expendedEncodedSounds.slice(i), this.LONGEST_PHRASE_LENGTH)
.map(function(symbolCodes) {
return [storage.getIncompleteMatched(symbolCodes), symbolCodes];
})
.filter(function(symbolCodesResultData) {
return !!symbolCodesResultData[0];
})
.map(function(symbolCodesResultData) {
var symbolCodesFirstResult =
symbolCodesResultData[0].getFirstResult();
var composedResultData;
if (i !== 0) {
composedResultData = previousFirstResult.copy();
composedResultData.str += symbolCodesFirstResult.str;
composedResultData.score += symbolCodesFirstResult.score;
composedResultData.index = symbolCodesFirstResult.index;
} else {
composedResultData = new JSZhuyinComposedResultData(
symbolCodesFirstResult,
[ symbolCodesFirstResult.str,
BopomofoEncoder.decode(symbolCodesResultData[1]).length ]);
}
return composedResultData;
});
if (currentComposedResultDataArr.length === 0) {
// Make sure we always have at least one full candidate.
// These composed candidates with symbols will have score=-Infinity.
// When all entries are of score=-Infinity, the sorting function will
// sort the last entrie to the top and it will happen to be the must
// useful result.
var composedResultData;
if (previousFirstResult) {
composedResultData = previousFirstResult.copy();
} else {
var firstSymbols =
BopomofoEncoder.decode(expendedEncodedSounds.slice(i, 1));
composedResultData = new JSZhuyinComposedResultData(
null, [ firstSymbols, firstSymbols.length ]);
}
composedResultData.str +=
BopomofoEncoder.decode(expendedEncodedSounds.slice(i));
composedResultData.score = -Infinity;
composedResultDataArr.push(composedResultData);
return composedResultDataArr;
} else {
return composedResultDataArr.concat(currentComposedResultDataArr);
}
}.bind(this), /* composedResultDataArr */ [])
.sort(function(a, b) {
if (b.score > a.score) {
return 1;
}
if (b.score < a.score) {
return -1;
}
if (b.str > a.str) {
return 1;
}
if (b.str < a.str) {
return -1;
}
return 0;
});
return composedResultDataArr[0];
};
var JSZhuyinPartialMatchingCandidatesBuilder = function() {
};
JSZhuyinPartialMatchingCandidatesBuilder.prototype.load = function(storage) {
this.storage = storage;
};
JSZhuyinPartialMatchingCandidatesBuilder.prototype.unload = function() {
this.cache = null;
};
JSZhuyinPartialMatchingCandidatesBuilder.prototype.getCandidates =
function(queryData) {
var storage = this.storage;
var insertFullyMatched =
(queryData.getTrimmedSymbols() !== queryData.symbols);
var dataPackResultDataArr = queryData.getTrimmedSymbolsCombinations()
.map(function(symbolCodes) {
var symbolCodeDataArr = [];
var arr, symbolLength;
var i = 0;
var n = insertFullyMatched ? symbolCodes.length : symbolCodes.length - 1;
while (i++ < n) {
arr = symbolCodes.slice(0, i);
symbolLength = BopomofoEncoder.decode(arr).length;
symbolCodeDataArr.push([symbolLength, arr]);
}
return symbolCodeDataArr;
})
.reduce(function(orderedSymbolCodeArrs, symbolCodeDataArr) {
symbolCodeDataArr.forEach(function(symbolCodeData) {
if (!orderedSymbolCodeArrs[symbolCodeData[0]]) {
orderedSymbolCodeArrs[symbolCodeData[0]] = [];
orderedSymbolCodeArrs[symbolCodeData[0]].push(symbolCodeData[1]);
} else {
var isDuplication = orderedSymbolCodeArrs[symbolCodeData[0]]
.some(function(arr) {
return (arr.length === symbolCodeData[1]) &&
arr.some(function(code, i) {
return (code === symbolCodeData[1][i]); });
});
if (!isDuplication) {
orderedSymbolCodeArrs[symbolCodeData[0]].push(symbolCodeData[1]);
}
}
});
return orderedSymbolCodeArrs;
}, /* orderedSymbolCodeArrs */ [])
.map(function(symbolCodeArrs, symbolLength) {
var allDataPackResult = symbolCodeArrs
.map(function(symbolCodes) {
if (!storage.getIncompleteMatched(symbolCodes)) {
return null;
}
return storage.getIncompleteMatched(symbolCodes).getResults();
})
.filter(function(dataPackResult) {
return dataPackResult !== null;
})
.reduce(function(allDataPackResult, dataPackResult) {
return allDataPackResult.concat(dataPackResult);
}, /* allDataPackResult */ [])
.sort(function(a, b) {
if (b.score > a.score) {
return 1;
}
if (b.score < a.score) {
return -1;
}
if (b.str > a.str) {
return 1;
}
if (b.str < a.str) {
return -1;
}
return 0;
});
if (!allDataPackResult.length) {
return;
}
return [symbolLength, allDataPackResult];
})
.filter(function(dataPackResultData) {
return dataPackResultData;
})
.reverse();
return dataPackResultDataArr;
};
/**
* The main IME logic.
* @this {object} JSZhuyin instance.
*/
var JSZhuyin = exports.JSZhuyin = function JSZhuyin() {
this.storage = null;
this.symbols = '';
this.confirmedPartIndex = 0;
this.confirmedCharacters = '';
this.defaultCandidate = undefined;
this.composedCandidatesBuilder = new JSZhuyinComposedCandidatesBuilder();
this.partiallyMatchedCandidatesBuilder =
new JSZhuyinPartialMatchingCandidatesBuilder();
this.overflowCandidateString = '';
this.overflowCandidateSymbolLength = 0;
this.queue = null;
this.candidateMetadata = null;
};
/**
* Limit the length of the symbols in the compositions.
* @type {number}
*/
JSZhuyin.prototype.MAX_SOUNDS_LENGTH = 48;
/**
* Longest possible phrase in the database, any longer than this will not be
* matched
* @type {Number}
*/
JSZhuyin.prototype.LONGEST_PHRASE_LENGTH = 6;
/**
* Suggest phrases after confirming characters.
* @type {boolean}
*/
JSZhuyin.prototype.SUGGEST_PHRASES = true;
/**
* Allow re-order of symbol input.
* Better error-handling for typing with hardware keyboard.
*/
JSZhuyin.prototype.REORDER_SYMBOLS = false;
/**
* When searching for matching words/phrases, consider these pairs of symbols
* are interchangables.
* Must be a string representing 2n sounds in Bopomofo symbols.
*
* Example string: 'ㄣㄥㄌㄖㄨㄛㄡ', making ㄣ interchangable with ㄥ and
* ㄌ interchangable with ㄖ, and ㄨㄛ with ㄡ.
* @type {string}
*/
JSZhuyin.prototype.INTERCHANGABLE_PAIRS = '';
/**
* ArrayBuffer instance holding the database.
* You may set this constant to skip asynchronization load() phase.
* @type {object}
*/
JSZhuyin.prototype.DATA_ARRAY_BUFFER = null;
/**
* handleKey will handle every key if this is set to true.
* Not applicable keys (e.g. a Backspace key when there isn't any symbol
* to remove) will be discarded.
* @type {Boolean}
*/
JSZhuyin.prototype.MUST_HANDLE_ALL_KEYS = false;
/**
* Overwritten path of database file.
* @type {string}
*/
JSZhuyin.prototype.dataURL = '';
/**
* Run when the loading is complete.
* @type {function}
*/
JSZhuyin.prototype.onloadend = null;
/**
* Run when loading is successful.
* @type {function}
*/
JSZhuyin.prototype.onload = null;
/**
* Run when unload.
* @type {function}
*/
JSZhuyin.prototype.onunload = null;
/**
* Run when database download progresses.
* @type {function}
*/
JSZhuyin.prototype.ondownloadprogress = null;
/**
* Run when error occours.
* @type {function}
*/
JSZhuyin.prototype.onerror = null;
/**
* Run when an action is handled; receives reqId passed to the functions.
* @type {function}
*/
JSZhuyin.prototype.onactionhandled = null;
/**
* Callback to call when the composition updates.
* @type {function}
*/
JSZhuyin.prototype.oncompositionupdate = null;
/**
* Callback to call when the composition ends.
* @type {function}
*/
JSZhuyin.prototype.oncompositionend = null;
/**
* Callback to call when candidate menu updates.
* @type {function}
*/
JSZhuyin.prototype.oncandidateschange = null;
/**
* Handle a key with it's DOM UI Event Level 3 key value.
* @param {string} key The key property of the key to handle,
* should be the printable character or a registered
* name in the spec.
* @param {any} reqId ID of the request.
* @return {boolean} Return true if the key will be handled async.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.handleKey = function jz_handleKey(key, reqId) {
if (!this.queue) {
throw new Error('JSZhuyin: You need to load() first.');
}
if (typeof key !== 'string') {
throw new Error('JSZhuyin: key passed to handleKey must be a string.');
}
if (this.MUST_HANDLE_ALL_KEYS) {
this.queue.queue('key', key, reqId);
return true;
}
if (key.length === 1 && BopomofoEncoder.isBopomofoSymbol(key)) {
// We must handle Bopomofo symbols.
this.queue.queue('key', key, reqId);
return true;
}
if (key.length > 1) {
var isAllBopomofoSymbols =
key.split('').every(function(chr) {
return BopomofoEncoder.isBopomofoSymbol(chr);
});
if (isAllBopomofoSymbols) {
// We must handle Bopomofo symbols.
this.queue.queue('key', key, reqId);
return true;
}
}
if (this.defaultCandidate || this.symbols) {
// We must handle all the keys if there are pending symbols or candidates.
this.queue.queue('key', key, reqId);
return true;
}
return false;
};
/**
* Handle a key event with it's keyCode or charCode. Deprecated.
* @param {number} code charCode of the keyboard event.
* If charCode is 0, you should pass keyCode instead.
* @param {any} reqId ID of the request.
* @return {boolean} Return true if the key will be handled async.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.handleKeyEvent = function jz_handleKeyEvent(code, reqId) {
var key;
switch (code) {
case 0x08:
key = 'Backspace';
break;
case 0x0d:
key = 'Enter';
break;
case 0x1b:
key = 'Escape';
break;
default:
// XXX: We are considering everything reach here is a printable character.
key = String.fromCharCode(code);
}
return this.handleKey(key, reqId);
};
/**
* Select a candidate. Will be handled in the action queue.
* @param {object} candidate One of the candidate that was sent via
* oncandidateschange callback.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.selectCandidate = function jz_selCandi(candidate, reqId) {
if (!Array.isArray(candidate) ||
typeof candidate[0] !== 'string' ||
typeof candidate[1] !== 'number') {
throw new Error('JSZhuyin: ' +
'malformed candidate object in selectCandidate call.');
}
this.queue.queue('candidateSelection', candidate, reqId);
};
/**
* Load JSZhuyin; loading the database and register callbacks, etc.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.load = function jz_load(data) {
if (this.loaded) {
throw new Error('Already loaded.');
}
this.loaded = true;
this.symbols = '';
this.defaultCandidate = undefined;
this.queue = new ActionQueue();
this.queue.handle = this.handle.bind(this);
this.storage = new JSZhuyinDataPackStorage();
this.candidateMetadata = new JSZhuyinCandidateMetadata();
this.composedCandidatesBuilder.load(this.storage);
this.partiallyMatchedCandidatesBuilder.load(this.storage);
if (this.DATA_ARRAY_BUFFER && !data) {
data = this.DATA_ARRAY_BUFFER;
}
if (data instanceof ArrayBuffer) {
this.storage.load(data);
if (typeof this.onload === 'function') {
this.onload();
}
if (typeof this.onloadend === 'function') {
this.onloadend();
}
} else {
this.dataLoader = new DataLoader();
if (this.dataURL) {
this.dataLoader.DATA_URL = this.dataURL;
}
this.dataLoader.onerror = function(err) {
if (typeof this.onerror === 'function') {
this.onerror(err);
}
}.bind(this);
this.dataLoader.onload = function() {
this.storage.load(this.dataLoader.data);
if (typeof this.onload === 'function') {
this.onload();
}
}.bind(this);
this.dataLoader.onloadend = function() {
if (typeof this.onloadend === 'function') {
this.onloadend();
}
}.bind(this);
this.dataLoader.onprogress = function(progress) {
if (typeof this.ondownloadprogress === 'function') {
this.ondownloadprogress(progress);
}
}.bind(this);
this.dataLoader.load();
}
};
/**
* Set configurations.
* @param {object} config Configuration to set on JSZhuyin.
*/
JSZhuyin.prototype.setConfig = function(config) {
for (var key in config) {
this[key] = config[key];
}
};
/**
* Unload JSZhuyin. Queue a task to remove everything from memory.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.unload = function jz_unload() {
if (!this.loaded) {
throw new Error('Already unloaded.');
}
this.queue.queue('unload');
};
/**
* Unload JSZhuyin in this function loop. Remove everything from memory.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.unloadSync = function jz_unloadSync() {
if (!this.loaded) {
throw new Error('Already unloaded.');
}
this.loaded = false;
if (this.storage) {
this.storage.unload();
this.storage = null;
}
if (this.dataLoader) {
this.dataLoader = null;
}
if (this.composedCandidatesBuilder) {
this.composedCandidatesBuilder.unload();
this.composedCandidatesBuilder = null;
}
if (this.partiallyMatchedCandidatesBuilder) {
this.partiallyMatchedCandidatesBuilder.unload();
this.partiallyMatchedCandidatesBuilder = null;
}
this.symbols = '';
this.storage = null;
this.defaultCandidate = undefined;
this.queue.handle = null;
this.queue = null;
if (typeof this.onunload === 'function') {
this.onunload();
}
};
/**
* Actual function to handle an action in the action queue.
* You should not call this method directly.
* @param {string} type A type keyword.
* @param {any} data Data to handle for the action.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.handle = function jz_handle(type, data, reqId) {
switch (type) {
case 'key':
switch (data) {
case 'Backspace':
if (this.symbols.length === 0) {
// Sliently discard the key here. Any meaningful response at
// this stage would be throw the event back to the client,
// which it would not be able to handle it either.
this.sendActionHandled(reqId);
this.queue.done();
break;
}
this.symbols = this.symbols.substr(0, this.symbols.length - 1);
this.query(reqId);
break;
case 'Enter':
if (!this.defaultCandidate) {
// Sliently discard the key here. Any meaningful response at
// this stage would be throw the event back to the client,
// which it would not be able to handle it either.
this.sendActionHandled(reqId);
this.queue.done();
break;
}
this.confirmCandidate(this.defaultCandidate, reqId);
break;
case 'Escape':
this.symbols = '';
this.query(reqId);
break;
default:
if (data.length === 1 && BopomofoEncoder.isBopomofoSymbol(data)) {
var mode = this.REORDER_SYMBOLS ?
BopomofoEncoder.APPEND_MODE_REORDER :
BopomofoEncoder.APPEND_MODE_NONE;
this.symbols =
BopomofoEncoder.appendToSymbols(this.symbols, data, mode);
this.query(reqId);
break;
}
if (data.length > 1) {
var isAllBopomofoSymbols =
data.split('').every(function(chr) {
return BopomofoEncoder.isBopomofoSymbol(chr);
});
if (isAllBopomofoSymbols) {
this.symbols += data;
this.query(reqId);
break;
}
}
if (!this.defaultCandidate) {
// Sliently discard the key here. Any meaningful response at
// this stage would be throw the event back to the client,
// and for synthesize this key.
this.sendActionHandled(reqId);
this.queue.done();
break;
}
// All other keys.
// XXX: We could only handle it as if it's a printable character here.
if (this.defaultCandidate) {
this.confirmCandidate(
[this.defaultCandidate[0] + data,
this.defaultCandidate[1]],
reqId);
} else {
this.confirmCandidate([data, this.candidateMetadata.NULL_ID]);
}
break;
}
break;
case 'candidateSelection':
this.confirmCandidate(data, reqId);
break;
case 'unload':
this.unloadSync();
break;
default:
throw new Error('Unknown action type: ' + type);
}
};
/**
* Run the query against the current symbols.
* You should not call this method directly.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.query = function jz_query(reqId) {
if (this.symbols.length === 0) {
this.candidateMetadata.clear();
this.updateComposition(reqId);
this.updateCandidates([], reqId);
this.sendActionHandled(reqId);
this.queue.done();
return;
}
// First, create the object that holds different forms of our current
// symbols to query.
var queryData = new JSZhuyinQueryData(
this.symbols, this.LONGEST_PHRASE_LENGTH);
// Confirming the overflow candidate if the lengths is too long.
if (queryData.expendedEncodedSounds.length > this.MAX_SOUNDS_LENGTH) {
this.endComposition(this.overflowCandidateString, reqId);
this.symbols = this.symbols.substr(this.overflowCandidateSymbolLength);
queryData = new JSZhuyinQueryData(this.symbols, this.LONGEST_PHRASE_LENGTH);
}
this.updateComposition(reqId);
this.storage.setInterchangeablePairs(this.INTERCHANGABLE_PAIRS);
// ==== START COMPOSING THE RESULT ====
var results = [];
var storage = this.storage;
this.overflowCandidateString = '';
this.overflowCandidateSymbolLength = 0;
this.candidateMetadata.clear();
// ==== PART I: PHRASES ====
// List all the choices if the entire query happens to match to
// phrases.
results = this._insertFullyMatchingCandidates(results, queryData);
// ==== PART II: COMPOSED RESULTS ====
// Insert one composed results, composed by the top-ranked phrases.
results = this._insertFullyMatchingComposedCandidates(results, queryData);
// ==== PART III: PHRASES THAT MATCHES SYMBOLS PARTIALLY ====
results = this._insertPartialMatchingCandidates(results, queryData);
// ==== PART IV: UNFORTUNATE TYPO ====
// Lastly, append the symbols as the last candidate to give out a proper
// feedback (if applicable).
results = this._insertTypoHints(results, queryData.expendedEncodedSounds);
this.updateCandidates(results, reqId);
this.sendActionHandled(reqId);
// Remove extra data in the composed result cache.
this.composedCandidatesBuilder.cleanupCache(queryData.expendedEncodedSounds);
// Flatten codes array of possible combinations from the beginning and end
// for cache cleanning in storage.
// (Disregard the middle part because they will be backed by composed result
// caches.)
var supersetCodes = [].concat(
queryData.getTrimmedSymbolsCombinations(),
queryData.getTrimmedFromEndSymbolsCombinations()
)
.reduce(function(supersetCodes, symbolCodes) {
return supersetCodes.concat(symbolCodes);
}, /* supersetCodes */ []);
// Remove cache entires from storage cache.
storage.cleanupCache(supersetCodes);
this.queue.done();
};
/**
* Internal method for Part I of query(), fully matched candidates into the
* result.
* @param {array(array(string, number))} results Result candidates.
* @param {object} queryData JSZhuyinQueryData
* instance.
* @return {array(array(string, number))} The result candidates array passed
* (modified in-place).
*/
JSZhuyin.prototype._insertFullyMatchingCandidates =
function(results, queryData) {
if (queryData.getTrimmedSymbols() !== this.symbols) {
return results;
}
var storage = this.storage;
var expendedEncodedSounds = queryData.expendedEncodedSounds;
var combinations = queryData.getTrimmedSymbolsCombinations();
combinations
.map(function(symbolCodes) {
// No need to search in the storage.
if (symbolCodes.length > this.LONGEST_PHRASE_LENGTH) {
return null;
}
return storage.getIncompleteMatched(symbolCodes);
}.bind(this))
.reduce(function(resultsArr, dataPack) {
if (!dataPack) {
return resultsArr;
}
return resultsArr.concat(dataPack.getResults());
}, /* resultsArr */ [])
.sort(function(a, b) {
if (b.score > a.score) {
return 1;
}
if (b.score < a.score) {
return -1;
}
if (b.str > a.str) {
return 1;
}
if (b.str < a.str) {
return -1;
}
return 0;
})
.forEach(function(result, i) {
if (!this.overflowCandidateString) {
this.overflowCandidateString = result.str;
this.overflowCandidateSymbolLength = this.symbols.length;
}
if (i === 0) {
this.composedCandidatesBuilder.addToCache(
expendedEncodedSounds,
new JSZhuyinComposedResultData(
result, [ result.str, this.symbols.length ]));
}
results.push([result.str,
this.candidateMetadata.saveData(this.symbols.length, result.index)]);
}.bind(this));
return results;
};
/**
* Internal method for Part I of query(), fully matched candidates into the
* result.
* @param {array(array(string, number))} results Result candidates.
* @param {object} queryData JSZhuyinQueryData
* instance.
* @return {array(array(string, number))} The result candidates array passed
* (modified in-place).
*/
JSZhuyin.prototype._insertFullyMatchingComposedCandidates =
function(results, queryData) {
// We don't need to compose any results if there is already results from
// part I.
// Assuming Part II reult is always inferior than Part I result?
if (results.length) {
return results;
}
this.composedCandidatesBuilder.LONGEST_PHRASE_LENGTH =
this.LONGEST_PHRASE_LENGTH;
var composedResultData =
this.composedCandidatesBuilder.getComposedCandidates(queryData);
if (!this.overflowCandidateString) {
this.overflowCandidateString = composedResultData.overflowData[0];
this.overflowCandidateSymbolLength = composedResultData.overflowData[1];
}
// XXX: suggest() not only needs the lastPartIndex but also the last
// part of the confirmed characters to work.
results.push([composedResultData.str,
this.candidateMetadata.saveData(
this.symbols.length, composedResultData.index)]);
return results;
};
/**
* Internal method for Part III of query(), insert partially matched candidates
* into results object
* @param {array(array(string, number))} results Result candidates.
* @param {object} queryData JSZhuyinQueryData
* instance.
* @return {array(array(string, number))} The result candidates array passed
* (modified in-place).
*/
JSZhuyin.prototype._insertPartialMatchingCandidates =
function(results, queryData) {
var dataPackResultDataArr =
this.partiallyMatchedCandidatesBuilder.getCandidates(queryData);
dataPackResultDataArr
.forEach(function(dataPackResultData) {
var symbolLength = dataPackResultData[0];
dataPackResultData[1].forEach(function(result) {
var isDuplication = [].concat(results).reverse()
.some(function(previousResult) {
return (previousResult[0] === result.str);
});
if (isDuplication) {
return;
}
if (!this.overflowCandidateString) {
this.overflowCandidateString = result.str;
this.overflowCandidateSymbolLength = symbolLength;
}
var res = [result.str,
this.candidateMetadata.saveData(symbolLength, result.index)];
results.push(res);
}.bind(this));
}.bind(this));
return results;
};
/**
* Internal method for Part IV of query(), insert typo hints.
* into results object
* @param {array(array(string, number))} results Result
* candidates.
* @param {array(number)} expendedEncodedSounds Symbols encoded.
* @return {array(array(string, number))} The result candidates array passed
* (modified in-place).
*/
JSZhuyin.prototype._insertTypoHints = function(results, expendedEncodedSounds) {
if (results.length === 1 && expendedEncodedSounds.length !== 1) {
var symbols = BopomofoEncoder.decode([expendedEncodedSounds[0]]);
results.push([symbols, this.candidateMetadata.saveData(symbols.length, 0)]);
}
return results;
};
/**
* Suggest possible phrases after the user had enter a term.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.suggest = function jz_suggest(reqId) {
this.candidateMetadata.clear();
if (this.confirmedPartIndex === this.candidateMetadata.NULL_ID ||
!this.SUGGEST_PHRASES) {
this.updateCandidates([], reqId);
this.sendActionHandled(reqId);
this.queue.done();
return;
}
var suggests = [];
var confirmedCharactersLength = this.confirmedCharacters.length;
var results = this.storage.getRangeFromContentIndex(this.confirmedPartIndex);
results.forEach(function each_suggest(dataPack) {
var dataPackResults =
dataPack.getResultsBeginsWith(this.confirmedCharacters);
dataPackResults.forEach(function each_result(dataPackResult) {
// Don't push duplicate entries.
var found = suggests.some(function finddup(suggest) {
return (dataPackResult.str === suggest.str);
});
if (!found) {
suggests.push(dataPackResult);
}
});
}.bind(this));
var candidates = [];
suggests.sort(function sort_suggests(a, b) {
if (b.score > a.score) {
return 1;
}
if (b.score < a.score) {
return -1;
}
if (b.str > a.str) {
return 1;
}
if (b.str < a.str) {
return -1;
}
return 0;
}).forEach(function each_suggests(suggests) {
candidates.push([
suggests.str.substr(confirmedCharactersLength),
this.candidateMetadata.NULL_ID]);
}, this);
this.updateCandidates(candidates, reqId);
this.sendActionHandled(reqId);
this.queue.done();
};
/**
* Update composition by call the oncompositionupdate callback.
* You should not call this method directly.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.updateComposition = function jz_updateComposition(reqId) {
if (typeof this.oncompositionupdate === 'function') {
this.oncompositionupdate(this.symbols, reqId);
}
};
/**
* End composition by call the oncompositionend callback.
* You should not call this method directly.
* @param {string} str String to end composition with.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.endComposition = function jz_endComposition(str, reqId) {
if (typeof this.oncompositionend === 'function') {
this.oncompositionend(str, reqId);
}
};
/**
* Update the candidate with query results.
* You should not call this method directly.
* @param {array} results The result array.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.updateCandidates = function jz_updateCandidates(results,
reqId) {
// Suggestion candidates cannot be confirmed as default candidate.
if (results[0] && results[0][1]) {
this.defaultCandidate = results[0];
} else {
this.defaultCandidate = undefined;
}
if (typeof this.oncandidateschange === 'function') {
this.oncandidateschange(results, reqId);
}
};
/**
* Confirm a selection by calling the compositionend callback and run
* the query again.
* You should not call this method directly.
* @param {object} candidate One of the candidate that was sent via
* oncandidateschange callback.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.confirmCandidate = function jz_confirmCandidate(candidate,
reqId) {
this.endComposition(candidate[0], reqId);
this.confirmedCharacters = candidate[0];
var metadata = this.candidateMetadata.getData(candidate[1]);
this.confirmedPartIndex = metadata[1];
this.symbols = this.symbols.substr(metadata[0]);
if (this.symbols.length !== 0) {
this.query(reqId);
} else {
this.updateComposition(reqId);
this.suggest(reqId);
}
};
/**
* Call the onactionhandled callback.
* You should not call this method directly.
* @param {any} reqId ID of the request.
* @this {object} JSZhuyin instance.
*/
JSZhuyin.prototype.sendActionHandled = function jz_sendActionHandled(reqId) {
if (typeof this.onactionhandled === 'function') {
this.onactionhandled(reqId);
}
};
}));