..
This commit is contained in:
@@ -205,7 +205,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
min-height: 140px; /* 카드 최소 높이 (6개가 한 화면에 쏙 들어가도록 컴팩트하게 설정) */
|
||||
min-height: 160px; /* 140px -> 160px로 여유 제공 */
|
||||
height: auto; /* 내용이 많아지면 자동으로 카드 높이 늘어남 */
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@
|
||||
<div id="upload-overlay" class="overlay" style="display: none;">
|
||||
<div class="box">
|
||||
<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>
|
||||
|
||||
@@ -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-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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -471,14 +474,15 @@
|
||||
/**
|
||||
* @description 현재 파라미터(readerId)를 키로 사용하여 텍스트 데이터를 저장합니다.
|
||||
*/
|
||||
async function saveBookToDB(fileName, chapters) {
|
||||
async function saveBookToDB(fileName, chapters, customId = readerId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!readerId) return reject("저장할 ID가 없습니다.");
|
||||
if (!customId) return reject("저장할 ID가 없습니다.");
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
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 = () => {
|
||||
console.log(`[DB] 책 데이터 저장 완료 (ID: ${readerId})`);
|
||||
console.log(`[DB] 책 데이터 저장 완료 (ID: ${customId})`);
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = (e) => reject(e.target.error);
|
||||
@@ -565,49 +569,60 @@
|
||||
const pKey = `webReader_progress_${book.id}`;
|
||||
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
||||
|
||||
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
|
||||
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 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 progressText = savedData.chapter !== undefined
|
||||
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
||||
// ⭐ [수정] flex-wrap과 gap을 적용하여 태그가 많아져도 겹치지 않도록 수정
|
||||
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
|
||||
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
|
||||
: '';
|
||||
|
||||
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 생성
|
||||
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. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
|
||||
// 2. [수정] titleArea 레이아웃 정돈
|
||||
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 = `
|
||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
||||
<strong>${book.fileName}</strong> ${progressText}
|
||||
</div>
|
||||
${bookStatsText} ${tagsText}
|
||||
${bookStatsText}
|
||||
${tagsText}
|
||||
${lastReadText}
|
||||
${memoText}
|
||||
`;
|
||||
|
||||
// 3. 버튼들을 담을 컨테이너
|
||||
// 3. 하단 버튼 영역
|
||||
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 favBtn = document.createElement('button');
|
||||
@@ -761,117 +776,106 @@
|
||||
// [5] 인코딩 감지 및 파일 분석 (+ ZIP 파일 병합 기능 추가)
|
||||
// ----------------------------------------------------------------
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
console.log(`[업로드] 파일 업로드 감지: ${file.name}`);
|
||||
uploadOverlay.style.display = 'none';
|
||||
loadingOverlay.style.display = 'flex';
|
||||
|
||||
const totalFiles = files.length;
|
||||
const isMultiple = totalFiles > 1;
|
||||
|
||||
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')) {
|
||||
// =============== EPUB 파일 처리 ===============
|
||||
console.log(`[파싱] EPUB 파일 파싱 시작`);
|
||||
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
||||
loadingTimer1 = setTimeout(async () => {
|
||||
console.log(`[파싱] EPUB 파싱 시작: ${file.name}`);
|
||||
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);
|
||||
await saveBookToDB(file.name, chapterList);
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||
|
||||
buildTocUI();
|
||||
loadChapter(0, 0);
|
||||
}, 100);
|
||||
await saveBookToDB(file.name, chapterList, currentId);
|
||||
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||
|
||||
} else if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
// =============== ZIP 파일 처리 (TXT 병합) ===============
|
||||
console.log(`[파싱] ZIP 파일 파싱 시작`);
|
||||
loadingText.innerText = "ZIP 압축 해제 및 텍스트 병합 중...";
|
||||
|
||||
loadingTimer1 = setTimeout(async () => {
|
||||
// =============== ZIP 파일 처리 ===============
|
||||
console.log(`[파싱] ZIP 파싱 시작: ${file.name}`);
|
||||
const zip = new JSZip();
|
||||
const loadedZip = await zip.loadAsync(file);
|
||||
|
||||
// ZIP 내부의 .txt 파일만 찾아서 이름순(자연정렬)으로 정렬
|
||||
// 자연정렬(numeric:true)을 통해 1.txt, 2.txt, 10.txt 순서가 꼬이지 않게 함
|
||||
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' }));
|
||||
|
||||
if (txtFiles.length === 0) {
|
||||
alert("ZIP 파일 안에 TXT 파일이 없습니다.");
|
||||
uploadOverlay.style.display = 'flex';
|
||||
loadingOverlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (txtFiles.length > 0) {
|
||||
let combinedText = "";
|
||||
|
||||
// 각 파일을 순서대로 읽으며 인코딩 감지 후 병합
|
||||
for (const fileName of txtFiles) {
|
||||
const fileData = await loadedZip.file(fileName).async("uint8array");
|
||||
|
||||
// UTF-8과 CP949(EUC-KR) 중 한글이 더 많이 깨지지 않는 쪽 선택
|
||||
const decoderUtf8 = new TextDecoder('utf-8');
|
||||
const decoderCp949 = new TextDecoder('euc-kr');
|
||||
|
||||
const sampleSize = Math.min(fileData.byteLength, 100000);
|
||||
const sampleBuffer = fileData.slice(0, sampleSize);
|
||||
|
||||
const sampleUtf8 = decoderUtf8.decode(sampleBuffer);
|
||||
const sampleCp949 = decoderCp949.decode(sampleBuffer);
|
||||
const countUtf8 = (decoderUtf8.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||
const countCp949 = (decoderCp949.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||
|
||||
const countUtf8 = (sampleUtf8.match(/[가-힣]/g) || []).length;
|
||||
const countCp949 = (sampleCp949.match(/[가-힣]/g) || []).length;
|
||||
|
||||
const decodedText = countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData);
|
||||
|
||||
// 파일과 파일 사이에 줄바꿈 추가하여 병합
|
||||
combinedText += `\n\n\n${decodedText}`;
|
||||
combinedText += `\n\n\n${countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData)}`;
|
||||
}
|
||||
|
||||
combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||
combinedText = fixForcedLineBreaks(combinedText);
|
||||
loadingText.innerText = "통합된 챕터 분석 및 저장 중...";
|
||||
|
||||
setTimeout(async () => {
|
||||
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
||||
parseChapters(combinedText);
|
||||
const extractedTags = extractTags(combinedText);
|
||||
await saveBookToDB(file.name, chapterList);
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||
|
||||
buildTocUI();
|
||||
loadChapter(0, 0);
|
||||
}, 100);
|
||||
}, 100);
|
||||
await saveBookToDB(file.name, chapterList, currentId);
|
||||
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||
}
|
||||
|
||||
} else {
|
||||
// =============== TXT 파일 처리 ===============
|
||||
console.log(`[파싱] TXT 파일 인코딩 분석 시작`);
|
||||
loadingText.innerText = "최적 인코딩 판독 중...";
|
||||
console.log(`[파싱] TXT 파싱 시작: ${file.name}`);
|
||||
let fullText = await readTextSafely(file);
|
||||
fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||
|
||||
loadingText.innerText = "챕터 분석 및 저장 중...";
|
||||
loadingTimer1 = setTimeout(async () => {
|
||||
console.log(`[파싱] TXT 챕터 분석 시작`);
|
||||
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();
|
||||
loadChapter(0, 0);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[오류] 파일 읽기 중 에러 발생:", err);
|
||||
alert("파일을 읽는 중 오류가 발생했습니다.");
|
||||
console.error("[오류] 파일 다중 읽기 중 에러 발생:", err);
|
||||
alert("파일을 처리하는 중 오류가 발생했습니다.");
|
||||
uploadOverlay.style.display = 'flex';
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// input 초기화 (같은 파일 다시 올릴 수 있게)
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1266,12 +1270,11 @@
|
||||
// 현재 챕터 본문 가져오기
|
||||
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
|
||||
|
||||
// 현재 챕터 본문에서 새로 발견된 태그 추출
|
||||
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
||||
const newChapterTags = extractTags(currentChapContent);
|
||||
|
||||
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
||||
const existingTags = existingData.tags || [];
|
||||
const mergedTags = Array.from(new Set([...existingTags, ...newChapterTags]));
|
||||
// ⭐ mergeTags 함수를 사용하여 기존 태그와 안전하게 병합
|
||||
const mergedTags = mergeTags(existingData.tags, newChapterTags);
|
||||
|
||||
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
||||
@@ -1280,7 +1283,7 @@
|
||||
lastRead: timeString, // 갱신된 시간
|
||||
memo: existingData.memo || '', // 기존 메모 유지
|
||||
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
|
||||
tags: mergedTags // ⭐ 자동 갱신 및 누적된 태그 저장
|
||||
tags: mergedTags // ⭐ 매칭 키워드가 포함된 태그 데이터 저장
|
||||
}));
|
||||
|
||||
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length}개)`);
|
||||
@@ -1485,7 +1488,10 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 포함되어 있으면 태그 배열을 반환합니다.
|
||||
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 2개 이상 포함되어 있으면 태그 배열을 반환합니다.
|
||||
*/
|
||||
/**
|
||||
* @description 텍스트 본문을 스캔하여 키워드가 2개 이상 포함되어 있으면 태그명과 매칭된 키워드 목록을 객체로 반환합니다.
|
||||
*/
|
||||
function extractTags(text) {
|
||||
const tags = [];
|
||||
@@ -1495,12 +1501,58 @@
|
||||
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
|
||||
|
||||
for (const [tag, keywords] of Object.entries(keywordRules)) {
|
||||
if (Array.isArray(keywords) && keywords.some(kw => kw.trim() && sampleText.includes(kw.trim()))) {
|
||||
tags.push(tag);
|
||||
if (Array.isArray(keywords)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 keywordManageBtn = document.getElementById('btn-manage-keywords');
|
||||
const keywordCloseBtn = document.getElementById('keyword-close-btn');
|
||||
@@ -1580,6 +1632,60 @@
|
||||
alert("태그 및 키워드 설정이 저장되었습니다.");
|
||||
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>
|
||||
|
||||
|
||||
|
||||
+204
-98
@@ -205,7 +205,8 @@
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
min-height: 140px; /* 카드 최소 높이 (6개가 한 화면에 쏙 들어가도록 컴팩트하게 설정) */
|
||||
min-height: 160px; /* 140px -> 160px로 여유 제공 */
|
||||
height: auto; /* 내용이 많아지면 자동으로 카드 높이 늘어남 */
|
||||
transition: transform 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@
|
||||
<div id="upload-overlay" class="overlay" style="display: none;">
|
||||
<div class="box">
|
||||
<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>
|
||||
|
||||
@@ -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-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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -471,14 +474,15 @@
|
||||
/**
|
||||
* @description 현재 파라미터(readerId)를 키로 사용하여 텍스트 데이터를 저장합니다.
|
||||
*/
|
||||
async function saveBookToDB(fileName, chapters) {
|
||||
async function saveBookToDB(fileName, chapters, customId = readerId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!readerId) return reject("저장할 ID가 없습니다.");
|
||||
if (!customId) return reject("저장할 ID가 없습니다.");
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
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 = () => {
|
||||
console.log(`[DB] 책 데이터 저장 완료 (ID: ${readerId})`);
|
||||
console.log(`[DB] 책 데이터 저장 완료 (ID: ${customId})`);
|
||||
resolve();
|
||||
};
|
||||
tx.onerror = (e) => reject(e.target.error);
|
||||
@@ -565,49 +569,60 @@
|
||||
const pKey = `webReader_progress_${book.id}`;
|
||||
const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
|
||||
|
||||
// ⭐ [추가] 총 챕터 수 및 총 글자 수 계산
|
||||
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 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 progressText = savedData.chapter !== undefined
|
||||
? `<div style="font-size:12px; color:#888; margin-left: 8px;">(읽는 중: Ch.${savedData.chapter + 1})</div>`
|
||||
// ⭐ [수정] flex-wrap과 gap을 적용하여 태그가 많아져도 겹치지 않도록 수정
|
||||
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
|
||||
? `<div style="font-size:11px; color:#aaa; margin-top: 6px;">🕒 마지막 열람: ${savedData.lastRead}</div>`
|
||||
: '';
|
||||
|
||||
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 생성
|
||||
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. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
|
||||
// 2. [수정] titleArea 레이아웃 정돈
|
||||
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 = `
|
||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow:hidden;">
|
||||
<strong>${book.fileName}</strong> ${progressText}
|
||||
</div>
|
||||
${bookStatsText} ${tagsText}
|
||||
${bookStatsText}
|
||||
${tagsText}
|
||||
${lastReadText}
|
||||
${memoText}
|
||||
`;
|
||||
|
||||
// 3. 버튼들을 담을 컨테이너
|
||||
// 3. 하단 버튼 영역
|
||||
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 favBtn = document.createElement('button');
|
||||
@@ -761,117 +776,106 @@
|
||||
// [5] 인코딩 감지 및 파일 분석 (+ ZIP 파일 병합 기능 추가)
|
||||
// ----------------------------------------------------------------
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
console.log(`[업로드] 파일 업로드 감지: ${file.name}`);
|
||||
uploadOverlay.style.display = 'none';
|
||||
loadingOverlay.style.display = 'flex';
|
||||
|
||||
const totalFiles = files.length;
|
||||
const isMultiple = totalFiles > 1;
|
||||
|
||||
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')) {
|
||||
// =============== EPUB 파일 처리 ===============
|
||||
console.log(`[파싱] EPUB 파일 파싱 시작`);
|
||||
loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
|
||||
loadingTimer1 = setTimeout(async () => {
|
||||
console.log(`[파싱] EPUB 파싱 시작: ${file.name}`);
|
||||
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);
|
||||
await saveBookToDB(file.name, chapterList);
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||
|
||||
buildTocUI();
|
||||
loadChapter(0, 0);
|
||||
}, 100);
|
||||
await saveBookToDB(file.name, chapterList, currentId);
|
||||
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||
|
||||
} else if (file.name.toLowerCase().endsWith('.zip')) {
|
||||
// =============== ZIP 파일 처리 (TXT 병합) ===============
|
||||
console.log(`[파싱] ZIP 파일 파싱 시작`);
|
||||
loadingText.innerText = "ZIP 압축 해제 및 텍스트 병합 중...";
|
||||
|
||||
loadingTimer1 = setTimeout(async () => {
|
||||
// =============== ZIP 파일 처리 ===============
|
||||
console.log(`[파싱] ZIP 파싱 시작: ${file.name}`);
|
||||
const zip = new JSZip();
|
||||
const loadedZip = await zip.loadAsync(file);
|
||||
|
||||
// ZIP 내부의 .txt 파일만 찾아서 이름순(자연정렬)으로 정렬
|
||||
// 자연정렬(numeric:true)을 통해 1.txt, 2.txt, 10.txt 순서가 꼬이지 않게 함
|
||||
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' }));
|
||||
|
||||
if (txtFiles.length === 0) {
|
||||
alert("ZIP 파일 안에 TXT 파일이 없습니다.");
|
||||
uploadOverlay.style.display = 'flex';
|
||||
loadingOverlay.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (txtFiles.length > 0) {
|
||||
let combinedText = "";
|
||||
|
||||
// 각 파일을 순서대로 읽으며 인코딩 감지 후 병합
|
||||
for (const fileName of txtFiles) {
|
||||
const fileData = await loadedZip.file(fileName).async("uint8array");
|
||||
|
||||
// UTF-8과 CP949(EUC-KR) 중 한글이 더 많이 깨지지 않는 쪽 선택
|
||||
const decoderUtf8 = new TextDecoder('utf-8');
|
||||
const decoderCp949 = new TextDecoder('euc-kr');
|
||||
|
||||
const sampleSize = Math.min(fileData.byteLength, 100000);
|
||||
const sampleBuffer = fileData.slice(0, sampleSize);
|
||||
|
||||
const sampleUtf8 = decoderUtf8.decode(sampleBuffer);
|
||||
const sampleCp949 = decoderCp949.decode(sampleBuffer);
|
||||
const countUtf8 = (decoderUtf8.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||
const countCp949 = (decoderCp949.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
|
||||
|
||||
const countUtf8 = (sampleUtf8.match(/[가-힣]/g) || []).length;
|
||||
const countCp949 = (sampleCp949.match(/[가-힣]/g) || []).length;
|
||||
|
||||
const decodedText = countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData);
|
||||
|
||||
// 파일과 파일 사이에 줄바꿈 추가하여 병합
|
||||
combinedText += `\n\n\n${decodedText}`;
|
||||
combinedText += `\n\n\n${countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData)}`;
|
||||
}
|
||||
|
||||
combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||
combinedText = fixForcedLineBreaks(combinedText);
|
||||
loadingText.innerText = "통합된 챕터 분석 및 저장 중...";
|
||||
|
||||
setTimeout(async () => {
|
||||
console.log(`[파싱] 병합된 텍스트 챕터 분석 시작`);
|
||||
parseChapters(combinedText);
|
||||
const extractedTags = extractTags(combinedText);
|
||||
await saveBookToDB(file.name, chapterList);
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
|
||||
|
||||
buildTocUI();
|
||||
loadChapter(0, 0);
|
||||
}, 100);
|
||||
}, 100);
|
||||
await saveBookToDB(file.name, chapterList, currentId);
|
||||
localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
|
||||
}
|
||||
|
||||
} else {
|
||||
// =============== TXT 파일 처리 ===============
|
||||
console.log(`[파싱] TXT 파일 인코딩 분석 시작`);
|
||||
loadingText.innerText = "최적 인코딩 판독 중...";
|
||||
console.log(`[파싱] TXT 파싱 시작: ${file.name}`);
|
||||
let fullText = await readTextSafely(file);
|
||||
fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
|
||||
|
||||
loadingText.innerText = "챕터 분석 및 저장 중...";
|
||||
loadingTimer1 = setTimeout(async () => {
|
||||
console.log(`[파싱] TXT 챕터 분석 시작`);
|
||||
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();
|
||||
loadChapter(0, 0);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("[오류] 파일 읽기 중 에러 발생:", err);
|
||||
alert("파일을 읽는 중 오류가 발생했습니다.");
|
||||
console.error("[오류] 파일 다중 읽기 중 에러 발생:", err);
|
||||
alert("파일을 처리하는 중 오류가 발생했습니다.");
|
||||
uploadOverlay.style.display = 'flex';
|
||||
loadingOverlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// input 초기화 (같은 파일 다시 올릴 수 있게)
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1266,12 +1270,11 @@
|
||||
// 현재 챕터 본문 가져오기
|
||||
const currentChapContent = chapterList[currentChapterIndex] ? chapterList[currentChapterIndex].content : '';
|
||||
|
||||
// 현재 챕터 본문에서 새로 발견된 태그 추출
|
||||
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
||||
const newChapterTags = extractTags(currentChapContent);
|
||||
|
||||
// 기존 태그 배열과 현재 챕터 태그 배열을 합치고 중복 제거 (Set 활용)
|
||||
const existingTags = existingData.tags || [];
|
||||
const mergedTags = Array.from(new Set([...existingTags, ...newChapterTags]));
|
||||
// ⭐ mergeTags 함수를 사용하여 기존 태그와 안전하게 병합
|
||||
const mergedTags = mergeTags(existingData.tags, newChapterTags);
|
||||
|
||||
// 3. 진행도, 시간, 메모, 즐겨찾기, 누적 태그를 함께 저장
|
||||
localStorage.setItem(PROGRESS_KEY, JSON.stringify({
|
||||
@@ -1280,7 +1283,7 @@
|
||||
lastRead: timeString, // 갱신된 시간
|
||||
memo: existingData.memo || '', // 기존 메모 유지
|
||||
isFavorite: existingData.isFavorite || false, // 즐겨찾기 유지
|
||||
tags: mergedTags // ⭐ 자동 갱신 및 누적된 태그 저장
|
||||
tags: mergedTags // ⭐ 매칭 키워드가 포함된 태그 데이터 저장
|
||||
}));
|
||||
|
||||
console.log(`[렌더링] 뷰 업데이트: 챕터 ${currentChapterIndex} / 페이지 ${currentPageInChapter + 1} (태그 수: ${mergedTags.length}개)`);
|
||||
@@ -1485,7 +1488,10 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 포함되어 있으면 태그 배열을 반환합니다.
|
||||
* @description 텍스트 본문을 스캔하여 동적으로 설정된 키워드가 2개 이상 포함되어 있으면 태그 배열을 반환합니다.
|
||||
*/
|
||||
/**
|
||||
* @description 텍스트 본문을 스캔하여 키워드가 2개 이상 포함되어 있으면 태그명과 매칭된 키워드 목록을 객체로 반환합니다.
|
||||
*/
|
||||
function extractTags(text) {
|
||||
const tags = [];
|
||||
@@ -1495,12 +1501,58 @@
|
||||
const sampleText = text.substring(0, 100000); // 상위 10만 자 스캔
|
||||
|
||||
for (const [tag, keywords] of Object.entries(keywordRules)) {
|
||||
if (Array.isArray(keywords) && keywords.some(kw => kw.trim() && sampleText.includes(kw.trim()))) {
|
||||
tags.push(tag);
|
||||
if (Array.isArray(keywords)) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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 keywordManageBtn = document.getElementById('btn-manage-keywords');
|
||||
const keywordCloseBtn = document.getElementById('keyword-close-btn');
|
||||
@@ -1580,6 +1632,60 @@
|
||||
alert("태그 및 키워드 설정이 저장되었습니다.");
|
||||
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>
|
||||
|
||||
|
||||
|
||||
@@ -36,18 +36,33 @@ class WebReaderActivity : AppCompatActivity() {
|
||||
private var pendingBackupData: String? = null
|
||||
|
||||
// 1. 파일 열기 (업로드/복원용) 런처
|
||||
// 1. 파일 열기 (업로드/복원용) 런처 - 다중 파일 지원으로 수정
|
||||
private val fileChooserLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
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)
|
||||
} else {
|
||||
filePathCallback?.onReceiveValue(null)
|
||||
}
|
||||
filePathCallback = null
|
||||
}
|
||||
|
||||
private fun isNetworkConnected(): Boolean {
|
||||
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
@@ -202,6 +217,11 @@ class WebReaderActivity : AppCompatActivity() {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
|
||||
// ⭐ 추가된 핵심 코드: 웹에서 multiple 속성을 보냈는지 확인 후 안드로이드 파일 탐색기에도 적용
|
||||
if (fileChooserParams?.mode == FileChooserParams.MODE_OPEN_MULTIPLE) {
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
}
|
||||
}
|
||||
fileChooserLauncher.launch(intent)
|
||||
return true
|
||||
|
||||
Reference in New Issue
Block a user