From 2177452f52f35ca5d9a532ac212e646dc761e578 Mon Sep 17 00:00:00 2001 From: lunaticbum Date: Wed, 12 Aug 2026 18:08:18 +0900 Subject: [PATCH] .. --- .../extensions/my_extension/reader.html | 364 +++++++++++------- app/src/main/assets/reader.html | 364 +++++++++++------- .../launcher/home/WebReaderActivity.kt | 22 +- 3 files changed, 491 insertions(+), 259 deletions(-) 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