해당 문서는 MS Office Plugin에 대한 코드이기 때문에 통상적인 프론트 개발자 또는 백엔드 개발자가 보고 이해하는 데에 상당한 시간이 소요되는 문서입니다. 이 점 참조하여 해당 플러그인을 수정하여 재배포하고자 하는 경우 공수에 대해 적어도 3개월을 잡으실 것을 권장해드립니다.
Once you’ve created a BeringAI API account and located your authentication key, you’re ready to make your first request. Let’s start with text translation.
npm -v
npm install npm -g
npm install -g yo generator-office
해당 코드는 처음 플러그인이 동작할 때, 토큰이 존재하는지, 토큰이 올바른지 등에 대해 파악해주는 코드입니다.
아래 Office.onReady로 시작하는 코드는 플러그인이 다 로드된 것이 확인된 후 동작해야할 것들이 기재되었습니다.
토큰이 유효하다면 loadPage 펑션을 사용하여 translator.html, translator.js를 로드하여 taskpane.html에 InnerHtml로 보여줍니다.
토큰이 없거나 유효하지 않다면 loadPage 펑션을 사용하여 firstExperience.html, firstExperience.js를 로드하여 taskpane.html에 content Element에 삽입되어 InnerHtml로 보여줍니다.
Office.onReady((info) => {
if (info.host === Office.HostType.Word) {
document.getElementById("sideload-msg").style.display = "none";
document.getElementById("app-body").style.display = "flex";
var token = getAccessToken();
if (!token) {
console.log('토큰이 없습니다.');
loadPage("firstExperience.html", "firstExperience.js");
} else {
try {
var decoded = jwtDecode(token);
console.log('Decoded:', decoded);
loadPage("translator.html", "translator.js");
} catch (error) {
console.error('유효하지 않은 토큰입니다:', error.message);
loadPage("firstExperience.html", "firstExperience.js");
}
}
}
});
function loadPage(pageUrl, scriptUrl) {
var contentDiv = document.getElementById("content");
// Fetch the content of the page
fetch(pageUrl)
.then(response => response.text())
.then(html => {
// Set the HTML content of the content div
contentDiv.innerHTML = html;
// Load and execute the script
loadScript(scriptUrl);
})
.catch(error => console.error("Error loading page:", error));
}
function loadScript(scriptUrl) {
// Create a script element
var script = document.createElement("script");
// Set the script source URL
script.src = scriptUrl;
// Append the script element to the body
document.body.appendChild(script);
}
해당 페이지는 유저애게 베링AI Plugin을 간략하게 소개하면서 로그인을 유도하는 페이지이다. 해당 페이지는 기획자의 의도로 만들어진 것이 절대 아니며, 마이크로소프트의 심사 대상에 ‘First experience’에 대한 내용이 포함되어 있기 때문에 넣은 것이다.
해당 JS코드는 단순히 ‘로그인’ 버튼을 누르면 loadPage 펑션을 통해 auth.js, auth.html을 여는 내용이다. 별다른 내용이 없으므로 그대로 두고 쓰되 참조만 하면 된다.
번역기에 대한 틀이 있는 HTML이다.
수정할 사안이 있다면 통상적으로 ‘언어’ 및 ‘엔진’에 대한 업데이트가 있는 경우이다.
바로 아래 코드는 엔진을 선택하는 메뉴이다. legal, patent, business라는 id만 유지한다면 정상적으로 동작시킬 수 있다.
더 아래 코드는 소스언어 선택 드롭다운의 코드인데, 언어별로 3글자 언어 id를 가지고 있다는 것을 확인할 수 있다.
삭선 처리되어있는 코드처럼 새 언어가 필요할 경우 동일한 형태로 추가할 수 있다.
세글자 언어 코드만 개발진과 협의한대로 지정해주면 된다.
gavel
Legal
lightbulb
Patent
business_center
Business
페이지 로드시에 동작해야하는 코드들에 대한 모음이다.
document.getElementById로 기재된 코드들은 ‘설정’, ‘번역 엔진’ 을 비롯한 각종 버튼을 읽어내기 위해 존재한다.
//document.getElementById(“review_selection_sticky_footer”).onclick = () => tryCatch(requestReviewForLongText);
위 두 줄의 코드는 번역 결과물에 대해 평가하는 리뷰 기능에 대한 버튼 관련 코드인데 현재 삭선처리 해두었다. 장기적으로 해당 리뷰 기능이 Word 플러그인에 대한 유저 피드백을 접수하는 수단이 되리라 생각된다.
참조로 위의 코드들이 Office.onReady에 존재해야하는 이유는 그 외의 영역에 있을 경우 별도로 호출하지 않는 한, MS Word내의 브라우저에서 아예 동작하지 않기 때문이다.
아래 코드 중 가장 중요한 것은 사실상 run()이다. 다른 코드들은 다른 프론트엔드 코드라고 생각하면 편하지만 run은 MS Word Plugin답게 만드는 코드이기 때문이다. 여기서 말하는 ‘MS Word Plugin 답게’란, 사이드 브라우저를 벗어나 MS Word Editor 창에 영향을 준다는 것이다.
Office.onReady((info) => {
if (info.host === Office.HostType.Word) {
// 페이지 로드 시에도 호출
document.getElementById("setting").onclick = () => tryCatch(setting);
document.getElementById("legal").onclick = () => tryCatch(selectLegalEngine)
document.getElementById("patent").onclick = () => tryCatch(selectPatentEngine)
document.getElementById("business").onclick = () => tryCatch(selectBusinessEngine)
document.getElementById("modal_close").onclick = () => tryCatch(closeModal)
document.getElementById("modal_close_patent").onclick = () => tryCatch(closeModal)
document.getElementById("modal_close_free_tier").onclick = () => tryCatch(closeModal)
document.getElementById("modal_close_same").onclick = () => tryCatch(closeModal)
document.getElementById("sideload-msg").style.display = "none";
document.getElementById("app-body").style.display = "flex";
showSelectionContent();
//Selection 영역
document.getElementById("insert").onclick = () => tryCatch(replaceText);
//document.getElementById("review_selection").onclick = () => tryCatch(requestReview);
//document.getElementById("review_selection_sticky_footer").onclick = () => tryCatch(requestReviewForLongText);
document.getElementById("insert_sticky_footer").onclick = () => tryCatch(replaceText);
document.getElementById("clear").onclick = () => tryCatch(clearSourceTarget);
document.getElementById("scroll").onclick = () => tryCatch(scrollDown);
document.getElementById("detected_language").onclick = () => tryCatch(changeToDetectedLanguage);
document.getElementById("target_copy").onclick = () => tryCatch(targetCopy);
document.getElementById("target_copy_sticky_footer").onclick = () => tryCatch(targetCopy);
document.getElementById("open_source_select").onclick = (event) => tryCatch(() => toggleSelectSource(event));
document.getElementById("select_source_language").onclick = (event) => tryCatch(() => toggleSelectSourceOpened(event));
document.getElementById("open_target_select").onclick = (event) => tryCatch(() => toggleSelectTarget(event));
document.getElementById("select_target_language").onclick = (event) => tryCatch(() => toggleSelectTargetOpened(event));
document.getElementById("switch").onclick = () => tryCatch(toggleSwitch);
var sourceLanguageButtons = document.querySelectorAll(".Selection-Button-Source.Source-Language-Select-Button");
// Use forEach to add click event listener to each element
sourceLanguageButtons.forEach(button => {
button.onclick = () => tryCatch(() => changeButtonTextSource(button));
});
var targetLanguageButtons = document.querySelectorAll(".Selection-Button-Target.Target-Language-Select-Button");
// Use forEach to add click event listener to each element
targetLanguageButtons.forEach(button => {
button.onclick = () => tryCatch(() => changeButtonTextTarget(button));
});
run();
applyStylesSource();
applyStylesTarget();
}
});
Office.context.document.addHandlerAsync는 document내에서의 이벤트 핸들러를 추가한다고 보면 된다.
Office.EventType.DocumentSelectionChanged는 document안에서의 Selection이 변경되는 이벤트를 감지하는 것이다. 여기서 말하는 Selection이란 워드 내에서의 드래그를 통한 영역 선택을 의미한다.
run()펑션을 통해 유저는 드래그한 텍스트를 번역기에 넣어 가져올 수 있다.
더 자세한 내용은 아래의 getSelect() 펑션을 확인해야한다.
// Run a batch operation against the Word object model.
async function run() {
Office.context.document.addHandlerAsync(
Office.EventType.DocumentSelectionChanged,
selectionChangedHandler
);
await getSelect();
}
아래 코드는, selected된 문자열을 가져와 소스 텍스트 창에 넣고 번역을 수행하는 것 까지 포함된다.
만약 선택된 문자가 공백일 경우, 번역을 수행하지 않는다.
만약 선택한 문자가 5000자 보다 길 경우, 5000자 까지만 잘라서 가지고 온다.
선택한 문자의 길이가 길어 소스 텍스트 박스의 세로 길이가 화면의 길이보다 길 경우, 푸터처럼 아래 영역에 피쳐들을 띄워주는 바가 노출되게 해두었다. calculatedLengthStickyFooter 참조.
debounceMakeApiRequest();는 번역을 수행하는 펑션이다. 유저의 요금이 낭비되지 않도록 디바운스를 걸어두었는데, 후술되는 관련 파트를 참조해주기 바란다.
마지막으로 getSelection()은 MS Office Add-in의 일종의 내장 펑션으로 본 소스코드에는 존재하지 않는다. 단순히 긁어오는 펑션으로 보면 된다.
async function getSelect() {
const selectedText = await runWithPromise(context => {
const selection = context.document.getSelection();
selection.load('text');
return context.sync().then(() => {
return selection.text;
});
});
if(selectedText === ''){
console.log('Blank Text:', selectedText);
}
else if(selectedText.length > 5000){
const sourceElement = document.getElementById("source");
sourceElement.textContent = selectedText.substring(0, 5000);
var calculatedLength = document.getElementById("calculated_length")
var calculatedLengthStickyFooter = document.getElementById("calculated_length_sticky_footer")
calculatedLength.textContent = "5000";
calculatedLengthStickyFooter.textContent = "5000";
buttonSection.style.display = buttonGroup.style.display = 'inline-block';
debounceMakeApiRequest();
if (sourceHeight.clientHeight > document.body.clientHeight - 134){
scrollDownButton.style.display = 'flex';
stickyFooter.style.display = 'flex';
}
else{
scrollDownButton.style.display = 'none';
stickyFooter.style.display = 'none';
}
}
else{
var calculatedLength = document.getElementById("calculated_length")
var calculatedLengthStickyFooter = document.getElementById("calculated_length_sticky_footer")
const sourceElement = document.getElementById("source");
sourceElement.textContent = selectedText;
calculatedLength.textContent = selectedText.length;
calculatedLengthStickyFooter.textContent = selectedText.length;
buttonSection.style.display = buttonGroup.style.display = 'inline-block';
debounceMakeApiRequest();
if (sourceHeight.clientHeight > document.body.clientHeight){
scrollDownButton.style.display = 'flex';
stickyFooter.style.display = 'flex';
}
else{
scrollDownButton.style.display = 'none';
stickyFooter.style.display = 'none';
}
}
}
MakeApiRequest();는 번역을 수행하는 API를 수행시키는 펑션이다.
debounceMakeApiRequest()는 위 펑션에 디바운스를 걸어 수행시키는 펑션이라고 보면 된다.
debounceMakeApiRequest()펑션은 일반적으로 소스 텍스트 박스에 번역하고자 하는 텍스트를 넣은 경우에 사용되지는 않는다.
debounceMakeApiRequest()펑션은 MS Word에디터 안에서 일어나는 셀렉션의 변경 이벤트에 의해 발생하는 getSelect()내에서만 호출이 된다. 이는 드래그를 하여 텍스트를 가져오는 것이 run with promise로 비동기적으로 수행되기 때문에 짧은 시간 내에 여러번 드래그하거나 셀렉션한 영역을 변경할 경우 번역이 여러번 수행될 수 있기 때문에 디바운스를 걸어두었다.
function debounceMakeApiRequest() {
clearTimeout(debounceTimeout);
console.log("debounce!");
debounceTimeout = setTimeout(() => {
makeApiRequest();
}, 1000); // 1초 지연
}
MakeApiRequest();는 번역을 수행하는 API를 수행시키는 펑션이다.
첫번째는 소스 텍스트 박스에 들어있는 텍스트를(직접 입력했거나, 셀렉션으로 가져왔거나) sourceText로 지정하는 것이다.
첫번째 if문은 소스텍스트가 비어있는 경우에 단순히 return함으로써 번역 API가 수행되지 않도록 하는 것이다.
requestedSourceLanguageId는 handleTranslation()펑션을 수행하는데, 해당 펑션은 자동감지되거나 선택되어있는 소스 언어의 ISO 3자리 값을 의미한다.
isSupportedPatent는 선택된 소스 및 타겟 언어쌍이 특허번역기에 포함되는지 확인하기 위해 존재한다.
중간에 try로 시작하여 각종 에러 모달을 띄우는 코드가 있는데 잘못된 언어쌍이거나 잘못된 엔진을 사용할 경우 띄우도록 설계되었다.
firstResponse는 번역 잡을 요청하는 과정으로, 번역 엔진, 텍스트, 소스언어, 타겟언어를 지정하여 잡 생성 콜을 한다.
api.post에서 api는 인터셉터로, 토큰이 유효한지 확인하며 axios를 사용해 베링랩 번역 api를 호출한다.
이후, eventSource를 통해 서버로 부터 번역 결과를 받게 된다. 번역 결과물을 받거나 실패한 경우, 유저의 컨트랙트 정보를 확인하고 사용량을 확인하는 코드가 수행되도록 되어있다.
async function makeApiRequest() {
const sourceText = Bering_TextField_Source.textContent.trim();
if (!sourceText) {
return;
}
const requestedSourceLanguageId = handleTranslation();
const isSupportedPatent = supportedPairsPatent.some(pair =>
(pair[0] === requestedSourceLanguageId && pair[1] === selectedTargetLanguageId) ||
(pair[0] === selectedTargetLanguageId && pair[1] === requestedSourceLanguageId)
);
try {
const abortController = new AbortController();
if (requestedSourceLanguageId !== "eng" && selectedTargetLanguageId !== "eng") {
document.getElementById('warningModal').classList.add('modal-show');
}
else if (engine === "patent" && isSupportedPatent==false) {
document.getElementById('warningModalPatent').classList.add('modal-show');
}
else if (requestedSourceLanguageId == selectedTargetLanguageId) {
document.getElementById('warningModalSame').classList.add('modal-show');
}
else{
const firstResponse = await api.post(
textTranslatorUrlPost,
{
domain: engine,
source_content: sourceText,
source_language: requestedSourceLanguageId,
target_language: selectedTargetLanguageId,
},
{
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal,
}
);
console.log('firstResponse:', firstResponse);
const translationJobId = firstResponse.data;
document.querySelector('.loader').classList.add('visible');
console.log('translationJobId:', translationJobId);
const eventSource = initializeEventSource(token, translationJobId);
eventSource.onmessage = (event) => {
const eventData = JSON.parse(event.data);
console.log('Received SSE message:', eventData);
handleApiResponse(eventData);
};
eventSource.onerror = (event) => {
document.querySelector('.loader').classList.remove('visible');
console.error('SSE error:', event);
eventSource.close();
checkContract(token)
.then(data => {
console.log(data);
free_quota = 999999999999;
var quota = Math.floor(data.quota);
if(quota > 5000) {
free_quota = 999999999999;
console.log('Not Free!');
return;
}
})
.catch(error => {
checkUsage(token)
.then(data => {
console.log("????????????????????????????????");
free_usage = data;
})
console.log(error);
free_quota = 5000;
console.log('Free!:', error);
console.log('Free!U!:', free_usage);
console.log('Free!Q!:', free_quota);
document.getElementById('warningModalFreeTier').classList.add('modal-show');
});
};
}
} catch (error) {
handleApiError(error);
}
}
해당 펑션은, 자동 감지 된 소스 언어의 코드값 또는 지정된 소스 언어 코드값을 확인하고 그에 따라 소스 언어 드롭다운의 스타일을 변경하고 코드 값을 makeApiRequest()에 리턴한다.
function handleTranslation() {
const francLanguage = franc(Bering_TextField_Source.textContent, options);
const translateFrom = document.getElementById('translate_from');
if (selectedSourceLanguageId == undefined) {
translateFrom.style.display = 'none';
handleDetectedLanguage(francLanguage);
return francLanguage;
} else {
//const languageTwoName = languageTwoCodes[francLanguage] || "Unknown Language";
const languageName = languageCodes[francLanguage] || "Unknown Language";
if (languageName !== "Unknown Language"){
if (selectedSourceLanguageId != francLanguage) {
var sourceDetectedLanguage = document.getElementById('source_detected_language');
sourceDetectedLanguage.textContent = languageName;
translateFrom.style.display = 'flex';
}
}
var detected_language = document.getElementById("language_detected");
detected_language.textContent = detected_language.dataset.originalText = "";
return selectedSourceLanguageId;
}
}
해당 펑션은, 자동감지 상태의 경우 소스언어 드롭다운 텍스트 영역에 {감지된 언어의 이름 + “- detected”}로 노출되게 하는 코드이다.
function handleTranslation() {
const francLanguage = franc(Bering_TextField_Source.textContent, options);
const translateFrom = document.getElementById('translate_from');
if (selectedSourceLanguageId == undefined) {
translateFrom.style.display = 'none';
handleDetectedLanguage(francLanguage);
return francLanguage;
} else {
//const languageTwoName = languageTwoCodes[francLanguage] || "Unknown Language";
const languageName = languageCodes[francLanguage] || "Unknown Language";
if (languageName !== "Unknown Language"){
if (selectedSourceLanguageId != francLanguage) {
var sourceDetectedLanguage = document.getElementById('source_detected_language');
sourceDetectedLanguage.textContent = languageName;
translateFrom.style.display = 'flex';
}
}
var detected_language = document.getElementById("language_detected");
detected_language.textContent = detected_language.dataset.originalText = "";
return selectedSourceLanguageId;
}
}
function handleApiResponse(response) {
console.log('Received SSE message:', response);
if (response.segments) {
// 모든 target_segment 값을 줄바꿈으로 구분된 하나의 문자열로 만듦
const combinedSegments = response.segments
.map(segment => segment.target_segment.replace(/\n/g, '
'))
.join('');
// 줄바꿈이 포함된 텍스트를 innerHTML을 통해 설정
Bering_TextField_Target.innerHTML = combinedSegments;
} else {
console.error('Invalid response structure:', response);
}
}