// tomugi-utils.jsx — shared navigation, math, and state helpers function scrollToId(id, offset = 64) { const el = id === "top" ? document.body : document.getElementById(id); if (!el) { window.scrollTo({ top: 0, behavior: "smooth" }); return; } const y = id === "top" ? 0 : el.getBoundingClientRect().top + window.scrollY - offset; window.scrollTo({ top: y, behavior: "smooth" }); } function getSeriesTotals(series) { return series.reduce( (totals, s) => ({ vols: totals.vols + s.vols, chaps: totals.chaps + s.chaps }), { vols: 0, chaps: 0 } ); } function buildVolumes(series) { if (Array.isArray(series.volumePlan) && series.volumePlan.length) { return series.volumePlan.map((volume) => { const chapters = Array.isArray(volume.chapters) ? volume.chapters.map((num) => ({ num })) : []; const start = chapters.length ? chapters[0].num : volume.n; const end = chapters.length ? chapters[chapters.length - 1].num : volume.n; return { n: volume.n, start, end, count: volume.count || chapters.length || 0, chapters, files: Array.isArray(volume.files) ? volume.files : [], organized: !!volume.organized, }; }); } const out = []; const base = Math.floor(series.chaps / series.vols); let remainder = series.chaps - base * series.vols; let chapter = 1; for (let i = 0; i < series.vols; i++) { const count = base + (remainder > 0 ? 1 : 0); if (remainder > 0) remainder--; const start = chapter; const end = chapter + count - 1; const chapters = Array.from({ length: count }, (_, k) => ({ num: start + k })); out.push({ n: i + 1, start, end, count, chapters, }); chapter = end + 1; } return out; } // Recommend series similar to `current`. Shared genres (from AniList) dominate // the score; same status and comparable length act as tie-breakers. Falls back // gracefully to status + length when a series has no genre data yet. function recommendSeries(current, all, limit = 6) { if (!current || !Array.isArray(all)) return []; const cg = new Set(current.genres || []); return all .filter((s) => s.id !== current.id) .map((s) => { const shared = (s.genres || []).filter((g) => cg.has(g)).length; let score = shared * 4; if (s.status === current.status) score += 1.5; score += Math.max(0, 2 - Math.abs((s.vols || 0) - (current.vols || 0)) / 6); return { s, score }; }) .sort((a, b) => b.score - a.score || a.s.title.localeCompare(b.s.title)) .slice(0, limit) .map((x) => x.s); } function buildDemoVolumes(totalChaps, perVolume) { const volCount = Math.ceil(totalChaps / perVolume); return Array.from({ length: volCount }, (_, v) => { const start = v * perVolume + 1; const end = Math.min((v + 1) * perVolume, totalChaps); return { n: v + 1, start, end, count: end - start + 1 }; }); } // ---- Volume organizing logic (ported from manga-pulse) ---- // Volume boundaries are NOT computed; they come from the series' volumePlan // (the same metadata manga-pulse uses). Organizing = parse each downloaded // file's chapter number, then map it to the volume that owns that chapter. function fmtChapter(n) { return Number.isInteger(n) ? String(n) : String(n); } function chapterNumberFromName(value) { const m = String(value || "").match(/\d+(?:\.\d+)?/); return m ? parseFloat(m[0]) : null; } function folderChapterNumber(name) { const m = String(name || "").match(/chapter\s+(\d+(?:\.\d+)?)/i); if (m) return parseFloat(m[1]); return chapterNumberFromName(name); } function isVolumeFolder(part) { const low = String(part || "").toLowerCase(); return low.includes("vol") || low.startsWith("0 ("); } // Find a chapter number for a file given its path segments (folders + filename). function pathChapterNumber(parts) { const segs = parts.filter((p) => p && p !== "."); if (!segs.length) return null; for (const part of segs) { if (part.toLowerCase().includes("chapter")) { const n = folderChapterNumber(part); if (n !== null) return n; } } for (const part of segs) { if (isVolumeFolder(part)) continue; const n = chapterNumberFromName(part); if (n !== null && n > 0) return n; } return null; } // Build chapter->volume map + per-volume chapter lists from a series. function chapterVolumeMap(series) { const vols = buildVolumes(series); const map = {}; const lists = {}; for (const v of vols) { lists[v.n] = []; for (const c of v.chapters || []) { map[c.num] = v.n; lists[v.n].push(c.num); } } return { map, lists }; } // Decimal/special chapters (e.g. 10.5): place with their base chapter's volume. function inferSpecialVolume(number, lists, map) { if (Number.isInteger(number)) return null; for (const vol of Object.keys(lists)) { const sorted = lists[vol].slice().sort((a, b) => a - b); for (let i = 0; i < sorted.length - 1; i++) { const start = sorted[i]; const end = sorted[i + 1]; if (!Number.isInteger(start) && start < number && number < end && Math.trunc(number) === Math.trunc(start)) { return Number(vol); } } } const base = Math.trunc(number); return map[base] !== undefined ? map[base] : null; } function chaptersToRange(nums) { const arr = nums.slice().sort((a, b) => a - b); if (!arr.length) return ""; const ranges = []; let start = arr[0]; let prev = arr[0]; for (let i = 1; i < arr.length; i++) { const n = arr[i]; if (Number.isInteger(prev) && Number.isInteger(n) && n === prev + 1) { prev = n; continue; } ranges.push(start === prev ? fmtChapter(start) : `${fmtChapter(start)}–${fmtChapter(prev)}`); start = prev = n; } ranges.push(start === prev ? fmtChapter(start) : `${fmtChapter(start)}–${fmtChapter(prev)}`); return ranges.join(", "); } // Group a list of file paths into volumes using the series' real volume plan. // Group items (file paths or {path, handle} entries) into volumes using the // series' real volume plan. getPath extracts the path string from each item. function groupFilesByVolume(series, items, getPath) { getPath = getPath || ((x) => String(x)); const { map, lists } = chapterVolumeMap(series); const volumes = {}; // vol -> { chapters:Set-ish, items:[] } const chaptersByVol = {}; // vol -> Set of chapter numbers const unmatchedSet = {}; for (const item of items) { const ch = pathChapterNumber(getPath(item).split("/")); if (ch === null) continue; let vol = map[ch]; if (vol === undefined) vol = inferSpecialVolume(ch, lists, map); if (vol === undefined || vol === null) { unmatchedSet[ch] = true; continue; } const e = volumes[vol] = volumes[vol] || { items: [] }; e.items.push({ item, chapter: ch, path: getPath(item) }); (chaptersByVol[vol] = chaptersByVol[vol] || {})[ch] = true; } const result = Object.keys(volumes) .map((v) => ({ n: Number(v), chapters: Object.keys(chaptersByVol[v]).map(parseFloat).sort((a, b) => a - b), items: volumes[v].items.sort((a, b) => (a.chapter - b.chapter) || (a.path < b.path ? -1 : 1)), files: volumes[v].items.length, })) .sort((a, b) => a.n - b.n); const allChapters = {}; for (const v of result) for (const c of v.chapters) allChapters[c] = true; return { volumes: result, unmatched: Object.keys(unmatchedSet).map(parseFloat).sort((a, b) => a - b), totalChapters: Object.keys(allChapters).length + Object.keys(unmatchedSet).length, totalFiles: items.length, }; } function organizePreview(series, filePaths) { return groupFilesByVolume(series, filePaths, (p) => String(p)); } const TomugiUtils = { scrollToId, getSeriesTotals, buildVolumes, buildDemoVolumes, recommendSeries, chaptersToRange, organizePreview, groupFilesByVolume, }; Object.assign(window, { TomugiUtils, scrollToId, buildVolumes, });