This commit is contained in:
2026-08-12 18:08:18 +09:00
parent b526fe4670
commit 2177452f52
3 changed files with 491 additions and 259 deletions
@@ -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
View File
@@ -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