..
This commit is contained in:
@@ -205,7 +205,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 140px; /* 카드 최소 높이 (6개가 한 화면에 쏙 들어가도록 컴팩트하게 설정) */
|
min-height: 160px; /* 140px -> 160px로 여유 제공 */
|
||||||
|
height: auto; /* 내용이 많아지면 자동으로 카드 높이 늘어남 */
|
||||||
transition: transform 0.15s, background 0.15s;
|
transition: transform 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +274,7 @@
|
|||||||
<div id="upload-overlay" class="overlay" style="display: none;">
|
<div id="upload-overlay" class="overlay" style="display: none;">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h2>텍스트 파일 열기</h2>
|
<h2>텍스트 파일 열기</h2>
|
||||||
<input type="file" id="file-input" accept=".txt, .epub, .zip">
|
<input type="file" id="file-input" accept=".txt, .epub, .zip" multiple>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -327,6 +328,8 @@
|
|||||||
<button id="btn-reset-keyword-rules" style="padding: 10px; background: #7f8c8d; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">초기화</button>
|
<button id="btn-reset-keyword-rules" style="padding: 10px; background: #7f8c8d; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">초기화</button>
|
||||||
<button id="btn-save-keyword-rules" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">저장하기</button>
|
<button id="btn-save-keyword-rules" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">저장하기</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-reapply-tags" style="margin-top: 8px; width: 100%; padding: 10px; background: #8e44ad; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">🔄 전체 서재 태그 싹 다시 스캔하기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -471,14 +474,15 @@
|
|||||||
/**
|
/**
|
||||||
* @description 현재 파라미터(readerId)를 키로 사용하여 텍스트 데이터를 저장합니다.
|
* @description 현재 파라미터(readerId)를 키로 사용하여 텍스트 데이터를 저장합니다.
|
||||||
*/
|
*/
|
||||||
async function saveBookToDB(fileName, chapters) {
|
async function saveBookToDB(fileName, chapters, customId = readerId) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!readerId) return reject("저장할 ID가 없습니다.");
|
if (!customId) return reject("저장할 ID가 없습니다.");
|
||||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
const store = tx.objectStore(STORE_NAME);
|
const store = tx.objectStore(STORE_NAME);
|
||||||
store.put({ id: readerId, fileName: fileName, chapters: chapters });
|
// 전달받은 customId로 저장하도록 수정
|
||||||
|
store.put({ id: customId, fileName: fileName, chapters: chapters });
|
||||||
tx.oncomplete = () => {
|
tx.oncomplete = () => {
|
||||||
console.log(`[DB] 책 데이터 저장 완료 (ID: ${readerId})`);
|
console.log(`[DB] 책 데이터 저장 완료 (ID: ${customId})`);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
tx.onerror = (e) => reject(e.target.error);
|
tx.onerror = (e) => reject(e.target.error);
|
||||||
@@ -565,49 +569,60 @@
|
|||||||
const pKey = `webReader_progress_${book.id}`;
|
const pKey = `webReader_progress_${book.id}`;
|
||||||
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
||||||
|
|
||||||
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
|
|
||||||
const totalChapters = book.chapters ? book.chapters.length : 0;
|
const totalChapters = book.chapters ? book.chapters.length : 0;
|
||||||
const totalChars = book.chapters ? book.chapters.reduce((sum, chap) => sum + (chap.content ? chap.content.length : 0), 0) : 0;
|
const totalChars = book.chapters ? book.chapters.reduce((sum, chap) => sum + (chap.content ? chap.content.length : 0), 0) : 0;
|
||||||
const formattedChars = totalChars.toLocaleString(); // 천 단위 쉼표 추가 (예: 125,430자)
|
const formattedChars = totalChars.toLocaleString();
|
||||||
|
|
||||||
|
let progressText = savedData.chapter !== undefined
|
||||||
|
? `<span style="font-size:12px; color:#888; margin-left: 6px;">(Ch.${savedData.chapter + 1})</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
let bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
|
let bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
|
||||||
|
|
||||||
let progressText = savedData.chapter !== undefined
|
// ⭐ [수정] flex-wrap과 gap을 적용하여 태그가 많아져도 겹치지 않도록 수정
|
||||||
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
let tagsText = '';
|
||||||
|
if (savedData.tags && savedData.tags.length > 0) {
|
||||||
|
const badges = savedData.tags.map(tagObj => {
|
||||||
|
// 기존 형태(단순 문자열) 태그 예외 처리
|
||||||
|
if (typeof tagObj === 'string') {
|
||||||
|
return `<span style="display:inline-block; padding: 2px 6px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tagObj}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매칭된 키워드 텍스트 조립 (예: "(마법, 드래곤)")
|
||||||
|
const matchedStr = (tagObj.matched && tagObj.matched.length > 0)
|
||||||
|
? `<span style="font-size: 10px; font-weight: normal; opacity: 0.85; margin-left: 3px;">(${tagObj.matched.join(', ')})</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
return `<span style="display:inline-block; padding: 3px 7px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tagObj.name}${matchedStr}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
tagsText = `<div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px;">${badges}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
let lastReadText = savedData.lastRead
|
let lastReadText = savedData.lastRead
|
||||||
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
|
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
let memoText = savedData.memo
|
let memoText = savedData.memo
|
||||||
? `<div style="font-size:12px; color:#f39c12; margin-top: 4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
|
? `<div style="font-size:12px; color:#f39c12; margin-top: 6px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 태그 HTML 생성
|
// 2. [수정] titleArea 레이아웃 정돈
|
||||||
let tagsText = '';
|
|
||||||
if (savedData.tags && savedData.tags.length > 0) {
|
|
||||||
const badges = savedData.tags.map(tag =>
|
|
||||||
`<span style="display:inline-block; padding: 3px 6px; margin-right: 5px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tag}</span>`
|
|
||||||
).join('');
|
|
||||||
tagsText = `<div style="margin-top: 6px;">${badges}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
|
|
||||||
const titleArea = document.createElement('div');
|
const titleArea = document.createElement('div');
|
||||||
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
|
titleArea.style.cssText = "flex: 1; display: flex; flex-direction: column; margin-bottom: 10px;";
|
||||||
titleArea.innerHTML = `
|
titleArea.innerHTML = `
|
||||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
||||||
<strong>${book.fileName}</strong> ${progressText}
|
<strong>${book.fileName}</strong> ${progressText}
|
||||||
</div>
|
</div>
|
||||||
${bookStatsText} ${tagsText}
|
${bookStatsText}
|
||||||
|
${tagsText}
|
||||||
${lastReadText}
|
${lastReadText}
|
||||||
${memoText}
|
${memoText}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 3. 버튼들을 담을 컨테이너
|
// 3. 하단 버튼 영역
|
||||||
const btnArea = document.createElement('div');
|
const btnArea = document.createElement('div');
|
||||||
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0;";
|
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0; margin-top: auto;";
|
||||||
|
|
||||||
const isFav = savedData.isFavorite || false;
|
const isFav = savedData.isFavorite || false;
|
||||||
const favBtn = document.createElement('button');
|
const favBtn = document.createElement('button');
|
||||||
@@ -761,117 +776,106 @@
|
|||||||
// [5] 인코딩 감지 및 파일 분석 (+ ZIP 파일 병합 기능 추가)
|
// [5] 인코딩 감지 및 파일 분석 (+ ZIP 파일 병합 기능 추가)
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
fileInput.addEventListener('change', async (e) => {
|
fileInput.addEventListener('change', async (e) => {
|
||||||
const file = e.target.files[0];
|
const files = e.target.files;
|
||||||
if (!file) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
console.log(`[업로드] 파일 업로드 감지: ${file.name}`);
|
|
||||||
uploadOverlay.style.display = 'none';
|
uploadOverlay.style.display = 'none';
|
||||||
loadingOverlay.style.display = 'flex';
|
loadingOverlay.style.display = 'flex';
|
||||||
|
|
||||||
|
const totalFiles = files.length;
|
||||||
|
const isMultiple = totalFiles > 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 선택된 파일들을 하나씩 순차적으로 처리
|
||||||
|
for (let i = 0; i < totalFiles; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
// 첫 번째 파일은 현재 URL의 readerId를 쓰고, 나머지는 새로 발급
|
||||||
|
const currentId = (i === 0 && readerId) ? readerId : 'book_' + Date.now() + '_' + i;
|
||||||
|
const pKey = `webReader_progress_${currentId}`;
|
||||||
|
|
||||||
|
loadingText.innerText = isMultiple
|
||||||
|
? `[${i + 1} / ${totalFiles}] '${file.name}' 분석 및 저장 중...`
|
||||||
|
: "문서를 분석하고 있습니다...";
|
||||||
|
|
||||||
|
// UI 텍스트 갱신을 위해 0.1초 대기 (브라우저 멈춤 방지)
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
if (file.name.toLowerCase().endsWith('.epub')) {
|
if (file.name.toLowerCase().endsWith('.epub')) {
|
||||||
// =============== EPUB 파일 처리 ===============
|
// =============== EPUB 파일 처리 ===============
|
||||||
console.log(`[파싱] EPUB 파일 파싱 시작`);
|
console.log(`[파싱] EPUB 파싱 시작: ${file.name}`);
|
||||||
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
|
||||||
loadingTimer1 = setTimeout(async () => {
|
|
||||||
await parseEpub(file);
|
await parseEpub(file);
|
||||||
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' '); // 초반 챕터 5개만 합쳐서 검사
|
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' ');
|
||||||
const extractedTags = extractTags(epubFullText);
|
const extractedTags = extractTags(epubFullText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList, currentId);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||||
|
|
||||||
buildTocUI();
|
|
||||||
loadChapter(0, 0);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
} else if (file.name.toLowerCase().endsWith('.zip')) {
|
} else if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
// =============== ZIP 파일 처리 (TXT 병합) ===============
|
// =============== ZIP 파일 처리 ===============
|
||||||
console.log(`[파싱] ZIP 파일 파싱 시작`);
|
console.log(`[파싱] ZIP 파싱 시작: ${file.name}`);
|
||||||
loadingText.innerText = "ZIP 압축 해제 및 텍스트 병합 중...";
|
|
||||||
|
|
||||||
loadingTimer1 = setTimeout(async () => {
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const loadedZip = await zip.loadAsync(file);
|
const loadedZip = await zip.loadAsync(file);
|
||||||
|
|
||||||
// ZIP 내부의 .txt 파일만 찾아서 이름순(자연정렬)으로 정렬
|
|
||||||
// 자연정렬(numeric:true)을 통해 1.txt, 2.txt, 10.txt 순서가 꼬이지 않게 함
|
|
||||||
const txtFiles = Object.keys(loadedZip.files)
|
const txtFiles = Object.keys(loadedZip.files)
|
||||||
.filter(fileName => fileName.toLowerCase().endsWith('.txt') && !loadedZip.files[fileName].dir)
|
.filter(fName => fName.toLowerCase().endsWith('.txt') && !loadedZip.files[fName].dir)
|
||||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
|
|
||||||
if (txtFiles.length === 0) {
|
if (txtFiles.length > 0) {
|
||||||
alert("ZIP 파일 안에 TXT 파일이 없습니다.");
|
|
||||||
uploadOverlay.style.display = 'flex';
|
|
||||||
loadingOverlay.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let combinedText = "";
|
let combinedText = "";
|
||||||
|
|
||||||
// 각 파일을 순서대로 읽으며 인코딩 감지 후 병합
|
|
||||||
for (const fileName of txtFiles) {
|
for (const fileName of txtFiles) {
|
||||||
const fileData = await loadedZip.file(fileName).async("uint8array");
|
const fileData = await loadedZip.file(fileName).async("uint8array");
|
||||||
|
|
||||||
// UTF-8과 CP949(EUC-KR) 중 한글이 더 많이 깨지지 않는 쪽 선택
|
|
||||||
const decoderUtf8 = new TextDecoder('utf-8');
|
const decoderUtf8 = new TextDecoder('utf-8');
|
||||||
const decoderCp949 = new TextDecoder('euc-kr');
|
const decoderCp949 = new TextDecoder('euc-kr');
|
||||||
|
|
||||||
const sampleSize = Math.min(fileData.byteLength, 100000);
|
const sampleSize = Math.min(fileData.byteLength, 100000);
|
||||||
const sampleBuffer = fileData.slice(0, sampleSize);
|
const sampleBuffer = fileData.slice(0, sampleSize);
|
||||||
|
|
||||||
const sampleUtf8 = decoderUtf8.decode(sampleBuffer);
|
const countUtf8 = (decoderUtf8.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||||
const sampleCp949 = decoderCp949.decode(sampleBuffer);
|
const countCp949 = (decoderCp949.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||||
|
|
||||||
const countUtf8 = (sampleUtf8.match(/[가-힣]/g) || []).length;
|
combinedText += `\n\n\n${countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData)}`;
|
||||||
const countCp949 = (sampleCp949.match(/[가-힣]/g) || []).length;
|
|
||||||
|
|
||||||
const decodedText = countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData);
|
|
||||||
|
|
||||||
// 파일과 파일 사이에 줄바꿈 추가하여 병합
|
|
||||||
combinedText += `\n\n\n${decodedText}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
|
combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||||
combinedText = fixForcedLineBreaks(combinedText);
|
combinedText = fixForcedLineBreaks(combinedText);
|
||||||
loadingText.innerText = "통합된 챕터 분석 및 저장 중...";
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
|
||||||
parseChapters(combinedText);
|
parseChapters(combinedText);
|
||||||
const extractedTags = extractTags(combinedText);
|
const extractedTags = extractTags(combinedText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList, currentId);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||||
|
}
|
||||||
buildTocUI();
|
|
||||||
loadChapter(0, 0);
|
|
||||||
}, 100);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// =============== TXT 파일 처리 ===============
|
// =============== TXT 파일 처리 ===============
|
||||||
console.log(`[파싱] TXT 파일 인코딩 분석 시작`);
|
console.log(`[파싱] TXT 파싱 시작: ${file.name}`);
|
||||||
loadingText.innerText = "최적 인코딩 판독 중...";
|
|
||||||
let fullText = await readTextSafely(file);
|
let fullText = await readTextSafely(file);
|
||||||
fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
|
fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||||
|
|
||||||
loadingText.innerText = "챕터 분석 및 저장 중...";
|
|
||||||
loadingTimer1 = setTimeout(async () => {
|
|
||||||
console.log(`[파싱] TXT 챕터 분석 시작`);
|
|
||||||
parseChapters(fullText);
|
parseChapters(fullText);
|
||||||
const extractedTags = extractTags(fullText);
|
|
||||||
await saveBookToDB(file.name, chapterList);
|
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
|
||||||
|
|
||||||
|
const extractedTags = extractTags(fullText);
|
||||||
|
await saveBookToDB(file.name, chapterList, currentId);
|
||||||
|
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||||
|
}
|
||||||
|
} // for문 끝
|
||||||
|
|
||||||
|
// 모든 처리 완료 후 동작 분기
|
||||||
|
if (isMultiple) {
|
||||||
|
// 여러 개를 올렸을 경우, 서재(메인)로 이동하여 추가된 목록 확인
|
||||||
|
alert(`총 ${totalFiles}권의 책이 서재에 일괄 추가되었습니다!`);
|
||||||
|
location.href = location.pathname;
|
||||||
|
} else {
|
||||||
|
// 단 1개만 올렸을 경우 기존처럼 즉시 뷰어 모드로 진입
|
||||||
buildTocUI();
|
buildTocUI();
|
||||||
loadChapter(0, 0);
|
loadChapter(0, 0);
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[오류] 파일 읽기 중 에러 발생:", err);
|
console.error("[오류] 파일 다중 읽기 중 에러 발생:", err);
|
||||||
alert("파일을 읽는 중 오류가 발생했습니다.");
|
alert("파일을 처리하는 중 오류가 발생했습니다.");
|
||||||
uploadOverlay.style.display = 'flex';
|
uploadOverlay.style.display = 'flex';
|
||||||
loadingOverlay.style.display = 'none';
|
loadingOverlay.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// input 초기화 (같은 파일 다시 올릴 수 있게)
|
||||||
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1266,12 +1270,11 @@
|
|||||||
// 현재 챕터 본문 가져오기
|
// 현재 챕터 본문 가져오기
|
||||||
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
|
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
|
||||||
|
|
||||||
// 현재 챕터 본문에서 새로 발견된 태그 추출
|
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
||||||
const newChapterTags = extractTags(currentChapContent);
|
const newChapterTags = extractTags(currentChapContent);
|
||||||
|
|
||||||
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
// ⭐ mergeTags 함수를 사용하여 기존 태그와 안전하게 병합
|
||||||
const existingTags = existingData.tags || [];
|
const mergedTags = mergeTags(existingData.tags, newChapterTags);
|
||||||
const mergedTags = Array.from(new Set([...existingTags, ...newChapterTags]));
|
|
||||||
|
|
||||||
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
|
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
||||||
@@ -1280,7 +1283,7 @@
|
|||||||
lastRead: timeString, // 갱신된 시간
|
lastRead: timeString, // 갱신된 시간
|
||||||
memo: existingData.memo || '', // 기존 메모 유지
|
memo: existingData.memo || '', // 기존 메모 유지
|
||||||
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
|
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
|
||||||
tags: mergedTags // ⭐ 자동 갱신 및 누적된 태그 저장
|
tags: mergedTags // ⭐ 매칭 키워드가 포함된 태그 데이터 저장
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length}개)`);
|
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length}개)`);
|
||||||
@@ -1485,7 +1488,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 포함되어 있으면 태그 배열을 반환합니다.
|
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 2개 이상 포함되어 있으면 태그 배열을 반환합니다.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @description 텍스트 본문을 스캔하여 키워드가 2개 이상 포함되어 있으면 태그명과 매칭된 키워드 목록을 객체로 반환합니다.
|
||||||
*/
|
*/
|
||||||
function extractTags(text) {
|
function extractTags(text) {
|
||||||
const tags = [];
|
const tags = [];
|
||||||
@@ -1495,12 +1501,58 @@
|
|||||||
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
|
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
|
||||||
|
|
||||||
for (const [tag, keywords] of Object.entries(keywordRules)) {
|
for (const [tag, keywords] of Object.entries(keywordRules)) {
|
||||||
if (Array.isArray(keywords) && keywords.some(kw => kw.trim() && sampleText.includes(kw.trim()))) {
|
if (Array.isArray(keywords)) {
|
||||||
tags.push(tag);
|
const validKeywords = keywords.filter(kw => kw.trim() !== "");
|
||||||
|
const matched = validKeywords.filter(kw => sampleText.includes(kw.trim()));
|
||||||
|
const threshold = validKeywords.length === 1 ? 1 : 2;
|
||||||
|
|
||||||
|
if (matched.length >= threshold) {
|
||||||
|
tags.push({
|
||||||
|
name: tag,
|
||||||
|
matched: matched // 매칭된 키워드 배열 담기
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 기존 태그와 새로 추출된 태그를 병합합니다 (이전 데이터 호환 및 키워드 누적).
|
||||||
|
*/
|
||||||
|
function mergeTags(existingTags, newTags) {
|
||||||
|
const tagMap = new Map();
|
||||||
|
|
||||||
|
// 1. 기존 태그 불러오기 (하위 호환성 처리)
|
||||||
|
if (Array.isArray(existingTags)) {
|
||||||
|
existingTags.forEach(t => {
|
||||||
|
if (typeof t === 'string') {
|
||||||
|
tagMap.set(t, new Set());
|
||||||
|
} else if (t && t.name) {
|
||||||
|
tagMap.set(t.name, new Set(t.matched || []));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 새로운 태그 병합
|
||||||
|
if (Array.isArray(newTags)) {
|
||||||
|
newTags.forEach(t => {
|
||||||
|
if (t && t.name) {
|
||||||
|
if (!tagMap.has(t.name)) {
|
||||||
|
tagMap.set(t.name, new Set());
|
||||||
|
}
|
||||||
|
(t.matched || []).forEach(kw => tagMap.get(t.name).add(kw));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Map -> Array 변환
|
||||||
|
return Array.from(tagMap.entries()).map(([name, matchedSet]) => ({
|
||||||
|
name: name,
|
||||||
|
matched: Array.from(matchedSet)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const keywordModal = document.getElementById('keyword-modal');
|
const keywordModal = document.getElementById('keyword-modal');
|
||||||
const keywordManageBtn = document.getElementById('btn-manage-keywords');
|
const keywordManageBtn = document.getElementById('btn-manage-keywords');
|
||||||
const keywordCloseBtn = document.getElementById('keyword-close-btn');
|
const keywordCloseBtn = document.getElementById('keyword-close-btn');
|
||||||
@@ -1580,6 +1632,60 @@
|
|||||||
alert("태그 및 키워드 설정이 저장되었습니다.");
|
alert("태그 및 키워드 설정이 저장되었습니다.");
|
||||||
keywordModal.style.display = 'none';
|
keywordModal.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const btnReapplyTags = document.getElementById('btn-reapply-tags');
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// ⭐ 전체 서재 태그 싹 다시 스캔하기 로직
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
btnReapplyTags.addEventListener('click', async () => {
|
||||||
|
if (!confirm("서재에 저장된 '모든 책'의 본문을 다시 분석하여 태그를 전면 교체하시겠습니까?\n(기존에 부여된 태그는 지워지고 현재 설정된 규칙으로 덮어씌워집니다. 책이 많으면 시간이 조금 걸립니다.)")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 닫고 로딩 화면 띄우기
|
||||||
|
keywordModal.style.display = 'none';
|
||||||
|
loadingText.innerText = "전체 서재 태그 재분석 중... 잠시만 기다려주세요.";
|
||||||
|
loadingOverlay.style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// DB에서 모든 책 데이터 가져오기
|
||||||
|
const allBooks = await getAllBooksFromDB();
|
||||||
|
|
||||||
|
for (const book of allBooks) {
|
||||||
|
let combinedText = '';
|
||||||
|
|
||||||
|
// 책의 초반 챕터들 텍스트를 모음 (약 10만자까지만)
|
||||||
|
if (book.chapters && book.chapters.length > 0) {
|
||||||
|
for (const chap of book.chapters) {
|
||||||
|
combinedText += (chap.content || '') + ' ';
|
||||||
|
if (combinedText.length > 100000) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트를 바탕으로 새로운 규칙에 맞게 태그 재추출
|
||||||
|
const newTags = extractTags(combinedText);
|
||||||
|
|
||||||
|
// 로컬 스토리지에 저장된 해당 책의 진행도 데이터 불러오기
|
||||||
|
const pKey = `webReader_progress_${book.id}`;
|
||||||
|
const savedData = JSON.parse(localStorage.getItem(pKey)) || { chapter: 0, page: 0 };
|
||||||
|
|
||||||
|
// 기존 태그를 싹 비우고 새 태그로 교체
|
||||||
|
savedData.tags = newTags;
|
||||||
|
localStorage.setItem(pKey, JSON.stringify(savedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingOverlay.style.display = 'none';
|
||||||
|
alert(`총 ${allBooks.length}권의 책에 대해 태그 재분석 및 적용이 완료되었습니다!`);
|
||||||
|
location.reload(); // 변경된 태그를 리스트에 반영하기 위해 새로고침
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[오류] 전체 태그 재적용 실패:", error);
|
||||||
|
alert("태그 재적용 중 오류가 발생했습니다.");
|
||||||
|
loadingOverlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+204
-98
@@ -205,7 +205,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 140px; /* 카드 최소 높이 (6개가 한 화면에 쏙 들어가도록 컴팩트하게 설정) */
|
min-height: 160px; /* 140px -> 160px로 여유 제공 */
|
||||||
|
height: auto; /* 내용이 많아지면 자동으로 카드 높이 늘어남 */
|
||||||
transition: transform 0.15s, background 0.15s;
|
transition: transform 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +274,7 @@
|
|||||||
<div id="upload-overlay" class="overlay" style="display: none;">
|
<div id="upload-overlay" class="overlay" style="display: none;">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<h2>텍스트 파일 열기</h2>
|
<h2>텍스트 파일 열기</h2>
|
||||||
<input type="file" id="file-input" accept=".txt, .epub, .zip">
|
<input type="file" id="file-input" accept=".txt, .epub, .zip" multiple>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -327,6 +328,8 @@
|
|||||||
<button id="btn-reset-keyword-rules" style="padding: 10px; background: #7f8c8d; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">초기화</button>
|
<button id="btn-reset-keyword-rules" style="padding: 10px; background: #7f8c8d; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">초기화</button>
|
||||||
<button id="btn-save-keyword-rules" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">저장하기</button>
|
<button id="btn-save-keyword-rules" style="flex: 1; padding: 10px; background: #27ae60; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">저장하기</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-reapply-tags" style="margin-top: 8px; width: 100%; padding: 10px; background: #8e44ad; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px;">🔄 전체 서재 태그 싹 다시 스캔하기</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -471,14 +474,15 @@
|
|||||||
/**
|
/**
|
||||||
* @description 현재 파라미터(readerId)를 키로 사용하여 텍스트 데이터를 저장합니다.
|
* @description 현재 파라미터(readerId)를 키로 사용하여 텍스트 데이터를 저장합니다.
|
||||||
*/
|
*/
|
||||||
async function saveBookToDB(fileName, chapters) {
|
async function saveBookToDB(fileName, chapters, customId = readerId) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!readerId) return reject("저장할 ID가 없습니다.");
|
if (!customId) return reject("저장할 ID가 없습니다.");
|
||||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||||
const store = tx.objectStore(STORE_NAME);
|
const store = tx.objectStore(STORE_NAME);
|
||||||
store.put({ id: readerId, fileName: fileName, chapters: chapters });
|
// 전달받은 customId로 저장하도록 수정
|
||||||
|
store.put({ id: customId, fileName: fileName, chapters: chapters });
|
||||||
tx.oncomplete = () => {
|
tx.oncomplete = () => {
|
||||||
console.log(`[DB] 책 데이터 저장 완료 (ID: ${readerId})`);
|
console.log(`[DB] 책 데이터 저장 완료 (ID: ${customId})`);
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
tx.onerror = (e) => reject(e.target.error);
|
tx.onerror = (e) => reject(e.target.error);
|
||||||
@@ -565,49 +569,60 @@
|
|||||||
const pKey = `webReader_progress_${book.id}`;
|
const pKey = `webReader_progress_${book.id}`;
|
||||||
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
||||||
|
|
||||||
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
|
|
||||||
const totalChapters = book.chapters ? book.chapters.length : 0;
|
const totalChapters = book.chapters ? book.chapters.length : 0;
|
||||||
const totalChars = book.chapters ? book.chapters.reduce((sum, chap) => sum + (chap.content ? chap.content.length : 0), 0) : 0;
|
const totalChars = book.chapters ? book.chapters.reduce((sum, chap) => sum + (chap.content ? chap.content.length : 0), 0) : 0;
|
||||||
const formattedChars = totalChars.toLocaleString(); // 천 단위 쉼표 추가 (예: 125,430자)
|
const formattedChars = totalChars.toLocaleString();
|
||||||
|
|
||||||
|
let progressText = savedData.chapter !== undefined
|
||||||
|
? `<span style="font-size:12px; color:#888; margin-left: 6px;">(Ch.${savedData.chapter + 1})</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
let bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
|
let bookStatsText = `<div style="font-size:11px; color:#aaa; margin-top: 4px;">📚 총 ${totalChapters}화 · ${formattedChars}자</div>`;
|
||||||
|
|
||||||
let progressText = savedData.chapter !== undefined
|
// ⭐ [수정] flex-wrap과 gap을 적용하여 태그가 많아져도 겹치지 않도록 수정
|
||||||
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
let tagsText = '';
|
||||||
|
if (savedData.tags && savedData.tags.length > 0) {
|
||||||
|
const badges = savedData.tags.map(tagObj => {
|
||||||
|
// 기존 형태(단순 문자열) 태그 예외 처리
|
||||||
|
if (typeof tagObj === 'string') {
|
||||||
|
return `<span style="display:inline-block; padding: 2px 6px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tagObj}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 매칭된 키워드 텍스트 조립 (예: "(마법, 드래곤)")
|
||||||
|
const matchedStr = (tagObj.matched && tagObj.matched.length > 0)
|
||||||
|
? `<span style="font-size: 10px; font-weight: normal; opacity: 0.85; margin-left: 3px;">(${tagObj.matched.join(', ')})</span>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
return `<span style="display:inline-block; padding: 3px 7px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tagObj.name}${matchedStr}</span>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
tagsText = `<div style="margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px;">${badges}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
let lastReadText = savedData.lastRead
|
let lastReadText = savedData.lastRead
|
||||||
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
|
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
let memoText = savedData.memo
|
let memoText = savedData.memo
|
||||||
? `<div style="font-size:12px; color:#f39c12; margin-top: 4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
|
? `<div style="font-size:12px; color:#f39c12; margin-top: 6px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;">📝 ${savedData.memo}</div>`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// 태그 HTML 생성
|
// 2. [수정] titleArea 레이아웃 정돈
|
||||||
let tagsText = '';
|
|
||||||
if (savedData.tags && savedData.tags.length > 0) {
|
|
||||||
const badges = savedData.tags.map(tag =>
|
|
||||||
`<span style="display:inline-block; padding: 3px 6px; margin-right: 5px; background: rgba(0, 168, 255, 0.2); color: var(--accent-color); border-radius: 4px; font-size: 11px; font-weight: bold;">${tag}</span>`
|
|
||||||
).join('');
|
|
||||||
tagsText = `<div style="margin-top: 6px;">${badges}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
|
|
||||||
const titleArea = document.createElement('div');
|
const titleArea = document.createElement('div');
|
||||||
titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
|
titleArea.style.cssText = "flex: 1; display: flex; flex-direction: column; margin-bottom: 10px;";
|
||||||
titleArea.innerHTML = `
|
titleArea.innerHTML = `
|
||||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
||||||
<strong>${book.fileName}</strong> ${progressText}
|
<strong>${book.fileName}</strong> ${progressText}
|
||||||
</div>
|
</div>
|
||||||
${bookStatsText} ${tagsText}
|
${bookStatsText}
|
||||||
|
${tagsText}
|
||||||
${lastReadText}
|
${lastReadText}
|
||||||
${memoText}
|
${memoText}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 3. 버튼들을 담을 컨테이너
|
// 3. 하단 버튼 영역
|
||||||
const btnArea = document.createElement('div');
|
const btnArea = document.createElement('div');
|
||||||
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0;";
|
btnArea.style.cssText = "display: flex; gap: 8px; flex-shrink: 0; margin-top: auto;";
|
||||||
|
|
||||||
const isFav = savedData.isFavorite || false;
|
const isFav = savedData.isFavorite || false;
|
||||||
const favBtn = document.createElement('button');
|
const favBtn = document.createElement('button');
|
||||||
@@ -761,117 +776,106 @@
|
|||||||
// [5] 인코딩 감지 및 파일 분석 (+ ZIP 파일 병합 기능 추가)
|
// [5] 인코딩 감지 및 파일 분석 (+ ZIP 파일 병합 기능 추가)
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
fileInput.addEventListener('change', async (e) => {
|
fileInput.addEventListener('change', async (e) => {
|
||||||
const file = e.target.files[0];
|
const files = e.target.files;
|
||||||
if (!file) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
console.log(`[업로드] 파일 업로드 감지: ${file.name}`);
|
|
||||||
uploadOverlay.style.display = 'none';
|
uploadOverlay.style.display = 'none';
|
||||||
loadingOverlay.style.display = 'flex';
|
loadingOverlay.style.display = 'flex';
|
||||||
|
|
||||||
|
const totalFiles = files.length;
|
||||||
|
const isMultiple = totalFiles > 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 선택된 파일들을 하나씩 순차적으로 처리
|
||||||
|
for (let i = 0; i < totalFiles; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
// 첫 번째 파일은 현재 URL의 readerId를 쓰고, 나머지는 새로 발급
|
||||||
|
const currentId = (i === 0 && readerId) ? readerId : 'book_' + Date.now() + '_' + i;
|
||||||
|
const pKey = `webReader_progress_${currentId}`;
|
||||||
|
|
||||||
|
loadingText.innerText = isMultiple
|
||||||
|
? `[${i + 1} / ${totalFiles}] '${file.name}' 분석 및 저장 중...`
|
||||||
|
: "문서를 분석하고 있습니다...";
|
||||||
|
|
||||||
|
// UI 텍스트 갱신을 위해 0.1초 대기 (브라우저 멈춤 방지)
|
||||||
|
await new Promise(r => setTimeout(r, 100));
|
||||||
|
|
||||||
if (file.name.toLowerCase().endsWith('.epub')) {
|
if (file.name.toLowerCase().endsWith('.epub')) {
|
||||||
// =============== EPUB 파일 처리 ===============
|
// =============== EPUB 파일 처리 ===============
|
||||||
console.log(`[파싱] EPUB 파일 파싱 시작`);
|
console.log(`[파싱] EPUB 파싱 시작: ${file.name}`);
|
||||||
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
|
||||||
loadingTimer1 = setTimeout(async () => {
|
|
||||||
await parseEpub(file);
|
await parseEpub(file);
|
||||||
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' '); // 초반 챕터 5개만 합쳐서 검사
|
const epubFullText = chapterList.slice(0, 5).map(c => c.content).join(' ');
|
||||||
const extractedTags = extractTags(epubFullText);
|
const extractedTags = extractTags(epubFullText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList, currentId);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||||
|
|
||||||
buildTocUI();
|
|
||||||
loadChapter(0, 0);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
} else if (file.name.toLowerCase().endsWith('.zip')) {
|
} else if (file.name.toLowerCase().endsWith('.zip')) {
|
||||||
// =============== ZIP 파일 처리 (TXT 병합) ===============
|
// =============== ZIP 파일 처리 ===============
|
||||||
console.log(`[파싱] ZIP 파일 파싱 시작`);
|
console.log(`[파싱] ZIP 파싱 시작: ${file.name}`);
|
||||||
loadingText.innerText = "ZIP 압축 해제 및 텍스트 병합 중...";
|
|
||||||
|
|
||||||
loadingTimer1 = setTimeout(async () => {
|
|
||||||
const zip = new JSZip();
|
const zip = new JSZip();
|
||||||
const loadedZip = await zip.loadAsync(file);
|
const loadedZip = await zip.loadAsync(file);
|
||||||
|
|
||||||
// ZIP 내부의 .txt 파일만 찾아서 이름순(자연정렬)으로 정렬
|
|
||||||
// 자연정렬(numeric:true)을 통해 1.txt, 2.txt, 10.txt 순서가 꼬이지 않게 함
|
|
||||||
const txtFiles = Object.keys(loadedZip.files)
|
const txtFiles = Object.keys(loadedZip.files)
|
||||||
.filter(fileName => fileName.toLowerCase().endsWith('.txt') && !loadedZip.files[fileName].dir)
|
.filter(fName => fName.toLowerCase().endsWith('.txt') && !loadedZip.files[fName].dir)
|
||||||
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
|
||||||
|
|
||||||
if (txtFiles.length === 0) {
|
if (txtFiles.length > 0) {
|
||||||
alert("ZIP 파일 안에 TXT 파일이 없습니다.");
|
|
||||||
uploadOverlay.style.display = 'flex';
|
|
||||||
loadingOverlay.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let combinedText = "";
|
let combinedText = "";
|
||||||
|
|
||||||
// 각 파일을 순서대로 읽으며 인코딩 감지 후 병합
|
|
||||||
for (const fileName of txtFiles) {
|
for (const fileName of txtFiles) {
|
||||||
const fileData = await loadedZip.file(fileName).async("uint8array");
|
const fileData = await loadedZip.file(fileName).async("uint8array");
|
||||||
|
|
||||||
// UTF-8과 CP949(EUC-KR) 중 한글이 더 많이 깨지지 않는 쪽 선택
|
|
||||||
const decoderUtf8 = new TextDecoder('utf-8');
|
const decoderUtf8 = new TextDecoder('utf-8');
|
||||||
const decoderCp949 = new TextDecoder('euc-kr');
|
const decoderCp949 = new TextDecoder('euc-kr');
|
||||||
|
|
||||||
const sampleSize = Math.min(fileData.byteLength, 100000);
|
const sampleSize = Math.min(fileData.byteLength, 100000);
|
||||||
const sampleBuffer = fileData.slice(0, sampleSize);
|
const sampleBuffer = fileData.slice(0, sampleSize);
|
||||||
|
|
||||||
const sampleUtf8 = decoderUtf8.decode(sampleBuffer);
|
const countUtf8 = (decoderUtf8.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||||
const sampleCp949 = decoderCp949.decode(sampleBuffer);
|
const countCp949 = (decoderCp949.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||||
|
|
||||||
const countUtf8 = (sampleUtf8.match(/[가-힣]/g) || []).length;
|
combinedText += `\n\n\n${countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData)}`;
|
||||||
const countCp949 = (sampleCp949.match(/[가-힣]/g) || []).length;
|
|
||||||
|
|
||||||
const decodedText = countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData);
|
|
||||||
|
|
||||||
// 파일과 파일 사이에 줄바꿈 추가하여 병합
|
|
||||||
combinedText += `\n\n\n${decodedText}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
|
combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||||
combinedText = fixForcedLineBreaks(combinedText);
|
combinedText = fixForcedLineBreaks(combinedText);
|
||||||
loadingText.innerText = "통합된 챕터 분석 및 저장 중...";
|
|
||||||
|
|
||||||
setTimeout(async () => {
|
|
||||||
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
|
||||||
parseChapters(combinedText);
|
parseChapters(combinedText);
|
||||||
const extractedTags = extractTags(combinedText);
|
const extractedTags = extractTags(combinedText);
|
||||||
await saveBookToDB(file.name, chapterList);
|
await saveBookToDB(file.name, chapterList, currentId);
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||||
|
}
|
||||||
buildTocUI();
|
|
||||||
loadChapter(0, 0);
|
|
||||||
}, 100);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// =============== TXT 파일 처리 ===============
|
// =============== TXT 파일 처리 ===============
|
||||||
console.log(`[파싱] TXT 파일 인코딩 분석 시작`);
|
console.log(`[파싱] TXT 파싱 시작: ${file.name}`);
|
||||||
loadingText.innerText = "최적 인코딩 판독 중...";
|
|
||||||
let fullText = await readTextSafely(file);
|
let fullText = await readTextSafely(file);
|
||||||
fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
|
fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||||
|
|
||||||
loadingText.innerText = "챕터 분석 및 저장 중...";
|
|
||||||
loadingTimer1 = setTimeout(async () => {
|
|
||||||
console.log(`[파싱] TXT 챕터 분석 시작`);
|
|
||||||
parseChapters(fullText);
|
parseChapters(fullText);
|
||||||
const extractedTags = extractTags(fullText);
|
|
||||||
await saveBookToDB(file.name, chapterList);
|
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
|
||||||
|
|
||||||
|
const extractedTags = extractTags(fullText);
|
||||||
|
await saveBookToDB(file.name, chapterList, currentId);
|
||||||
|
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||||
|
}
|
||||||
|
} // for문 끝
|
||||||
|
|
||||||
|
// 모든 처리 완료 후 동작 분기
|
||||||
|
if (isMultiple) {
|
||||||
|
// 여러 개를 올렸을 경우, 서재(메인)로 이동하여 추가된 목록 확인
|
||||||
|
alert(`총 ${totalFiles}권의 책이 서재에 일괄 추가되었습니다!`);
|
||||||
|
location.href = location.pathname;
|
||||||
|
} else {
|
||||||
|
// 단 1개만 올렸을 경우 기존처럼 즉시 뷰어 모드로 진입
|
||||||
buildTocUI();
|
buildTocUI();
|
||||||
loadChapter(0, 0);
|
loadChapter(0, 0);
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[오류] 파일 읽기 중 에러 발생:", err);
|
console.error("[오류] 파일 다중 읽기 중 에러 발생:", err);
|
||||||
alert("파일을 읽는 중 오류가 발생했습니다.");
|
alert("파일을 처리하는 중 오류가 발생했습니다.");
|
||||||
uploadOverlay.style.display = 'flex';
|
uploadOverlay.style.display = 'flex';
|
||||||
loadingOverlay.style.display = 'none';
|
loadingOverlay.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// input 초기화 (같은 파일 다시 올릴 수 있게)
|
||||||
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1266,12 +1270,11 @@
|
|||||||
// 현재 챕터 본문 가져오기
|
// 현재 챕터 본문 가져오기
|
||||||
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
|
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
|
||||||
|
|
||||||
// 현재 챕터 본문에서 새로 발견된 태그 추출
|
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
||||||
const newChapterTags = extractTags(currentChapContent);
|
const newChapterTags = extractTags(currentChapContent);
|
||||||
|
|
||||||
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
// ⭐ mergeTags 함수를 사용하여 기존 태그와 안전하게 병합
|
||||||
const existingTags = existingData.tags || [];
|
const mergedTags = mergeTags(existingData.tags, newChapterTags);
|
||||||
const mergedTags = Array.from(new Set([...existingTags, ...newChapterTags]));
|
|
||||||
|
|
||||||
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
|
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
|
||||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
||||||
@@ -1280,7 +1283,7 @@
|
|||||||
lastRead: timeString, // 갱신된 시간
|
lastRead: timeString, // 갱신된 시간
|
||||||
memo: existingData.memo || '', // 기존 메모 유지
|
memo: existingData.memo || '', // 기존 메모 유지
|
||||||
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
|
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
|
||||||
tags: mergedTags // ⭐ 자동 갱신 및 누적된 태그 저장
|
tags: mergedTags // ⭐ 매칭 키워드가 포함된 태그 데이터 저장
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length}개)`);
|
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length}개)`);
|
||||||
@@ -1485,7 +1488,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 포함되어 있으면 태그 배열을 반환합니다.
|
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 2개 이상 포함되어 있으면 태그 배열을 반환합니다.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @description 텍스트 본문을 스캔하여 키워드가 2개 이상 포함되어 있으면 태그명과 매칭된 키워드 목록을 객체로 반환합니다.
|
||||||
*/
|
*/
|
||||||
function extractTags(text) {
|
function extractTags(text) {
|
||||||
const tags = [];
|
const tags = [];
|
||||||
@@ -1495,12 +1501,58 @@
|
|||||||
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
|
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
|
||||||
|
|
||||||
for (const [tag, keywords] of Object.entries(keywordRules)) {
|
for (const [tag, keywords] of Object.entries(keywordRules)) {
|
||||||
if (Array.isArray(keywords) && keywords.some(kw => kw.trim() && sampleText.includes(kw.trim()))) {
|
if (Array.isArray(keywords)) {
|
||||||
tags.push(tag);
|
const validKeywords = keywords.filter(kw => kw.trim() !== "");
|
||||||
|
const matched = validKeywords.filter(kw => sampleText.includes(kw.trim()));
|
||||||
|
const threshold = validKeywords.length === 1 ? 1 : 2;
|
||||||
|
|
||||||
|
if (matched.length >= threshold) {
|
||||||
|
tags.push({
|
||||||
|
name: tag,
|
||||||
|
matched: matched // 매칭된 키워드 배열 담기
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 기존 태그와 새로 추출된 태그를 병합합니다 (이전 데이터 호환 및 키워드 누적).
|
||||||
|
*/
|
||||||
|
function mergeTags(existingTags, newTags) {
|
||||||
|
const tagMap = new Map();
|
||||||
|
|
||||||
|
// 1. 기존 태그 불러오기 (하위 호환성 처리)
|
||||||
|
if (Array.isArray(existingTags)) {
|
||||||
|
existingTags.forEach(t => {
|
||||||
|
if (typeof t === 'string') {
|
||||||
|
tagMap.set(t, new Set());
|
||||||
|
} else if (t && t.name) {
|
||||||
|
tagMap.set(t.name, new Set(t.matched || []));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 새로운 태그 병합
|
||||||
|
if (Array.isArray(newTags)) {
|
||||||
|
newTags.forEach(t => {
|
||||||
|
if (t && t.name) {
|
||||||
|
if (!tagMap.has(t.name)) {
|
||||||
|
tagMap.set(t.name, new Set());
|
||||||
|
}
|
||||||
|
(t.matched || []).forEach(kw => tagMap.get(t.name).add(kw));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Map -> Array 변환
|
||||||
|
return Array.from(tagMap.entries()).map(([name, matchedSet]) => ({
|
||||||
|
name: name,
|
||||||
|
matched: Array.from(matchedSet)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const keywordModal = document.getElementById('keyword-modal');
|
const keywordModal = document.getElementById('keyword-modal');
|
||||||
const keywordManageBtn = document.getElementById('btn-manage-keywords');
|
const keywordManageBtn = document.getElementById('btn-manage-keywords');
|
||||||
const keywordCloseBtn = document.getElementById('keyword-close-btn');
|
const keywordCloseBtn = document.getElementById('keyword-close-btn');
|
||||||
@@ -1580,6 +1632,60 @@
|
|||||||
alert("태그 및 키워드 설정이 저장되었습니다.");
|
alert("태그 및 키워드 설정이 저장되었습니다.");
|
||||||
keywordModal.style.display = 'none';
|
keywordModal.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const btnReapplyTags = document.getElementById('btn-reapply-tags');
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// ⭐ 전체 서재 태그 싹 다시 스캔하기 로직
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
btnReapplyTags.addEventListener('click', async () => {
|
||||||
|
if (!confirm("서재에 저장된 '모든 책'의 본문을 다시 분석하여 태그를 전면 교체하시겠습니까?\n(기존에 부여된 태그는 지워지고 현재 설정된 규칙으로 덮어씌워집니다. 책이 많으면 시간이 조금 걸립니다.)")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 닫고 로딩 화면 띄우기
|
||||||
|
keywordModal.style.display = 'none';
|
||||||
|
loadingText.innerText = "전체 서재 태그 재분석 중... 잠시만 기다려주세요.";
|
||||||
|
loadingOverlay.style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// DB에서 모든 책 데이터 가져오기
|
||||||
|
const allBooks = await getAllBooksFromDB();
|
||||||
|
|
||||||
|
for (const book of allBooks) {
|
||||||
|
let combinedText = '';
|
||||||
|
|
||||||
|
// 책의 초반 챕터들 텍스트를 모음 (약 10만자까지만)
|
||||||
|
if (book.chapters && book.chapters.length > 0) {
|
||||||
|
for (const chap of book.chapters) {
|
||||||
|
combinedText += (chap.content || '') + ' ';
|
||||||
|
if (combinedText.length > 100000) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 텍스트를 바탕으로 새로운 규칙에 맞게 태그 재추출
|
||||||
|
const newTags = extractTags(combinedText);
|
||||||
|
|
||||||
|
// 로컬 스토리지에 저장된 해당 책의 진행도 데이터 불러오기
|
||||||
|
const pKey = `webReader_progress_${book.id}`;
|
||||||
|
const savedData = JSON.parse(localStorage.getItem(pKey)) || { chapter: 0, page: 0 };
|
||||||
|
|
||||||
|
// 기존 태그를 싹 비우고 새 태그로 교체
|
||||||
|
savedData.tags = newTags;
|
||||||
|
localStorage.setItem(pKey, JSON.stringify(savedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingOverlay.style.display = 'none';
|
||||||
|
alert(`총 ${allBooks.length}권의 책에 대해 태그 재분석 및 적용이 완료되었습니다!`);
|
||||||
|
location.reload(); // 변경된 태그를 리스트에 반영하기 위해 새로고침
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[오류] 전체 태그 재적용 실패:", error);
|
||||||
|
alert("태그 재적용 중 오류가 발생했습니다.");
|
||||||
|
loadingOverlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,18 +36,33 @@ class WebReaderActivity : AppCompatActivity() {
|
|||||||
private var pendingBackupData: String? = null
|
private var pendingBackupData: String? = null
|
||||||
|
|
||||||
// 1. 파일 열기 (업로드/복원용) 런처
|
// 1. 파일 열기 (업로드/복원용) 런처
|
||||||
|
// 1. 파일 열기 (업로드/복원용) 런처 - 다중 파일 지원으로 수정
|
||||||
private val fileChooserLauncher = registerForActivityResult(
|
private val fileChooserLauncher = registerForActivityResult(
|
||||||
ActivityResultContracts.StartActivityForResult()
|
ActivityResultContracts.StartActivityForResult()
|
||||||
) { result ->
|
) { result ->
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
val data = result.data
|
val data = result.data
|
||||||
val uriList = if (data?.data != null) arrayOf(data.data!!) else null
|
var uriList: Array<Uri>? = null
|
||||||
|
|
||||||
|
// 다중 선택을 했을 경우 (clipData로 데이터가 들어옴)
|
||||||
|
if (data?.clipData != null) {
|
||||||
|
val count = data.clipData!!.itemCount
|
||||||
|
uriList = Array(count) { i ->
|
||||||
|
data.clipData!!.getItemAt(i).uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 단일 선택을 했을 경우 (data로 데이터가 들어옴)
|
||||||
|
else if (data?.data != null) {
|
||||||
|
uriList = arrayOf(data.data!!)
|
||||||
|
}
|
||||||
|
|
||||||
filePathCallback?.onReceiveValue(uriList)
|
filePathCallback?.onReceiveValue(uriList)
|
||||||
} else {
|
} else {
|
||||||
filePathCallback?.onReceiveValue(null)
|
filePathCallback?.onReceiveValue(null)
|
||||||
}
|
}
|
||||||
filePathCallback = null
|
filePathCallback = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isNetworkConnected(): Boolean {
|
private fun isNetworkConnected(): Boolean {
|
||||||
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
val network = connectivityManager.activeNetwork ?: return false
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
@@ -202,6 +217,11 @@ class WebReaderActivity : AppCompatActivity() {
|
|||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
type = "*/*"
|
type = "*/*"
|
||||||
|
|
||||||
|
// ⭐ 추가된 핵심 코드: 웹에서 multiple 속성을 보냈는지 확인 후 안드로이드 파일 탐색기에도 적용
|
||||||
|
if (fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fileChooserLauncher.launch(intent)
|
fileChooserLauncher.launch(intent)
|
||||||
return true
|
return true
|
||||||
|
|||||||
Reference in New Issue
Block a user