diff --git a/app/src/main/assets/extensions/my_extension/reader.html b/app/src/main/assets/extensions/my_extension/reader.html
index 87241db6..621205fb 100644
--- a/app/src/main/assets/extensions/my_extension/reader.html
+++ b/app/src/main/assets/extensions/my_extension/reader.html
@@ -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 @@
@@ -327,6 +328,8 @@
+
+
@@ -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);
@@ -562,52 +566,63 @@
li.className = 'library-item';
// 1. 로컬 스토리지 데이터 불러오기
- const pKey = `webReader_progress_${book.id}`;
- const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
+ 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 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();
- let bookStatsText = `📚 총 ${totalChapters}화 · ${formattedChars}자
`;
+ let progressText = savedData.chapter !== undefined
+ ? `(Ch.${savedData.chapter + 1})`
+ : '';
- let progressText = savedData.chapter !== undefined
- ? `(읽는 중: Ch.${savedData.chapter + 1})
`
- : '';
+ let bookStatsText = `📚 총 ${totalChapters}화 · ${formattedChars}자
`;
- let lastReadText = savedData.lastRead
- ? `🕒 마지막 열람: ${savedData.lastRead}
`
- : '';
+ // ⭐ [수정] flex-wrap과 gap을 적용하여 태그가 많아져도 겹치지 않도록 수정
+ let tagsText = '';
+ if (savedData.tags && savedData.tags.length > 0) {
+ const badges = savedData.tags.map(tagObj => {
+ // 기존 형태(단순 문자열) 태그 예외 처리
+ if (typeof tagObj === 'string') {
+ return `${tagObj}`;
+ }
- let memoText = savedData.memo
- ? `📝 ${savedData.memo}
`
- : '';
+ // 매칭된 키워드 텍스트 조립 (예: "(마법, 드래곤)")
+ const matchedStr = (tagObj.matched && tagObj.matched.length > 0)
+ ? `(${tagObj.matched.join(', ')})`
+ : '';
- // 태그 HTML 생성
- let tagsText = '';
- if (savedData.tags && savedData.tags.length > 0) {
- const badges = savedData.tags.map(tag =>
- `${tag}`
- ).join('');
- tagsText = `${badges}
`;
- }
+ return `${tagObj.name}${matchedStr}`;
+ }).join('');
- // 2. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
- const titleArea = document.createElement('div');
- titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
- titleArea.innerHTML = `
+ tagsText = `${badges}
`;
+ }
+
+ let lastReadText = savedData.lastRead
+ ? `🕒 마지막 열람: ${savedData.lastRead}
`
+ : '';
+
+ let memoText = savedData.memo
+ ? `📝 ${savedData.memo}
`
+ : '';
+
+ // 2. [수정] titleArea 레이아웃 정돈
+ const titleArea = document.createElement('div');
+ titleArea.style.cssText = "flex: 1; display: flex; flex-direction: column; margin-bottom: 10px;";
+ titleArea.innerHTML = `
${book.fileName} ${progressText}
- ${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 {
- if (file.name.toLowerCase().endsWith('.epub')) {
- // =============== EPUB 파일 처리 ===============
- console.log(`[파싱] EPUB 파일 파싱 시작`);
- loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
- loadingTimer1 = setTimeout(async () => {
+ // 선택된 파일들을 하나씩 순차적으로 처리
+ 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 파싱 시작: ${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 }));
+ await saveBookToDB(file.name, chapterList, currentId);
+ localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
- buildTocUI();
- loadChapter(0, 0);
- }, 100);
-
- } else if (file.name.toLowerCase().endsWith('.zip')) {
- // =============== ZIP 파일 처리 (TXT 병합) ===============
- console.log(`[파싱] ZIP 파일 파싱 시작`);
- loadingText.innerText = "ZIP 압축 해제 및 텍스트 병합 중...";
-
- loadingTimer1 = setTimeout(async () => {
+ } else if (file.name.toLowerCase().endsWith('.zip')) {
+ // =============== 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");
+ 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);
- let combinedText = "";
+ const countUtf8 = (decoderUtf8.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
+ const countCp949 = (decoderCp949.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
- // 각 파일을 순서대로 읽으며 인코딩 감지 후 병합
- for (const fileName of txtFiles) {
- const fileData = await loadedZip.file(fileName).async("uint8array");
+ combinedText += `\n\n\n${countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData)}`;
+ }
+ combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
+ combinedText = fixForcedLineBreaks(combinedText);
- // 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 = (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 = 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 }));
+ await saveBookToDB(file.name, chapterList, currentId);
+ localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
+ }
- buildTocUI();
- loadChapter(0, 0);
- }, 100);
- }, 100);
-
- } else {
- // =============== TXT 파일 처리 ===============
- console.log(`[파싱] TXT 파일 인코딩 분석 시작`);
- loadingText.innerText = "최적 인코딩 판독 중...";
- let fullText = await readTextSafely(file);
- fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
-
- loadingText.innerText = "챕터 분석 및 저장 중...";
- loadingTimer1 = setTimeout(async () => {
- console.log(`[파싱] TXT 챕터 분석 시작`);
+ } else {
+ // =============== TXT 파일 처리 ===============
+ console.log(`[파싱] TXT 파싱 시작: ${file.name}`);
+ let fullText = await readTextSafely(file);
+ fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
parseChapters(fullText);
- const extractedTags = extractTags(fullText);
- await saveBookToDB(file.name, chapterList);
- localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
- buildTocUI();
- loadChapter(0, 0);
- }, 100);
+ 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);
}
+
} 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';
+ }
+ });
+
diff --git a/app/src/main/assets/reader.html b/app/src/main/assets/reader.html
index 4a6efb1d..6e5486f1 100644
--- a/app/src/main/assets/reader.html
+++ b/app/src/main/assets/reader.html
@@ -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 @@
@@ -327,6 +328,8 @@
+
+
@@ -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);
@@ -562,52 +566,63 @@
li.className = 'library-item';
// 1. 로컬 스토리지 데이터 불러오기
- const pKey = `webReader_progress_${book.id}`;
- const savedData = JSON.parse(localStorage.getItem(pKey)) || {};
+ 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 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();
- let bookStatsText = `📚 총 ${totalChapters}화 · ${formattedChars}자
`;
+ let progressText = savedData.chapter !== undefined
+ ? `(Ch.${savedData.chapter + 1})`
+ : '';
- let progressText = savedData.chapter !== undefined
- ? `(읽는 중: Ch.${savedData.chapter + 1})
`
- : '';
+ let bookStatsText = `📚 총 ${totalChapters}화 · ${formattedChars}자
`;
- let lastReadText = savedData.lastRead
- ? `🕒 마지막 열람: ${savedData.lastRead}
`
- : '';
+ // ⭐ [수정] flex-wrap과 gap을 적용하여 태그가 많아져도 겹치지 않도록 수정
+ let tagsText = '';
+ if (savedData.tags && savedData.tags.length > 0) {
+ const badges = savedData.tags.map(tagObj => {
+ // 기존 형태(단순 문자열) 태그 예외 처리
+ if (typeof tagObj === 'string') {
+ return `${tagObj}`;
+ }
- let memoText = savedData.memo
- ? `📝 ${savedData.memo}
`
- : '';
+ // 매칭된 키워드 텍스트 조립 (예: "(마법, 드래곤)")
+ const matchedStr = (tagObj.matched && tagObj.matched.length > 0)
+ ? `(${tagObj.matched.join(', ')})`
+ : '';
- // 태그 HTML 생성
- let tagsText = '';
- if (savedData.tags && savedData.tags.length > 0) {
- const badges = savedData.tags.map(tag =>
- `${tag}`
- ).join('');
- tagsText = `${badges}
`;
- }
+ return `${tagObj.name}${matchedStr}`;
+ }).join('');
- // 2. 책 제목, 분량 정보, 태그, 진행도, 시간, 메모 조립
- const titleArea = document.createElement('div');
- titleArea.style.cssText = "overflow: hidden; flex: 1; padding-right: 10px;";
- titleArea.innerHTML = `
+ tagsText = `${badges}
`;
+ }
+
+ let lastReadText = savedData.lastRead
+ ? `🕒 마지막 열람: ${savedData.lastRead}
`
+ : '';
+
+ let memoText = savedData.memo
+ ? `📝 ${savedData.memo}
`
+ : '';
+
+ // 2. [수정] titleArea 레이아웃 정돈
+ const titleArea = document.createElement('div');
+ titleArea.style.cssText = "flex: 1; display: flex; flex-direction: column; margin-bottom: 10px;";
+ titleArea.innerHTML = `
${book.fileName} ${progressText}
- ${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 {
- if (file.name.toLowerCase().endsWith('.epub')) {
- // =============== EPUB 파일 처리 ===============
- console.log(`[파싱] EPUB 파일 파싱 시작`);
- loadingText.innerText = "EPUB 압축 해제 및 목차 분석 중...";
- loadingTimer1 = setTimeout(async () => {
+ // 선택된 파일들을 하나씩 순차적으로 처리
+ 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 파싱 시작: ${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 }));
+ await saveBookToDB(file.name, chapterList, currentId);
+ localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
- buildTocUI();
- loadChapter(0, 0);
- }, 100);
-
- } else if (file.name.toLowerCase().endsWith('.zip')) {
- // =============== ZIP 파일 처리 (TXT 병합) ===============
- console.log(`[파싱] ZIP 파일 파싱 시작`);
- loadingText.innerText = "ZIP 압축 해제 및 텍스트 병합 중...";
-
- loadingTimer1 = setTimeout(async () => {
+ } else if (file.name.toLowerCase().endsWith('.zip')) {
+ // =============== 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");
+ 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);
- let combinedText = "";
+ const countUtf8 = (decoderUtf8.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
+ const countCp949 = (decoderCp949.decode(sampleBuffer).match(/[가-힣]/g) || []).length;
- // 각 파일을 순서대로 읽으며 인코딩 감지 후 병합
- for (const fileName of txtFiles) {
- const fileData = await loadedZip.file(fileName).async("uint8array");
+ combinedText += `\n\n\n${countCp949 > countUtf8 ? decoderCp949.decode(fileData) : decoderUtf8.decode(fileData)}`;
+ }
+ combinedText = combinedText.replace(/(\n\s*)+\n/g, '\n\n');
+ combinedText = fixForcedLineBreaks(combinedText);
- // 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 = (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 = 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 }));
+ await saveBookToDB(file.name, chapterList, currentId);
+ localStorage.setItem(pKey, JSON.stringify({ chapter: 0, page: 0, tags: extractedTags }));
+ }
- buildTocUI();
- loadChapter(0, 0);
- }, 100);
- }, 100);
-
- } else {
- // =============== TXT 파일 처리 ===============
- console.log(`[파싱] TXT 파일 인코딩 분석 시작`);
- loadingText.innerText = "최적 인코딩 판독 중...";
- let fullText = await readTextSafely(file);
- fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
-
- loadingText.innerText = "챕터 분석 및 저장 중...";
- loadingTimer1 = setTimeout(async () => {
- console.log(`[파싱] TXT 챕터 분석 시작`);
+ } else {
+ // =============== TXT 파일 처리 ===============
+ console.log(`[파싱] TXT 파싱 시작: ${file.name}`);
+ let fullText = await readTextSafely(file);
+ fullText = fullText.replace(/(\n\s*)+\n/g, '\n\n');
parseChapters(fullText);
- const extractedTags = extractTags(fullText);
- await saveBookToDB(file.name, chapterList);
- localStorage.setItem(PROGRESS_KEY, JSON.stringify({ chapter: 0, page: 0 }));
- buildTocUI();
- loadChapter(0, 0);
- }, 100);
+ 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);
}
+
} 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';
+ }
+ });
+
diff --git a/app/src/main/kotlin/bums/lunatic/launcher/home/WebReaderActivity.kt b/app/src/main/kotlin/bums/lunatic/launcher/home/WebReaderActivity.kt
index 4a967c97..37130bf4 100644
--- a/app/src/main/kotlin/bums/lunatic/launcher/home/WebReaderActivity.kt
+++ b/app/src/main/kotlin/bums/lunatic/launcher/home/WebReaderActivity.kt
@@ -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? = 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