비로그인으로 뉴토끼 북마크 관리를 가능하게 해주는 스크립트입니다.
// ==UserScript==
// @name Toki Mark
// @namespace http://tampermonkey.net/
// @version 260523012800
// @description 비로그인으로 뉴토끼 북마크 관리를 가능하게 해주는 스크립트입니다.
// @license MIT
// @include *://sbxh*.com/*
// @include *://*.sbxh*.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=newtoki.com
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function() {
'use strict';
// Strict Hostname Guard to prevent execution on unrelated .com/.net domains
const isTokiSite = /^(.*\.)?(sbxh\d*|newtoki\d*|manatoki\d*)\.(com|net)$/i.test(location.hostname);
if (!isTokiSite) return;
// DevTools Shortcut Bypass logic to override site's keydown restriction
(function bypassDevToolsBlocker() {
const isDevToolsKey = (e) => {
if (!(e instanceof KeyboardEvent)) return false;
const keyCode = e.keyCode || e.which;
const key = e.key ? e.key.toLowerCase() : '';
if (keyCode === 123 || key === 'f12') return true;
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (keyCode === 73 || keyCode === 67 || keyCode === 74 || key === 'i' || key === 'c' || key === 'j')) return true;
if ((e.ctrlKey || e.metaKey) && (keyCode === 85 || key === 'u')) return true;
return false;
};
window.addEventListener('keydown', function(e) {
if (isDevToolsKey(e)) {
e.stopImmediatePropagation();
}
}, true);
})();
const STORAGE_KEY = 'toki_local_bookmarks_v10';
const PREF_KEY = 'toki_sort_pref_v10';
const PIN_UP_KEY = 'toki_pin_up_v10';
const PIN_CLR_KEY = 'toki_pin_clr_v10'; // Pin clear key
const SORT_ASC_KEY = 'toki_sort_asc_v10';
const SCAN_QUEUE_KEY = 'toki_scan_queue_v20'; // Reset scan queue key
const SYNC_AT_KEY = 'toki_last_sync_at_v10';
const REMOTE_AT_KEY = 'toki_last_remote_at_v10';
let pushDebounceTimer = null; // Timer for sync debouncing
const DIRTY_KEY = 'toki_dirty_count';
const syncChannel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('toki_update_sync_v20') : null;
// Constants for UI stability
const ID_BOOKMARKS_WRAPPER = 'my-local-bookmarks';
const ID_MANHWA_BTN = 'my-local-btn';
const ID_VIEWER_UI = 'toki-go-bookmark'; // Using bookmark link as UI presence indicator
// Encapsulated State
const tokiState = {
filters: { states: [], genres: [] },
filterMode: 'AND',
searchQuery: '',
activeTab: (() => {
let saved = localStorage.getItem('toki_fav_last_tab');
if (!saved || saved === 'all') saved = 'manhwa';
return saved;
})(),
isScanning: false,
isSyncing: false,
btnTimeout: null,
debounceTimer: null,
isPushing: false
};
// Detection for mobile environments
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
/**
* Shared Utilities
*/
const TokiUtils = {
// [LWW: Last Write Wins] Unified timestamp extraction for conflict resolution
getEffectiveTime: (m) => Math.max(m.exactUpdatedAt || 0, m.parsedUpdatedAt || 0, m.latestUpdatedAt || 0, m.lastReadAt || 0, m.addedAt || 0),
formatDateTime: (ts) => {
if(!ts) return "";
const d = new Date(ts); const pad = n => n.toString().padStart(2, '0');
return `${String(d.getFullYear()).slice(2)}.${pad(d.getMonth()+1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
getCategoryFromUrl: (url) => {
if (!url) return 'manhwa';
const m = url.match(/\/(manhwa|webtoon|novel)\//i) || url.match(/\/(manhwa|webtoon|novel)$/i);
return m ? m[1].toLowerCase() : 'manhwa';
},
extractAuthor: (htmlString, docTitle) => {
let rawTitle = docTitle || "";
if (htmlString) {
const titleMatch = htmlString.match(/<title>([^<]+)<\/title>/i);
if (titleMatch) rawTitle = titleMatch[1];
}
const match = rawTitle.match(/^(.*?)\s*-\s*([^|]+?)\s*\|\s*뉴토끼$/);
if (match) {
const candidate = match[2].trim();
if (candidate.match(/화$|프롤로그|완결|단편|예고편|^\d/)) {
return "";
}
return candidate;
}
return "";
},
ensureV10Structure: (data) => {
// Return a safe empty structure if data is empty, not an object, or an array (data corruption state)
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return { manhwa: {}, webtoon: {}, novel: {} };
}
// Perform migration if it is an old version (single object without category separation)
if (!data.manhwa && !data.webtoon && !data.novel) {
const migrated = { manhwa: {}, webtoon: {}, novel: {} };
let migratedCount = 0;
for (const [id, item] of Object.entries(data)) {
if (!item || typeof item !== 'object') continue; // Skip corrupted individual item
const cat = TokiUtils.getCategoryFromUrl(item.url || '');
migrated[cat][id] = item;
migratedCount++;
}
// CRITICAL SAFETY RESCUE: If migration resulted in 0 items but the original data had elements,
// fallback everything into 'manhwa' to prevent catastrophic data loss!
const originalKeysCount = Object.keys(data).length;
if (migratedCount === 0 && originalKeysCount > 0) {
console.warn('[TokiUtils] Migration guard: 0 items migrated but original data had', originalKeysCount, 'keys. Rescuing data to manhwa...');
for (const [id, item] of Object.entries(data)) {
if (item && typeof item === 'object') {
migrated.manhwa[id] = item;
}
}
}
return migrated;
}
// If it already has the new version structure (fill in missing categories if any)
return {
manhwa: data.manhwa || {},
webtoon: data.webtoon || {},
novel: data.novel || {}
};
}
};
// ==========================================
// 🛡️ TokiSafetyGuard (Triple Data Protection)
// ==========================================
const TokiSafetyGuard = {
performEmergencyRecovery: function() {
try {
const legacyKey = 'toki_local_bookmarks';
const currentKey = STORAGE_KEY;
const backupKey = 'toki_bookmarks_backup_safe';
const legacyData = localStorage.getItem(legacyKey);
const currentData = localStorage.getItem(currentKey);
// 1. Emergency Recovery: Migration from legacy plain token structure
const isEmpty = !currentData || currentData === '{}' || currentData === '{"manhwa":{},"webtoon":{},"novel":{}}';
if (legacyData && isEmpty) {
const parsed = JSON.parse(legacyData);
const structured = TokiUtils.ensureV10Structure(parsed);
saveBookmarks(structured, { silent: true });
console.log('[TokiSafetyGuard] Emergency Recovery Success: Legacy bookmarks restored.');
if (typeof TokiUI !== 'undefined') {
TokiUI.toast('💡 이전 버전의 북마크가 안전하게 복구되었습니다.', 'success');
}
}
// 2. Local Safe Backup: Take a periodic snapshot of stable local data
const activeData = localStorage.getItem(currentKey);
if (activeData && activeData !== '{}' && activeData !== '{"manhwa":{},"webtoon":{},"novel":{}}') {
localStorage.setItem(backupKey, activeData);
}
} catch (e) {
console.error('[TokiSafetyGuard] Emergency recovery failed:', e);
}
},
validateSchema: function(data) {
if (!data || typeof data !== 'object') return false;
// Strict Schema Assertion: Must contain at least one of core categories
const hasManhwa = 'manhwa' in data && typeof data.manhwa === 'object' && !Array.isArray(data.manhwa);
const hasWebtoon = 'webtoon' in data && typeof data.webtoon === 'object' && !Array.isArray(data.webtoon);
const hasNovel = 'novel' in data && typeof data.novel === 'object' && !Array.isArray(data.novel);
return hasManhwa || hasWebtoon || hasNovel;
}
};
// ==========================================
// CSS Injection
// ==========================================
const style = document.createElement('style');
style.innerHTML = `
/* Theme Variables */
:root, [data-theme="light"] {
--toki-bg: #fff; --toki-bg-sub: #f8f9fa; --toki-bg-input: #fff;
--toki-border: #e9ecef; --toki-border-light: #eee; --toki-border-dashed: #dcdde1;
--toki-text: #2d3436; --toki-text-sub: #636e72; --toki-text-muted: #a4b0be; --toki-text-label: #b2bec3;
--toki-card-shadow: 0 1px 3px rgba(0,0,0,0.04);
--toki-highlight-bg: #ffebeb; --toki-highlight-border: #ffcccc;
--toki-empty-bg: #f8f9fa; --toki-delete-bg: #f1f2f6;
--toki-clr-pin-bg: #f1f2f6; --toki-clr-pin-border: #dfe6e9; --toki-clr-pin-hover: #e2e6e9;
--toki-pin-bg: #ffebeb; --toki-pin-hover: #ffe3e3;
--toki-genre-bg: #f8f9fa; --toki-genre-border: #e9ecef; --toki-genre-hover: #e2e6e9;
--toki-filter-bg: #fff; --toki-filter-border: #dcdde1; --toki-filter-hover: #f1f2f6;
--toki-dropdown-bg: white; --toki-dropdown-shadow: 0 4px 12px rgba(0,0,0,0.15);
--toki-done-bg: #dfe6e9; --toki-done-text: #636e72;
--toki-modal-bg: #fff; --toki-modal-cancel: #dfe6e9;
}
[data-theme="dark"] {
--toki-bg: #1e1e2e; --toki-bg-sub: #181825; --toki-bg-input: #11111b;
--toki-border: #313244; --toki-border-light: #313244; --toki-border-dashed: #45475a;
--toki-text: #cdd6f4; --toki-text-sub: #a6adc8; --toki-text-muted: #6c7086; --toki-text-label: #585b70;
--toki-card-shadow: 0 1px 3px rgba(0,0,0,0.3);
--toki-highlight-bg: #3b1c2a; --toki-highlight-border: #5a3040;
--toki-empty-bg: #181825; --toki-delete-bg: #313244;
--toki-clr-pin-bg: #313244; --toki-clr-pin-border: #45475a; --toki-clr-pin-hover: #45475a;
--toki-pin-bg: #3b1c2a; --toki-pin-hover: #3a2030;
--toki-genre-bg: #181825; --toki-genre-border: #313244; --toki-genre-hover: #45475a;
--toki-filter-bg: #1e1e2e; --toki-filter-border: #45475a; --toki-filter-hover: #313244;
--toki-dropdown-bg: #1e1e2e; --toki-dropdown-shadow: 0 4px 12px rgba(0,0,0,0.5);
--toki-done-bg: #313244; --toki-done-text: #a6adc8;
--toki-modal-bg: #1e1e2e; --toki-modal-cancel: #313244;
}
@keyframes pulseUP { 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 71, 87, 0.7); } 70% { transform: scale(1.05); box-shadow: 0 0 0 4px rgba(255, 71, 87, 0); } 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 71, 87, 0); } }
@keyframes toastFadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes modalFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes modalSlideIn { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
.toki-up-badge { display: inline-flex; align-items: center; justify-content: center; background-color: #ff4757; color: white; border-radius: 4px; padding: 0 5px; height: 18px; font-size: 11px; font-weight: 900; animation: pulseUP 2s infinite; flex-shrink: 0; white-space: nowrap; line-height: 1; }
.toki-clr-badge { display: inline-flex; align-items: center; justify-content: center; background-color: #636e72; color: white; border-radius: 4px; padding: 0 5px; height: 18px; font-size: 11px; font-weight: 900; flex-shrink: 0; white-space: nowrap; line-height: 1; }
.toki-control-bar { display: flex; justify-content: space-between; align-items: center; background: var(--toki-bg-sub); padding: 12px 16px; border-radius: 8px; margin: 15px 0 25px 0; border: 1px solid var(--toki-border); box-shadow: var(--toki-card-shadow); flex-wrap: wrap; gap: 10px; }
.toki-card { background: var(--toki-bg); border-radius: 8px; margin-bottom: 12px; padding: 12px; border: 1px solid var(--toki-border-light); box-shadow: var(--toki-card-shadow); display: flex; flex-direction: column; gap: 12px; }
.toki-card-top { display: flex; text-decoration: none; color: inherit; align-items: center; }
.toki-thumb { border-radius: 6px; overflow: hidden; width: 80px; height: 100px; flex-shrink: 0; background: var(--toki-delete-bg); }
.toki-thumb img { width: 100%; height: 100%; object-fit: cover; }
.toki-info { width: 100%; padding-left: 15px; display: flex; flex-direction: column; justify-content: center; }
.toki-title { font-size: 16px; margin-bottom: 4px; display: flex; align-items: center; font-weight: 800; color: var(--toki-text); gap: 6px; }
.toki-genre { font-size: 11.5px; color: var(--toki-text-muted); margin-bottom: 4px; font-weight: 500; }
.toki-author { font-size: 12px; color: var(--toki-text-sub); margin-bottom: 4px; font-weight: 600; }
.toki-meta-box { display: flex; flex-direction: column; gap: 4px; }
.toki-meta-row { font-size: 13px; color: var(--toki-text-sub); display: flex; align-items: center; }
.toki-date-sub { font-size: 11px; color: var(--toki-text-muted); margin-left: 6px; }
.toki-actions { display: flex; gap: 8px; width: 100%; }
.toki-btn { padding: 10px 0; border-radius: 6px; font-size: 13px; font-weight: bold; text-align: center; text-decoration: none; cursor: pointer; border: none; transition: filter 0.2s; display: flex; justify-content: center; align-items: center; }
.toki-btn:hover { filter: brightness(0.95); color: white; }
#toki-toast-container { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 10000001; display: flex; flex-direction: column-reverse; gap: 10px; align-items: center; }
.toki-toast { background: rgba(45, 52, 54, 0.95); color: white; padding: 12px 24px; border-radius: 50px; font-size: 14px; font-weight: bold; box-shadow: 0 4px 15px rgba(0,0,0,0.2); animation: toastFadeIn 0.3s ease-out; pointer-events: none; backdrop-filter: blur(5px); border: 1px solid rgba(255,255,255,0.1); white-space: nowrap; transition: opacity 0.2s, transform 0.2s; width: max-content; min-width: 160px; text-align: center; }
.toki-toast.success { background: rgba(0, 184, 148, 0.95); }
.toki-toast.error { background: rgba(255, 71, 87, 0.95); }
.toki-toast.hiding { opacity: 0; transform: translateY(10px); }
.toki-modal-backdrop { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 10000000; display: flex; align-items: center; justify-content: center; animation: modalFadeIn 0.2s; backdrop-filter: blur(2px); }
.toki-modal { background: var(--toki-modal-bg); border-radius: 12px; width: 320px; max-width: 90%; padding: 24px; box-shadow: 0 10px 25px rgba(0,0,0,0.2); animation: modalSlideIn 0.3s forwards; text-align: center; }
.toki-modal-title { font-size: 18px; font-weight: 800; color: var(--toki-text); margin-bottom: 10px; }
.toki-modal-desc { font-size: 14px; color: var(--toki-text-sub); margin-bottom: 20px; line-height: 1.5; }
.toki-modal-btns { display: flex; gap: 10px; justify-content: center; }
.toki-modal-btn { flex: 1; padding: 10px; border-radius: 8px; font-size: 14px; font-weight: bold; cursor: pointer; border: none; }
.toki-modal-btn.cancel { background: #dfe6e9; color: #2d3436; }
.toki-modal-btn.cancel:hover { background: #b2bec3; }
.toki-modal-btn.confirm { background: #ff4757; color: white; }
.toki-modal-btn.primary { background: #0984e3; color: white; }
.toki-filter-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.toki-filter-btn { padding: 6px 12px; border-radius: 20px; font-size: 13px; font-weight: bold; cursor: pointer; border: 1px solid var(--toki-filter-border); background: var(--toki-filter-bg); color: var(--toki-text-sub); transition: 0.2s; display: flex; align-items: center; gap: 6px; user-select: none; }
.toki-filter-btn:hover { background: var(--toki-filter-hover); }
.toki-filter-btn.active { background: #0984e3; color: white; border-color: #0984e3; }
.toki-filter-btn.reset { background: #ff7675; color: white; border-color: #ff7675; }
.toki-filter-btn.reset:hover { background: #d63031; }
.toki-dropdown { position: relative; }
.toki-dropdown-menu { position: absolute; top: 100%; left: 0; margin-top: 5px; background: var(--toki-dropdown-bg); border: 1px solid var(--toki-filter-border); border-radius: 8px; box-shadow: var(--toki-dropdown-shadow); width: 600px; max-width: 90vw; max-height: 400px; overflow-y: auto; z-index: 100; display: none; padding: 12px; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 6px; }
.toki-dropdown-menu.show { display: grid; }
.toki-genre-item { padding: 6px 8px; font-size: 12px; font-weight: bold; cursor: pointer; border-radius: 6px; display: flex; align-items: center; justify-content: center; gap: 4px; background: var(--toki-genre-bg); border: 1px solid var(--toki-genre-border); color: var(--toki-text-sub); transition: 0.15s; }
.toki-genre-item:hover { background: var(--toki-genre-hover); }
.toki-genre-item.active { background: #0984e3; color: white; border-color: #0984e3; }
.toki-genre-item.active .chk { display: inline-block; }
.toki-genre-item .chk { display: none; margin-right: 2px; }
.toki-mode-btn { padding: 6px 12px; border-radius: 20px; font-size: 13px; font-weight: 900; cursor: pointer; border: 1px solid #2d3436; background: #2d3436; color: white; transition: 0.2s; display: flex; align-items: center; gap: 6px; user-select: none; }
.toki-mode-btn:hover { background: #636e72; border-color: #636e72; }
.toki-mode-btn.or-mode { background: #00b894; border-color: #00b894; }
.toki-mode-btn.or-mode:hover { background: #55efc4; border-color: #55efc4; }
.toki-pin-toggle { font-size:13px; font-weight:800; color:#ff4757; display:flex; align-items:center; gap:4px; cursor:pointer; background:var(--toki-pin-bg); padding:6px 10px; border-radius:6px; border:1px solid var(--toki-highlight-border); user-select:none; transition:0.2s; }
.toki-pin-toggle:hover { background:var(--toki-pin-hover); }
.toki-pin-toggle input { cursor: pointer; margin: 0; }
.toki-pin-toggle.clr-pin { color:var(--toki-text-sub); background:var(--toki-clr-pin-bg); border-color:var(--toki-clr-pin-border); }
.toki-pin-toggle.clr-pin:hover { background:var(--toki-clr-pin-hover); }
.toki-pin-toggle:has(input:checked) { border-color: #ff4757; background: rgba(255, 71, 87, 0.05); }
.toki-pin-toggle.clr-pin:has(input:checked) { border-color: #00b894; background: rgba(0, 184, 148, 0.05); }
/* [Sync Modal] */
.toki-sync-modal-bg { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 2000001; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); animation: modalFadeIn 0.2s; }
.toki-sync-modal-box { position: relative; background: var(--toki-modal-bg); border-radius: 16px; width: 380px; max-width: 90%; padding: 28px; box-shadow: 0 15px 35px rgba(0,0,0,0.3); animation: modalSlideIn 0.3s forwards; }
.toki-sync-title { font-size: 20px; font-weight: 900; color: var(--toki-text); margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
.toki-sync-title .status-toggle { font-size: 11px; padding: 4px 12px; border-radius: 20px; background: #dfe6e9; color: #636e72; cursor: pointer; transition: 0.2s; border: 1px solid #b2bec3; user-select: none; }
.toki-sync-title .status-toggle:hover { background: #b2bec3; }
.toki-sync-title .status-toggle.on { background: #00b894; color: white; border-color: #00b894; }
.toki-sync-title .status-toggle.on:hover { background: #00a383; }
.toki-sync-desc { font-size: 13.5px; color: var(--toki-text-sub); margin-bottom: 20px; line-height: 1.6; text-align: left; }
.toki-sync-input { width: 100%; padding: 12px; border-radius: 8px; border: 1px solid var(--toki-filter-border); background: var(--toki-bg-input); color: var(--toki-text); margin-bottom: 20px; font-family: monospace; font-size: 13px; box-sizing: border-box; }
.toki-sync-btns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.toki-sync-btn { padding: 12px 5px; border-radius: 8px; font-size: 13px; font-weight: bold; cursor: pointer; border: none; transition: 0.2s; background: var(--toki-bg-sub); color: var(--toki-text-sub); border: 1px solid var(--toki-border); }
.toki-sync-btn.primary { background: #0984e3; color: white; border-color: #0984e3; }
.toki-sync-btn.success { background: #00b894; color: white; border-color: #00b894; }
.toki-sync-btn.cancel { background: #f1f2f6; color: #2d3436; border: 1px solid #dfe6e9; }
.toki-sync-btn:hover { opacity: 0.9; transform: translateY(-1px); }
.toki-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* [Stats Grid] */
.toki-stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 15px; padding: 0 5px; }
.toki-stat-item { background: var(--toki-bg-sub); border: 1px solid var(--toki-border); border-radius: 10px; padding: 10px 5px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; }
.toki-stat-item .label { font-size: 11px; font-weight: 800; color: var(--toki-text-muted); }
.toki-stat-item .value { font-size: 15px; font-weight: 900; color: var(--toki-text); }
/* [PC Specific Styles] */
.toki-pc .toki-card { border-radius: 8px; transition: all 0.2s; }
.toki-pc .toki-card:hover { border-color: #0984e3; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.toki-pc .toki-actions { display: flex; gap: 10px; width: 100%; margin-top: 5px; }
.toki-pc .toki-btn-main { flex: 1; } /* View from first episode / Resume (PC) */
.toki-pc .toki-btn-sub { flex: 1; } /* Next episode / Latest episode (PC) */
.toki-pc .toki-btn-del { flex: 0 0 85px; background: var(--toki-delete-bg) !important; color: var(--toki-text-sub) !important; }
.toki-pc .btn-import, .toki-pc .btn-export { background: #2d3436 !important; color: white !important; }
.toki-pc .toki-title .title-text { flex: none; }
.toki-pc #sortSelect { height: 32px; font-size: 13px; border-color: var(--toki-filter-border); transition: border-color 0.2s; }
.toki-pc #sortSelect:focus { border-color: #0984e3; }
/* [Mobile Specific Styles] */
.toki-mobile .toki-category-tabs { gap: 6px; padding: 4px; margin-bottom: 12px; border-radius: 10px; }
.toki-mobile .toki-tab-btn { padding: 10px 4px; font-size: 13px; gap: 4px; border-radius: 8px; }
.toki-mobile .tab-badge.has-up { font-size: 9.5px; padding: 1px 4px; }
.toki-mobile .toki-stats-grid { grid-template-columns: repeat(4, 1fr); }
.toki-mobile .toki-stat-item.total { grid-column: span 4; flex-direction: row; justify-content: space-between; padding: 12px 20px; }
.toki-mobile .toki-control-bar { padding: 14px; margin: 10px 0; flex-direction: column; align-items: stretch; gap: 12px; border-radius: 12px; }
.toki-mobile .toki-filter-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.toki-mobile .toki-filter-btn, .toki-mobile .toki-mode-btn, .toki-mobile .toki-dropdown { width: 100%; justify-content: center; padding: 10px 0; font-size: 12px; }
.toki-mobile #btnFilterReset { grid-column: span 1; }
.toki-mobile #btnFilterMode { grid-column: span 1; }
.toki-mobile .toki-dropdown { grid-column: span 2; }
.toki-mobile .state-toggle { grid-column: span 1; }
.toki-mobile #sortDirBtn .toki-icon-blue { color: #0984e3 !important; font-weight: 900; }
.toki-mobile .toki-sort-row { display: flex; flex-direction: column; gap: 10px; padding-top: 5px; border-top: 1px dashed var(--toki-border); }
.toki-mobile .toki-sort-main { display: flex; gap: 8px; align-items: center; }
.toki-mobile #sortSelect { flex: 1; height: 44px; border-radius: 6px; border: 1px solid var(--toki-filter-border); background: var(--toki-bg-input); color: var(--toki-text); font-weight: bold; }
.toki-mobile #sortDirBtn { width: 100px; height: 44px; margin-right: 0 !important; }
.toki-mobile .toki-pin-group { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.toki-mobile .toki-pin-toggle { justify-content: center; padding: 12px; height: 48px; font-size: 12px; box-sizing: border-box; border-width: 2px; }
.toki-mobile .toki-pin-toggle input { width: 18px; height: 18px; }
.toki-mobile .toki-sync-group { display: flex; flex-direction: column; gap: 8px; }
.toki-mobile .toki-action-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.toki-mobile .toki-action-row button { height: 48px; font-size: 13px; border-radius: 8px; font-weight: 800; border: none; color: white; cursor: pointer; }
.toki-mobile .btn-import, .toki-mobile .btn-export { background: #2d3436 !important; }
.toki-mobile #syncSettingsBtn { background: #4b6584 !important; }
.toki-mobile #checkUpdatesBtn { background: #0984e3 !important; box-shadow: 0 2px 5px rgba(9, 132, 227, 0.3); }
.toki-mobile .toki-sync-group button { width: 100%; margin: 0; }
.toki-mobile .toki-card { padding: 12px; border-radius: 12px; }
.toki-mobile .toki-thumb { width: 75px; height: 100px; }
.toki-mobile .toki-title { font-size: 16px; line-height: 1.4; }
.toki-mobile .toki-actions { display: flex !important; gap: 8px !important; align-items: stretch !important; margin-top: 12px; }
.toki-mobile .toki-actions .toki-btn { padding: 14px 0 !important; border-radius: 10px !important; font-size: 12px !important; height: 52px !important; display: flex !important; align-items: center !important; justify-content: center !important; margin: 0 !important; }
.toki-mobile .toki-actions .toki-btn-main, .toki-mobile .toki-actions .toki-btn-sub { flex: 1 !important; }
.toki-mobile .toki-actions .toki-btn-del { flex: 0 0 75px !important; background: var(--toki-delete-bg) !important; border: 1px solid var(--toki-border) !important; color: var(--toki-text-sub) !important; font-weight: bold; }
.toki-mobile .toki-dropdown-menu { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 90%; max-width: 420px; max-height: 85vh; grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 24px; border-radius: 24px; z-index: 2000000; box-shadow: 0 0 0 9999px rgba(0,0,0,0.75), 0 25px 50px rgba(0,0,0,0.5); overflow-y: auto; }
.toki-mobile .toki-genre-item { padding: 14px 8px; font-size: 14px; border-radius: 10px; }
.toki-mobile-close { grid-column: 1 / -1 !important; margin-top: 15px !important; background: #2d3436 !important; color: white !important; padding: 16px !important; border-radius: 12px !important; text-align: center !important; font-weight: 800 !important; font-size: 15px !important; box-shadow: 0 4px 10px rgba(0,0,0,0.2) !important; cursor: pointer; }
/* [Custom Sort Dropdown] */
.toki-sort-item { padding: 10px 15px; font-size: 13px; font-weight: 700; cursor: pointer; color: var(--toki-text-sub); border-radius: 6px; transition: 0.2s; }
.toki-sort-item:hover { background: var(--toki-bg-sub); color: #0984e3; }
.toki-sort-item.active { background: #0984e3; color: white; }
.toki-icon-blue { display: none; } /* Hide the custom text icons as we revert to emojis */
.toki-title { display: flex; align-items: center; gap: 6px; word-break: keep-all; line-height: 1.4; }
.toki-title .title-text { flex: 1; }
/* Search Bar Styles */
.toki-search-wrap {
position: relative;
display: flex;
align-items: center;
background: var(--toki-bg-input);
border: 1px solid var(--toki-filter-border);
border-radius: 6px;
padding: 0 10px;
height: 34px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.toki-search-wrap:focus-within {
border-color: #0984e3;
box-shadow: 0 0 0 3px rgba(9, 132, 227, 0.15);
}
.toki-search-icon {
font-size: 13px;
color: var(--toki-text-muted);
margin-right: 8px;
user-select: none;
}
#toki-search-input {
border: none;
background: transparent;
color: var(--toki-text);
font-size: 13px;
font-weight: 600;
outline: none;
width: 180px;
padding: 0;
height: 100%;
}
#toki-search-input::placeholder {
color: var(--toki-text-muted);
font-weight: 500;
}
.toki-search-clear {
border: none;
background: transparent;
color: var(--toki-text-muted);
font-size: 12px;
font-weight: bold;
cursor: pointer;
padding: 0 4px;
margin-left: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s;
}
.toki-search-clear:hover {
color: #ff4757;
}
/* Mobile Search Bar Overrides */
.toki-mobile .toki-search-wrap {
height: 44px;
border-radius: 8px;
margin-bottom: 8px;
width: 100%;
box-sizing: border-box;
}
.toki-mobile #toki-search-input {
font-size: 14px;
width: 100%;
}
.toki-mobile .toki-search-icon {
font-size: 15px;
}
.toki-mobile .toki-search-clear {
font-size: 14px;
padding: 0 8px;
}
/* Custom Thumbnail Bookmark Button Overrides */
.my-local-fav-heart {
display: flex !important;
align-items: center !important;
justify-content: center !important;
background: rgba(0, 0, 0, 0.4) !important;
border-radius: 50% !important;
width: 38px !important;
height: 38px !important;
padding: 0 !important;
transition: background-color 0.2s, transform 0.2s !important;
z-index: 10 !important;
}
.my-local-fav-heart:hover {
background: rgba(0, 0, 0, 0.6) !important;
transform: scale(1.1) !important;
}
.my-local-fav-heart svg {
transition: transform 0.2s, fill 0.2s, stroke 0.2s !important;
}
.my-local-fav-heart:active svg {
transform: scale(0.8) !important;
}
/* Premium Category Tabs - Bubble Button Style */
.toki-category-tabs {
display: flex;
gap: 12px;
margin-bottom: 20px;
padding: 6px;
background: var(--toki-bg-sub);
border-radius: 12px;
border: 1px solid var(--toki-border);
}
.toki-tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 16px;
border: none;
background: transparent;
color: var(--toki-text-sub);
font-size: 14.5px;
font-weight: 800;
cursor: pointer;
border-radius: 9px;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.toki-tab-btn:hover {
background: var(--toki-bg);
color: var(--toki-text);
transform: translateY(-1px);
}
.toki-tab-btn.active {
background: rgba(9, 132, 227, 0.12);
color: #0984e3 !important;
box-shadow: 0 4px 12px rgba(9, 132, 227, 0.2);
border: 1px solid rgba(9, 132, 227, 0.4);
}
.tab-badge {
display: none;
font-size: 10px;
font-weight: 900;
padding: 2px 6px;
border-radius: 10px;
background: var(--toki-border);
color: var(--toki-text-muted);
}
.tab-badge.has-up {
display: inline-flex;
align-items: center;
justify-content: center;
background: #ff4757;
color: white !important;
font-size: 10.5px;
border-radius: 10px;
box-shadow: 0 2px 6px rgba(255, 71, 87, 0.4);
animation: pulseUP 2s infinite;
}
`;
document.head.appendChild(style);
// ==========================================
// Custom UI and Utilities
// ==========================================
const TokiUI = {
toast: function(msg, type = 'default') {
let container = document.getElementById('toki-toast-container');
if (!container) { container = document.createElement('div'); container.id = 'toki-toast-container'; document.body.appendChild(container); }
const toast = document.createElement('div'); toast.className = `toki-toast ${type}`; toast.innerText = msg;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => toast.remove(), 200);
}, 2500);
},
confirm: function(title, desc = "") {
return new Promise(resolve => {
const backdrop = document.createElement('div'); backdrop.className = 'toki-modal-backdrop';
backdrop.innerHTML = `<div class="toki-modal"><div class="toki-modal-title">${title}</div>${desc ? `<div class="toki-modal-desc">${desc}</div>` : ''}<div class="toki-modal-btns"><button class="toki-modal-btn cancel" id="toki-modal-cancel">취소</button><button class="toki-modal-btn confirm" id="toki-modal-ok">확인</button></div></div>`;
document.body.appendChild(backdrop);
document.getElementById('toki-modal-ok').onclick = () => { backdrop.remove(); resolve(true); };
document.getElementById('toki-modal-cancel').onclick = () => { backdrop.remove(); resolve(false); };
});
},
importDialog: function() {
return new Promise(resolve => {
const backdrop = document.createElement('div'); backdrop.className = 'toki-modal-backdrop';
backdrop.innerHTML = `<div class="toki-modal" style="width:360px;"><div class="toki-modal-title">가져오기 설정</div><div class="toki-modal-desc"><b>병합</b>: 기존 북마크를 유지하며 합침<br><b>덮어쓰기</b>: 기존 북마크를 모두 삭제함</div><div class="toki-modal-btns" style="flex-direction:column;"><button class="toki-modal-btn primary" id="toki-import-merge">기존 데이터에 병합</button><button class="toki-modal-btn confirm" id="toki-import-overwrite">기존 데이터 덮어쓰기</button><button class="toki-modal-btn cancel" id="toki-import-cancel">취소</button></div></div>`;
document.body.appendChild(backdrop);
document.getElementById('toki-import-merge').onclick = () => { backdrop.remove(); resolve('merge'); };
document.getElementById('toki-import-overwrite').onclick = () => { backdrop.remove(); resolve('overwrite'); };
document.getElementById('toki-import-cancel').onclick = () => { backdrop.remove(); resolve(null); };
});
}
};
function getBookmarks() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return TokiUtils.ensureV10Structure(raw ? JSON.parse(raw) : null);
} catch (e) {
return { manhwa: {}, webtoon: {}, novel: {} };
}
}
function saveBookmarks(data, opts) {
const newStructured = TokiUtils.ensureV10Structure(data);
const oldRaw = localStorage.getItem(STORAGE_KEY);
let hasCoreChanges = false;
if (oldRaw && !(opts && opts.silent)) {
try {
const oldStructured = TokiUtils.ensureV10Structure(JSON.parse(oldRaw));
const categories = ['manhwa', 'webtoon', 'novel'];
for (const cat of categories) {
const oldCat = oldStructured[cat] || {};
const newCat = newStructured[cat] || {};
const oldKeys = Object.keys(oldCat);
const newKeys = Object.keys(newCat);
// 1. Number of items changed (addition/deletion)
if (oldKeys.length !== newKeys.length) {
hasCoreChanges = true;
break;
}
// 2. Core field diff check
for (const id of newKeys) {
if (!oldCat[id]) {
hasCoreChanges = true;
break;
}
const oldItem = oldCat[id];
const newItem = newCat[id];
if (
oldItem.title !== newItem.title ||
oldItem.author !== newItem.author ||
oldItem.status !== newItem.status ||
oldItem.lastReadEp !== newItem.lastReadEp ||
oldItem.lastReadEpLink !== newItem.lastReadEpLink ||
oldItem.latestEp !== newItem.latestEp ||
oldItem.latestEpLink !== newItem.latestEpLink ||
oldItem.nextEp !== newItem.nextEp ||
oldItem.nextEpLink !== newItem.nextEpLink ||
oldItem.url !== newItem.url
) {
hasCoreChanges = true;
break;
}
}
if (hasCoreChanges) break;
}
} catch (diffErr) {
console.error('[saveBookmarks] Diff calculation failed:', diffErr);
hasCoreChanges = true; // Fallback to safety mark on comparison failure
}
} else if (!oldRaw) {
hasCoreChanges = true;
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(newStructured));
// Only mark dirty and trigger push/sync when real contents change
if (hasCoreChanges && !(opts && opts.silent)) {
const dc = parseInt(localStorage.getItem(DIRTY_KEY) || '0');
localStorage.setItem(DIRTY_KEY, (dc + 1).toString());
localStorage.setItem('toki_unpushed_changes', 'true');
if (typeof GistSync !== 'undefined' && GistSync.isFallbackMode()) {
localStorage.setItem('toki_offline_dirty', 'true');
}
}
}
function getSortPref() { return localStorage.getItem(PREF_KEY) || 'siteUpdateDesc'; }
// [TokiCrypt] Obfuscation envelope for secure storage protection
const TokiCrypt = {
encrypt: function(text) {
if (!text) return '';
try { return btoa(text); } catch(e) { return ''; }
},
decrypt: function(encoded) {
if (!encoded) return '';
try {
const decoded = atob(encoded);
if (/[^\x20-\x7E]/.test(decoded)) return ''; // Return empty string if old method is corrupted (forces re-login)
return decoded;
} catch(e) { return ''; }
}
};
const GistSync = {
getToken: () => TokiCrypt.decrypt(localStorage.getItem('toki_gh_token')),
getGistId: () => localStorage.getItem('toki_gist_id'),
getMode: () => localStorage.getItem('toki_sync_mode') === 'true',
setCredentials: function(token, gistId) {
localStorage.setItem('toki_gh_token', TokiCrypt.encrypt(token));
if(gistId) localStorage.setItem('toki_gist_id', gistId);
// Clear existing unauthenticated/stale Rate Limit cache when setting new credentials (prevents 98/100 bug)
localStorage.removeItem('toki_rate_limit_remaining');
localStorage.removeItem('toki_rate_limit_reset');
localStorage.removeItem('toki_rate_limit_limit');
},
enable: () => localStorage.setItem('toki_sync_mode', 'true'),
disable: () => {
localStorage.removeItem('toki_sync_mode');
localStorage.removeItem('toki_gh_token');
localStorage.removeItem('toki_gist_id');
localStorage.removeItem('toki_sync_fallback');
localStorage.removeItem('toki_offline_dirty');
localStorage.removeItem('toki_rate_limit_remaining');
localStorage.removeItem('toki_rate_limit_reset');
localStorage.removeItem('toki_rate_limit_limit');
},
headers: function() {
return {
'Authorization': `Bearer ${this.getToken()}`,
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
'X-GitHub-Api-Version': '2022-11-28'
};
},
// Rate Limit Tracer Utility
updateRateLimit: function(headersStr) {
if (!headersStr) return;
const headers = {};
headersStr.split(/[\r\n]+/).forEach(line => {
const parts = line.split(':');
if (parts.length >= 2) {
const key = parts[0].trim().toLowerCase();
const val = parts.slice(1).join(':').trim();
headers[key] = val;
}
});
const remaining = headers['x-ratelimit-remaining'];
const reset = headers['x-ratelimit-reset'];
const limit = headers['x-ratelimit-limit'];
// Safety Guard: If sync mode is active but limit is unauthenticated level (<= 100),
// ignore it as it is an unauthenticated rate limit caused by invalid token (e.g. 401 Unauthorized)
if (limit !== undefined) {
const limitVal = parseInt(limit);
if (this.getMode() && this.getToken() && limitVal <= 100) {
return;
}
localStorage.setItem('toki_rate_limit_limit', limit);
}
if (remaining !== undefined) localStorage.setItem('toki_rate_limit_remaining', remaining);
if (reset !== undefined) localStorage.setItem('toki_rate_limit_reset', reset);
// Automatically enter temporary local mode if Remaining is 0
if (remaining === '0') {
this.enterOfflineFallback(true);
}
},
getRateLimit: function() {
const rawRemaining = localStorage.getItem('toki_rate_limit_remaining');
const rawReset = localStorage.getItem('toki_rate_limit_reset');
const rawLimit = localStorage.getItem('toki_rate_limit_limit');
return {
remaining: rawRemaining ? parseInt(rawRemaining) : -1,
reset: rawReset ? parseInt(rawReset) * 1000 : 0,
limit: rawLimit ? parseInt(rawLimit) : -1
};
},
isFallbackMode: () => localStorage.getItem('toki_sync_fallback') === 'true',
enterOfflineFallback: function(isRateLimit = false) {
localStorage.setItem('toki_sync_fallback', 'true');
if (isRateLimit) {
console.warn('[GistSync] Gist API Rate Limit exceeded. Dynamic offline fallback engaged.');
} else {
console.warn('[GistSync] Server/Network failure detected. Temporary offline fallback engaged.');
}
},
exitOfflineFallback: function() {
localStorage.removeItem('toki_sync_fallback');
console.log('[GistSync] Restored to online sync mode.');
},
checkAndRecoverSync: async function() {
if (!this.getMode() || !this.getToken()) return;
const rate = this.getRateLimit();
const now = Date.now();
if (this.isFallbackMode()) {
if (now >= rate.reset || rate.remaining > 0) {
this.exitOfflineFallback();
if (localStorage.getItem('toki_offline_dirty') === 'true') {
console.log('[GistSync] Offline local changes detected on recovery. Invoking smart push reconciliation.');
localStorage.removeItem('toki_offline_dirty');
await this.push(false);
} else {
await this.pull();
}
}
}
},
// GM_xmlhttpRequest Wrapper for CSP Bypass
request: function(opts) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest === 'undefined') {
return reject(new Error('GM_xmlhttpRequest is not available. Please check @grant.'));
}
GM_xmlhttpRequest({
method: opts.method || 'GET',
url: opts.url,
headers: this.headers(),
data: opts.data ? JSON.stringify(opts.data) : null,
onload: (res) => {
if (res.responseHeaders) {
GistSync.updateRateLimit(res.responseHeaders);
}
if (res.status >= 200 && res.status < 300) {
try { resolve(JSON.parse(res.responseText)); }
catch(e) { reject(new Error('Invalid JSON response from Gist API')); }
} else {
if (res.status === 403 || res.status === 429) {
GistSync.enterOfflineFallback(true);
} else if (res.status >= 500) {
GistSync.enterOfflineFallback(false);
}
reject({ status: res.status, message: res.statusText || 'API Error', responseHeaders: res.responseHeaders });
}
},
onerror: (err) => {
GistSync.enterOfflineFallback(false);
reject(err);
}
});
});
},
pull: async function(options = { forceOverwrite: false }) {
if (!this.getMode() || !this.getToken()) return false;
// Self-healing check
await this.checkAndRecoverSync();
// Abort network request if in fallback mode
const rate = this.getRateLimit();
if (this.isFallbackMode() && Date.now() < rate.reset) {
console.warn('[GistSync] Pull aborted: Switch to Offline Fallback is active (Rate Limit).');
return false;
}
try {
let gistId = this.getGistId();
if (!gistId) {
const gists = await this.request({ url: 'https://api.github.com/gists' });
const found = gists.find(g => g.files['toki_bookmarks_sync.json']);
if (found) {
gistId = found.id;
this.setCredentials(this.getToken(), gistId);
} else {
const newGist = await this.request({
method: 'POST',
url: 'https://api.github.com/gists',
data: { description: "Newtoki Bookmark Sync", public: false, files: { 'toki_bookmarks_sync.json': { content: JSON.stringify(getBookmarks()) } } }
});
this.setCredentials(this.getToken(), newGist.id);
localStorage.setItem(REMOTE_AT_KEY, newGist.updated_at);
return true;
}
}
const data = await this.request({ url: `https://api.github.com/gists/${gistId}` });
const file = data.files['toki_bookmarks_sync.json'];
if (!file) throw new Error('File not found in Gist');
let content = file.content;
if (file.truncated || !content) {
content = await this.request({ url: `${file.raw_url}?cb=${Date.now()}` });
}
let remoteData = {};
try {
remoteData = TokiUtils.ensureV10Structure(JSON.parse(content || '{}'));
if (!TokiSafetyGuard.validateSchema(remoteData)) {
throw new Error('Gist data does not match the valid bookmarks schema');
}
} catch(parseErr) {
console.error('[GistSync] Broken JSON parsing or validation failed:', parseErr);
if (typeof TokiUI !== 'undefined') {
TokiUI.toast('❌ 서버 동기화 실패: 원격 데이터 구조가 올바르지 않습니다.', 'error');
}
throw parseErr;
}
const remoteUpdatedAt = data.updated_at;
const localData = getBookmarks();
const localNovelCount = Object.keys(localData.novel || {}).length;
const localManhwaCount = Object.keys(localData.manhwa || {}).length;
const localWebtoonCount = Object.keys(localData.webtoon || {}).length;
const isLocalEmpty = (localNovelCount + localManhwaCount + localWebtoonCount) === 0;
let merged = {
manhwa: { ...localData.manhwa },
webtoon: { ...localData.webtoon },
novel: { ...localData.novel }
};
let hasChanges = false;
const lastSyncAt = parseInt(localStorage.getItem(SYNC_AT_KEY) || '0');
const categories = ['manhwa', 'webtoon', 'novel'];
for (const cat of categories) {
const remoteCatData = remoteData[cat] || {};
const localCatData = localData[cat] || {};
// 1. Process remote updates and additions
for (const [key, remoteItem] of Object.entries(remoteCatData)) {
const localItem = localCatData[key];
if (!localItem) {
const remoteTime = TokiUtils.getEffectiveTime(remoteItem);
// CRITICAL FIX: If forceOverwrite is enabled or local database is empty, bypass timestamp check!
if (!options.forceOverwrite && isLocalEmpty === false && lastSyncAt > 0 && remoteTime <= lastSyncAt) {
hasChanges = true;
} else {
merged[cat][key] = remoteItem;
hasChanges = true;
}
} else {
if (options.forceOverwrite) {
merged[cat][key] = remoteItem;
hasChanges = true;
} else {
const localReadTime = localItem.lastReadAt || 0;
const remoteReadTime = remoteItem.lastReadAt || 0;
const localUpdateTime = Math.max(localItem.exactUpdatedAt || 0, localItem.parsedUpdatedAt || 0, localItem.latestUpdatedAt || 0, localItem.addedAt || 0);
const remoteUpdateTime = Math.max(remoteItem.exactUpdatedAt || 0, remoteItem.parsedUpdatedAt || 0, remoteItem.latestUpdatedAt || 0, remoteItem.addedAt || 0);
let itemChanged = false;
const mergedItem = { ...localItem };
if (remoteReadTime > localReadTime) {
mergedItem.lastReadEp = remoteItem.lastReadEp;
mergedItem.lastReadEpLink = remoteItem.lastReadEpLink;
mergedItem.lastReadAt = remoteItem.lastReadAt;
mergedItem.nextEp = remoteItem.nextEp;
mergedItem.nextEpLink = remoteItem.nextEpLink;
itemChanged = true;
}
if (remoteUpdateTime > localUpdateTime) {
mergedItem.latestEp = remoteItem.latestEp;
mergedItem.latestEpLink = remoteItem.latestEpLink;
mergedItem.latestDate = remoteItem.latestDate;
mergedItem.exactUpdatedAt = remoteItem.exactUpdatedAt;
mergedItem.parsedUpdatedAt = remoteItem.parsedUpdatedAt;
mergedItem.latestUpdatedAt = remoteItem.latestUpdatedAt;
mergedItem.status = remoteItem.status || localItem.status;
mergedItem.genres = remoteItem.genres || localItem.genres;
mergedItem.author = remoteItem.author || localItem.author;
mergedItem.thumb = remoteItem.thumb || localItem.thumb;
itemChanged = true;
}
if (itemChanged) {
merged[cat][key] = mergedItem;
hasChanges = true;
}
}
}
}
// 2. Process deletions from remote (LWW Deletion Sync)
// Skip remote deletions if forceOverwrite is active or local database is empty
if (!options.forceOverwrite && !isLocalEmpty) {
for (const key of Object.keys(localCatData)) {
if (!remoteCatData[key]) {
const localItem = localCatData[key];
const addedTime = localItem.addedAt || 0;
if (lastSyncAt > 0 && addedTime < lastSyncAt) {
delete merged[cat][key];
hasChanges = true;
}
}
}
}
}
if (hasChanges || options.forceOverwrite) saveBookmarks(merged, { silent: true });
localStorage.setItem(REMOTE_AT_KEY, remoteUpdatedAt);
if (localStorage.getItem('toki_unpushed_changes') !== 'true') {
localStorage.setItem(SYNC_AT_KEY, Date.now().toString());
}
return true;
} catch(e) { console.error('Gist Pull Error:', e); return false; }
},
checkNeedPull: async function() {
if (!this.getMode() || !this.getToken() || !this.getGistId()) return false;
// Abort checking if in offline fallback
const rate = this.getRateLimit();
if (this.isFallbackMode() && Date.now() < rate.reset) {
return false;
}
try {
// Light metadata check via Gist updated_at timestamp
const data = await this.request({ url: `https://api.github.com/gists/${this.getGistId()}` });
const remoteUpdatedAt = data.updated_at;
const lastRemoteAt = localStorage.getItem(REMOTE_AT_KEY);
// Trigger pull if remote version is newer than last known remote sync timestamp
return remoteUpdatedAt !== lastRemoteAt;
} catch (e) {
console.error('[GistSync] checkNeedPull Network/API failure:', e);
return false;
}
},
push: function(force = false) {
const self = this;
if (!self.getMode() || !self.getToken()) return;
// Abort pushing if in offline fallback
const rate = self.getRateLimit();
if (self.isFallbackMode() && Date.now() < rate.reset) {
const diff = rate.reset - Date.now();
const mins = Math.floor(diff / 60000);
const secs = Math.floor((diff % 60000) / 1000);
if (force) {
TokiUI.toast(`⚠️ API 할당량 초과: 대기 중 (${mins}분 ${secs}초 남음)`, 'error');
}
return;
}
if (pushDebounceTimer) clearTimeout(pushDebounceTimer);
const executePush = async (isManual = false) => {
if (tokiState.isPushing) {
pushDebounceTimer = setTimeout(() => self.push(false), 5000);
return;
}
tokiState.isPushing = true;
try {
let remoteBookmarkCount = 0;
let hasFetchedRemote = false;
if (!self.getGistId()) {
const success = await self.pull();
if (!success) {
if (isManual) TokiUI.toast('❌ Gist를 찾거나 생성할 수 없습니다.', 'error');
return;
}
} else {
// [Optimization] Run pull first only when new remote changes are found via checkNeedPull, significantly reducing API usage
const needPull = await self.checkNeedPull();
if (needPull) {
await self.pull();
}
// Fetch remote data to check bookmark count for Write-Zero protection
try {
const data = await self.request({ url: `https://api.github.com/gists/${self.getGistId()}` });
const file = data.files['toki_bookmarks_sync.json'];
if (file) {
let content = file.content;
if (file.truncated || !content) {
content = await self.request({ url: `${file.raw_url}?cb=${Date.now()}` });
}
const parsed = JSON.parse(content || '{}');
const remoteNovel = Object.keys(parsed.novel || {}).length;
const remoteManhwa = Object.keys(parsed.manhwa || {}).length;
const remoteWebtoon = Object.keys(parsed.webtoon || {}).length;
remoteBookmarkCount = remoteNovel + remoteManhwa + remoteWebtoon;
hasFetchedRemote = true;
}
} catch (remoteErr) {
console.error('[GistSync] Write-Zero checking: remote fetch failed:', remoteErr);
}
}
const localData = getBookmarks();
const localNovel = Object.keys(localData.novel || {}).length;
const localManhwa = Object.keys(localData.manhwa || {}).length;
const localWebtoon = Object.keys(localData.webtoon || {}).length;
const localBookmarkCount = localNovel + localManhwa + localWebtoon;
// WRITE-ZERO SAFETY VALVE: Prevent wiping remote bookmarks if local is empty
if (localBookmarkCount === 0 && hasFetchedRemote && remoteBookmarkCount > 0) {
if (isManual) {
const userConfirmed = await TokiUI.confirm(
'⚠️ 빈 데이터 업로드 경고',
`현재 로컬(기기)의 북마크가 0개이지만, 백업 서버(Gist)에는 ${remoteBookmarkCount}개의 북마크가 저장되어 있습니다.\n\n정말로 서버의 백업 데이터를 모두 삭제하고 초기화하시겠습니까?`
);
if (!userConfirmed) {
TokiUI.toast('❌ 업로드가 취소되었습니다. 서버 데이터 보호됨.', 'error');
return;
}
} else {
console.warn('[GistSync] Background push aborted: Local bookmarks are 0, but remote contains', remoteBookmarkCount, 'items. Safety guard active.');
return;
}
}
if (isManual) TokiUI.toast('☁️ 서버에 북마크를 업로드 중...');
const data = await self.request({
method: 'PATCH',
url: `https://api.github.com/gists/${self.getGistId()}`,
data: { files: { 'toki_bookmarks_sync.json': { content: JSON.stringify(localData) } } }
});
localStorage.setItem(REMOTE_AT_KEY, data.updated_at);
localStorage.setItem(DIRTY_KEY, '0');
localStorage.setItem('toki_last_push_time', Date.now().toString());
localStorage.setItem(SYNC_AT_KEY, Date.now().toString());
// Clear unpushed changes marker since push succeeded
localStorage.removeItem('toki_unpushed_changes');
if (isManual) TokiUI.toast('✅ 업로드 완료!', 'success');
} catch(e) {
console.error('[TokiMark] Gist Push Error:', e);
if (isManual) {
if (e.status === 403 || e.status === 429) {
const r = self.getRateLimit();
const diff = r.reset - Date.now();
const mins = Math.max(0, Math.floor(diff / 60000));
const secs = Math.max(0, Math.floor((diff % 60000) / 1000));
TokiUI.toast(`⚠️ 할당량 소진: ${mins}분 ${secs}초 후 다시 시도하세요.`, 'error');
}
else TokiUI.toast('❌ 업로드 실패: 토큰 또는 권한 확인', 'error');
}
} finally {
tokiState.isPushing = false;
}
};
const now = Date.now();
const lastPush = parseInt(localStorage.getItem('toki_last_push_time') || '0');
const MIN_PUSH_INTERVAL = 10000;
if (force) {
executePush(true);
} else {
const remaining = MIN_PUSH_INTERVAL - (now - lastPush);
if (remaining <= 0) {
pushDebounceTimer = setTimeout(() => executePush(false), 1000);
} else {
pushDebounceTimer = setTimeout(() => executePush(false), Math.max(3000, remaining));
}
}
},
showSettingsModal: function() {
// Remove existing modal (prevent duplication)
const oldModal = document.querySelector('.toki-sync-modal-bg');
if (oldModal) oldModal.remove();
const isSync = this.getMode();
const bg = document.createElement('div');
bg.className = 'toki-sync-modal-bg';
bg.innerHTML = `
<div class="toki-sync-modal-box">
<div class="toki-sync-title">
☁️ 웹 동기화
<span class="status-toggle ${isSync ? 'on' : ''}" id="toki-sync-toggle" title="클릭하여 연결 상태 전환">
${isSync ? '연결됨' : '미연결'}
</span>
</div>
<div class="toki-sync-desc">
GitHub Gist를 활용해 기기간 북마크를 공유합니다.
</div>
<details style="margin-bottom:15px; font-size:12px; line-height:1.6; cursor:pointer;">
<summary style="font-weight:bold; margin-bottom:4px; color:var(--toki-text-sub);">📖 토큰 발급 방법 (Classic)</summary>
<ol style="padding-left:18px; margin:8px 0; color:var(--toki-text-sub); font-size:11px;">
<li>GitHub Settings → Developer settings</li>
<li>Personal access tokens → Tokens (classic)</li>
<li>Generate new token → <b>gist</b> 권한 체크 ✅</li>
</ol>
</details>
<input type="password" id="toki-sync-token" class="toki-sync-input" placeholder="GitHub Access Token (classic)" value="${this.getToken() || ''}">
<!-- Rate Limit / Offline Status Display -->
<div id="toki-sync-rate-status" style="margin-bottom:15px; padding:12px; background:var(--toki-bg-sub); border:1px solid var(--toki-border); border-radius:8px; font-size:12px; display:none; box-sizing:border-box; transition: all 0.3s ease;">
<div style="display:flex; justify-content:space-between; margin-bottom:6px; font-weight:bold; color:var(--toki-text-sub); white-space:nowrap; gap:8px;">
<span style="flex-shrink:0;">요청 제한 잔여:</span>
<span id="toki-rate-remaining-val" style="white-space:nowrap; text-align:right;">-</span>
</div>
<div style="display:flex; justify-content:space-between; font-weight:bold; color:var(--toki-text-sub); white-space:nowrap; gap:8px;">
<span style="flex-shrink:0;">제한 리셋 대기:</span>
<span id="toki-rate-reset-val" style="white-space:nowrap; text-align:right;">-</span>
</div>
</div>
<div style="margin-bottom:20px; display:flex; align-items:center; justify-content:space-between; font-size:13px; font-weight:bold; color:var(--toki-text-sub);">
<span>🔔 자동 스캔 알림 받기</span>
<label class="toki-pin-toggle" style="padding:4px 8px; border-radius:6px; font-size:12px; margin:0;">
<input type="checkbox" id="toki-auto-notice-toggle" ${localStorage.getItem('toki_auto_scan_notice') !== 'false' ? 'checked' : ''}> 활성화
</label>
</div>
<div class="toki-sync-btns">
<button class="toki-sync-btn cancel" id="toki-sync-close">닫기</button>
<button class="toki-sync-btn primary" id="toki-sync-pull" ${!isSync ? 'disabled' : ''}>지금 풀</button>
<button class="toki-sync-btn success" id="toki-sync-push" ${!isSync ? 'disabled' : ''}>지금 푸시</button>
</div>
</div>
`;
document.body.appendChild(bg);
let rateTimer = null;
const close = () => {
if (rateTimer) clearInterval(rateTimer);
bg.remove();
};
bg.onclick = (e) => { if(e.target === bg) close(); };
document.getElementById('toki-sync-close').onclick = close;
const formatDuration = (ms) => {
const totalSecs = Math.max(0, Math.floor(ms / 1000));
const hrs = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = totalSecs % 60;
const pad = n => n.toString().padStart(2, '0');
if (hrs > 0) return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
return `${pad(mins)}:${pad(secs)}`;
};
const updateRateUI = () => {
const statusDiv = document.getElementById('toki-sync-rate-status');
const remSpan = document.getElementById('toki-rate-remaining-val');
const resetSpan = document.getElementById('toki-rate-reset-val');
const btnPull = document.getElementById('toki-sync-pull');
const btnPush = document.getElementById('toki-sync-push');
if (!statusDiv || !remSpan || !resetSpan) return;
const isSyncEnabled = this.getMode();
if (!isSyncEnabled || !this.getToken()) {
statusDiv.style.display = 'none';
return;
}
const rate = this.getRateLimit();
if (rate.remaining === -1) {
statusDiv.style.display = 'none';
return;
}
statusDiv.style.display = 'block';
remSpan.innerText = `${rate.remaining} / ${rate.limit}`;
const now = Date.now();
if (this.isFallbackMode() && now < rate.reset) {
statusDiv.style.borderLeft = '4px solid #ff4757';
const diff = rate.reset - now;
resetSpan.innerHTML = `<span style="color:#ff4757; font-weight:900;">⌛ ~ ${formatDuration(diff)}</span> (로컬 모드)`;
if (btnPull) btnPull.disabled = true;
if (btnPush) btnPush.disabled = true;
} else {
statusDiv.style.borderLeft = '4px solid #0984e3';
if (rate.reset > now) {
const diff = rate.reset - now;
resetSpan.innerHTML = `<span style="color:#2ed573; font-weight:900;">✅ 정상</span> (⌛ ~ ${formatDuration(diff)})`;
} else {
resetSpan.innerHTML = `<span style="color:#2ed573; font-weight:900;">✅ 충전 완료</span>`;
}
if (this.isFallbackMode()) {
this.checkAndRecoverSync();
}
if (btnPull) btnPull.disabled = false;
if (btnPush) btnPush.disabled = false;
}
};
updateRateUI();
rateTimer = setInterval(updateRateUI, 1000);
document.getElementById('toki-auto-notice-toggle').onchange = (e) => {
localStorage.setItem('toki_auto_scan_notice', e.target.checked ? 'true' : 'false');
TokiUI.toast(e.target.checked ? '🔔 자동 스캔 알림이 활성화되었습니다.' : '🔕 자동 스캔 알림이 비활성화되었습니다.', 'success');
};
document.getElementById('toki-sync-toggle').onclick = async () => {
const tokenInput = document.getElementById('toki-sync-token').value.trim();
if (this.getMode()) {
if (await TokiUI.confirm('⚠️ 동기화 해제', '연결을 해제하시겠습니까? 토큰 정보가 삭제됩니다.')) {
this.disable();
TokiUI.toast('동기화 비활성화됨');
close();
}
} else {
if (!tokenInput) return TokiUI.toast('토큰을 먼저 입력하세요.', 'error');
const btnToggle = document.getElementById('toki-sync-toggle');
btnToggle.innerText = '연결 중...';
// Temporary bind credentials for searching
this.setCredentials(tokenInput, null);
try {
// Check if Gist list contains a bookmark file
const gists = await this.request({ url: 'https://api.github.com/gists' });
const foundGist = gists.find(g => g.files['toki_bookmarks_sync.json']);
let remoteBookmarkCount = 0;
if (foundGist) {
const data = await this.request({ url: `https://api.github.com/gists/${foundGist.id}` });
const file = data.files['toki_bookmarks_sync.json'];
if (file) {
let content = file.content;
if (file.truncated || !content) {
content = await this.request({ url: file.raw_url });
}
const parsed = JSON.parse(content || '{}');
const remoteNovel = Object.keys(parsed.novel || {}).length;
const remoteManhwa = Object.keys(parsed.manhwa || {}).length;
const remoteWebtoon = Object.keys(parsed.webtoon || {}).length;
remoteBookmarkCount = remoteNovel + remoteManhwa + remoteWebtoon;
}
}
if (foundGist && remoteBookmarkCount > 0) {
// Prompt user for synchronization direction
const modeChoice = await TokiUI.importDialog();
if (modeChoice === 'merge') {
this.setCredentials(tokenInput, foundGist.id);
this.enable();
const pullSuccess = await this.pull({ forceOverwrite: false });
if (pullSuccess) {
this.push(true);
TokiUI.toast('웹 동기화 연결 성공 (데이터 병합 완료)!', 'success');
localStorage.setItem(DIRTY_KEY, '1');
if (typeof renderFavoritesList === 'function') renderFavoritesList();
close();
} else {
this.disable();
TokiUI.toast('❌ 데이터 병합 실패: 네트워크 확인', 'error');
btnToggle.innerText = '미연결';
}
} else if (modeChoice === 'overwrite') {
this.setCredentials(tokenInput, foundGist.id);
this.enable();
const pullSuccess = await this.pull({ forceOverwrite: true });
if (pullSuccess) {
TokiUI.toast('웹 동기화 연결 성공 (로컬 데이터 덮어쓰기 완료)!', 'success');
localStorage.setItem(DIRTY_KEY, '1');
if (typeof renderFavoritesList === 'function') renderFavoritesList();
close();
} else {
this.disable();
TokiUI.toast('❌ 데이터 오버라이트 실패: 네트워크 확인', 'error');
btnToggle.innerText = '미연결';
}
} else {
// Cancel and rollback credentials
this.disable();
btnToggle.innerText = '미연결';
TokiUI.toast('연동이 취소되었습니다.');
}
} else {
// Fresh integration or empty remote Gist
this.enable();
const success = await this.pull();
if (success) {
TokiUI.toast('웹 동기화 연결 성공!', 'success');
this.push(true);
localStorage.setItem(DIRTY_KEY, '1');
if (typeof renderFavoritesList === 'function') renderFavoritesList();
close();
} else {
this.disable();
TokiUI.toast('오류: 토큰 권한 또는 네트워크 확인', 'error');
btnToggle.innerText = '미연결';
}
}
} catch (err) {
console.error('[GistSync] Initial bind failed:', err);
this.disable();
TokiUI.toast('오류: 토큰 권한 또는 네트워크 확인', 'error');
btnToggle.innerText = '미연결';
}
}
};
document.getElementById('toki-sync-pull').onclick = async () => {
const token = document.getElementById('toki-sync-token').value.trim();
if(token) this.setCredentials(token, null);
TokiUI.toast('☁️ 서버에서 데이터를 가져오는 중...');
const success = await this.pull();
if (success) {
TokiUI.toast('✅ 다운로드 완료!', 'success');
if (typeof renderFavoritesList === 'function') renderFavoritesList();
} else {
TokiUI.toast('❌ 다운로드 실패', 'error');
}
};
document.getElementById('toki-sync-push').onclick = () => {
const token = document.getElementById('toki-sync-token').value.trim();
if(token) this.setCredentials(token, null);
this.push(true);
};
}
};
function saveSortPref(pref) { localStorage.setItem(PREF_KEY, pref); }
// SWR Dynamic Caching (Dynamic TTL)
function getTTL(m) {
let lastUpdated = m.exactUpdatedAt || m.parsedUpdatedAt || m.latestUpdatedAt || m.addedAt;
if (!lastUpdated) lastUpdated = Date.now(); // Fallback for NaN error prevention
const daysSinceUpdate = (Date.now() - lastUpdated) / (1000 * 60 * 60 * 24);
if (m.status === 'completed') {
if (daysSinceUpdate <= 90) return 7 * 24 * 60 * 60 * 1000;
if (daysSinceUpdate <= 365) return 30 * 24 * 60 * 60 * 1000;
return Infinity;
}
const isReading = m.lastReadEp != null;
if (isReading) {
if (daysSinceUpdate <= 1) return 1 * 60 * 60 * 1000;
if (daysSinceUpdate <= 7) return 6 * 60 * 60 * 1000;
return 24 * 60 * 60 * 1000;
} else {
if (daysSinceUpdate <= 7) return 12 * 60 * 60 * 1000;
return 24 * 60 * 60 * 1000;
}
}
// Extract Exact Timestamp inside JSON (availableAt > publishedAt+createdAt > createdAt)
// Next.js JSON inside HTML is escaped as \\\" -> regex matches both \\\\?"
function parseExactUpdatedAt(htmlText) {
try {
const Q = '[\\\\"]+'; // Flexibly support escaped or normal quotes
const reAvail = new RegExp('availableAt' + Q + ':' + Q + '(\\d{4}-[^"\\\\]+?)' + Q);
const rePub = new RegExp('publishedAt' + Q + ':' + Q + '([^"\\\\]+?)' + Q);
const reCrt = new RegExp('createdAt' + Q + ':' + Q + '([^"\\\\]+?)' + Q);
const availMatch = htmlText.match(reAvail);
const pubMatch = htmlText.match(rePub);
const crtMatch = htmlText.match(reCrt);
let bestDate = null;
if (availMatch) {
bestDate = new Date(availMatch[1]);
// Replace with createdAt time if hour and minute are 00:00
if (bestDate.getUTCHours() === 0 && bestDate.getUTCMinutes() === 0 && crtMatch) {
const crtDate = new Date(crtMatch[1]);
bestDate.setUTCHours(crtDate.getUTCHours(), crtDate.getUTCMinutes(), 0, 0);
}
return bestDate.getTime();
}
if (pubMatch && crtMatch) {
const pubDate = new Date(pubMatch[1]);
const crtDate = new Date(crtMatch[1]);
pubDate.setUTCHours(crtDate.getUTCHours(), crtDate.getUTCMinutes(), 0, 0);
return pubDate.getTime();
}
if (crtMatch) return new Date(crtMatch[1]).getTime();
} catch (e) {}
return null;
}
function parseStatus(doc) {
const statusEl = doc.querySelector('.hero-v2-badges .pill-status');
if (statusEl) {
return statusEl.classList.contains('completed') ? 'completed' : 'ongoing';
}
return 'ongoing';
}
function parseSiteDateToTimestamp(dateStr) {
if (!dateStr) return 0;
const now = Date.now();
if (dateStr.includes('초 전')) return now - parseInt(dateStr) * 1000;
if (dateStr.includes('분 전')) return now - parseInt(dateStr) * 60 * 1000;
if (dateStr.includes('시간 전')) return now - parseInt(dateStr) * 3600 * 1000;
if (dateStr.includes('일 전')) return now - parseInt(dateStr) * 86400 * 1000;
if (dateStr.includes('어제')) return now - 86400 * 1000;
const parts = dateStr.split(/[\.\-]/);
if (parts.length >= 2) {
const d = new Date();
if (parts.length === 3) {
let year = parseInt(parts[0]);
if (year < 100) year += 2000;
d.setFullYear(year, parseInt(parts[1]) - 1, parseInt(parts[2]));
} else {
d.setMonth(parseInt(parts[0]) - 1, parseInt(parts[1]));
}
return d.getTime();
}
return now;
}
function formatDateTime(ts) {
return TokiUtils.formatDateTime(ts);
}
function extractShortEp(text) {
if (!text) return "";
const specialMatch = text.match(/(프롤로그|에필로그|후기|최종화|마지막화)/);
if (specialMatch) return specialMatch[1];
const prefixMatch = text.match(/(외전|특별편|단편|번외|시즌\s*\d+)(?:\s*(\d+(?:[\.\-]\d+)?)\s*(화|권)?)?/);
if (prefixMatch) {
const prefix = prefixMatch[1].replace(/\s+/g, '');
if (prefixMatch[2]) {
const unit = prefixMatch[3] || '화';
return prefix + ' ' + prefixMatch[2] + unit;
}
return prefix;
}
const match = text.match(/(\d+(?:[\.\-]\d+)?)\s*(화|권)/);
if (match) return match[1] + match[2];
const lastWord = text.trim().split(' ').pop();
if (/^\d+(?:[\.\-]\d+)?$/.test(lastWord)) return lastWord + '화';
const fallbackText = text.split('-')[0].split('|')[0].trim();
return fallbackText.length > 20 ? fallbackText.substring(0, 20) + '...' : fallbackText;
}
const removeTokiLocalBookmark = async function(id, category, e) {
if(e && e.preventDefault) e.preventDefault();
const isConfirmed = await TokiUI.confirm('북마크 삭제', '해당 작품을 로컬 북마크에서 완전히 삭제합니다.');
if(isConfirmed) {
const marks = getBookmarks();
const cat = category || 'manhwa';
if (marks[cat] && marks[cat][id]) {
delete marks[cat][id];
saveBookmarks(marks);
if(typeof GistSync !== 'undefined') GistSync.push();
TokiUI.toast('작품이 삭제되었습니다.');
}
// [Fix] Surgical UI Update: Instead of removing the entire UI, just re-render to reflect changes
if(typeof renderFavoritesList === 'function') renderFavoritesList();
localStorage.setItem(DIRTY_KEY, '0');
}
};
function exportData() {
const data = localStorage.getItem(STORAGE_KEY) || '{}';
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `toki_bookmarks_${new Date().toISOString().slice(2, 10).replace(/-/g, '')}.json`;
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
TokiUI.toast('데이터가 백업되었습니다.', 'success');
}
function importData() {
const input = document.createElement('input'); input.type = 'file'; input.accept = '.json';
input.onchange = e => {
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const parsed = JSON.parse(event.target.result);
if (typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
const sanitizedData = TokiUtils.ensureV10Structure(parsed);
const now = Date.now();
const categories = ['manhwa', 'webtoon', 'novel'];
for (const cat of categories) {
for (const key of Object.keys(sanitizedData[cat])) {
const item = sanitizedData[cat][key];
if (!item.addedAt) item.addedAt = now;
if (!item.latestUpdatedAt) item.latestUpdatedAt = now;
}
}
const action = await TokiUI.importDialog();
if (!action) return;
if (action === 'overwrite') {
saveBookmarks(sanitizedData);
TokiUI.toast('데이터 덮어쓰기 완료', 'success');
} else if (action === 'merge') {
const local = getBookmarks();
const merged = {
manhwa: Object.assign({}, local.manhwa, sanitizedData.manhwa),
webtoon: Object.assign({}, local.webtoon, sanitizedData.webtoon),
novel: Object.assign({}, local.novel, sanitizedData.novel)
};
saveBookmarks(merged);
TokiUI.toast('데이터 병합 완료', 'success');
}
renderFavoritesList();
} catch(err) { TokiUI.toast('올바르지 않은 파일입니다.', 'error'); }
};
reader.readAsText(file);
};
input.click();
}
function isViewerPage() {
return /^\/(manhwa|webtoon|novel)\/[^/]+\/[^/?#]+/.test(location.pathname);
}
function isViewerUIApplied() {
const botActions = document.querySelector('.vw-bot-actions');
if (!botActions) return true;
const siteBookmarkBtn = Array.from(botActions.children).find(el =>
el.innerText.includes('책갈피') ||
el.getAttribute('aria-label')?.includes('책갈피') ||
el.title?.includes('책갈피')
);
if (siteBookmarkBtn) return false;
const favLink = botActions.querySelector(`#${ID_VIEWER_UI}`);
if (!favLink) return false;
const children = Array.from(botActions.children);
const prevBtn = children.find(el => el.innerText.includes('이전화') || el.getAttribute('aria-label')?.includes('이전화') || el.title?.includes('이전화'));
const listBtn = children.find(el => el.innerText.includes('목록') || el.getAttribute('aria-label')?.includes('목록') || el.title?.includes('목록'));
const nextBtn = children.find(el => el.innerText.includes('다음화') || el.getAttribute('aria-label')?.includes('다음화') || el.title?.includes('다음화'));
if (prevBtn && children.indexOf(prevBtn) > children.indexOf(favLink)) return false;
if (listBtn && children.indexOf(favLink) > children.indexOf(listBtn)) return false;
if (nextBtn && children.indexOf(listBtn) > children.indexOf(nextBtn)) return false;
return true;
}
// ==========================================
// 2. Viewer Tracking
// ==========================================
function injectViewerUI() {
if (!location.pathname.match(/^\/(manhwa|webtoon|novel)\/[^/]+\/[^/?#]+/)) return;
const match = location.pathname.match(/^\/(manhwa|webtoon|novel)\/([^/]+)\/([^/?#]+)$/);
if (match) {
const category = match[1];
const workId = match[2];
let marks = getBookmarks();
if (marks[category] && marks[category][workId]) {
const fallbackTitle = document.title.split('|')[0].trim();
const shortEp = extractShortEp(fallbackTitle);
marks[category][workId].lastReadEp = shortEp;
marks[category][workId].lastReadEpLink = location.pathname;
marks[category][workId].lastReadAt = Date.now();
const nextBtn = Array.from(document.querySelectorAll('a')).find(a =>
a.innerText.includes('다음화') || a.innerText.includes('다음 화') || a.id === 'goNextBtn' || a.className.includes('btn-next')
);
if (nextBtn && nextBtn.getAttribute('href') && nextBtn.getAttribute('href') !== '#') {
marks[category][workId].nextEpLink = nextBtn.getAttribute('href');
marks[category][workId].nextEp = "다음화";
} else {
marks[category][workId].nextEpLink = null;
marks[category][workId].nextEp = null;
}
saveBookmarks(marks);
if(typeof GistSync !== 'undefined') GistSync.push();
fetch(`/${category}/${workId}`).then(res => res.text()).then(text => {
const doc = new DOMParser().parseFromString(text, 'text/html');
const epList = Array.from(doc.querySelectorAll('.ep-list-v2 .ep-row-v2-link, .novel-eps li a'));
const currentPath = location.pathname;
let lastIdx = epList.findIndex(el => {
const href = el.getAttribute('href');
return href && href.includes(currentPath);
});
const getEpNode = (el) => {
if (category === 'novel') {
return el.querySelector('.ne-title') || el.querySelector('.ne-num') || el;
}
return el.querySelector('.ne-title, .ep-row-v2-title strong, .ep-row-v2-title, .ne-num') || el;
};
if (lastIdx === -1) {
lastIdx = epList.findIndex(el => {
const titleNode = getEpNode(el);
return extractShortEp(titleNode.textContent) === shortEp;
});
}
const updatedMarks = getBookmarks();
if(updatedMarks[category] && updatedMarks[category][workId]) {
if (lastIdx !== -1) {
const currNode = getEpNode(epList[lastIdx]);
updatedMarks[category][workId].lastReadEp = extractShortEp(currNode.textContent);
}
if (lastIdx > 0) {
const nextEl = epList[lastIdx - 1];
const nextNode = getEpNode(nextEl);
updatedMarks[category][workId].nextEp = extractShortEp(nextNode.textContent);
updatedMarks[category][workId].nextEpLink = nextEl.getAttribute('href');
} else if (lastIdx === 0) {
updatedMarks[category][workId].nextEp = null;
updatedMarks[category][workId].nextEpLink = null;
}
saveBookmarks(updatedMarks);
if(typeof GistSync !== 'undefined') GistSync.push();
}
}).catch(err => console.log("다음화 탐색 중단"));
}
// [Task 4] Novel bookmark button hijacking
const novelNav = document.querySelector('.ne-nav');
if (novelNav && category === 'novel') {
const originalBtn = novelNav.querySelector('.bookmark-btn');
if (originalBtn) {
originalBtn.style.display = 'none';
let customBtn = document.getElementById('toki-novel-bookmark-btn');
if (!customBtn) {
customBtn = document.createElement('button');
customBtn.id = 'toki-novel-bookmark-btn';
customBtn.type = 'button';
customBtn.className = 'btn btn--outline bookmark-btn-custom';
customBtn.style.borderColor = '#ff4757';
customBtn.style.color = '#ff4757';
customBtn.style.display = 'inline-flex';
customBtn.style.alignItems = 'center';
customBtn.style.gap = '6px';
customBtn.style.cursor = 'pointer';
const marks = getBookmarks();
if (!marks['novel']) marks['novel'] = {};
const novelMark = marks['novel'][workId];
const fallbackTitle = document.title.split('|')[0].trim();
const currentEpText = document.querySelector('.ne-h1')?.innerText.trim() || fallbackTitle;
const isCurrentEpMarked = novelMark && (novelMark.lastReadEp === extractShortEp(currentEpText) || novelMark.lastReadEpLink === location.pathname);
customBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="${isCurrentEpMarked ? '#ff4757' : 'none'}" stroke="#ff4757" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
<span>${isCurrentEpMarked ? '책갈피 완료' : '책갈피 저장'}</span>
`;
originalBtn.parentNode.insertBefore(customBtn, originalBtn.nextSibling);
customBtn.onclick = async () => {
const _marks = getBookmarks();
if (!_marks['novel']) _marks['novel'] = {};
const now = Date.now();
const _currentEpText = document.querySelector('.ne-h1')?.innerText.trim() || extractShortEp(document.title);
if (!_marks['novel'][workId]) {
const novelTitle = document.querySelector('.crumb strong')?.innerText.trim() || document.title.split('-')[0].trim();
_marks['novel'][workId] = {
id: workId,
title: novelTitle,
author: TokiUtils.extractAuthor(document.documentElement.innerHTML, document.title),
genres: "",
url: `/novel/${workId}`,
thumb: "",
latestEp: _currentEpText,
latestEpLink: location.pathname,
latestDate: TokiUtils.formatDateTime(now),
firstEpLink: `/novel/${workId}`,
lastReadEp: _currentEpText,
lastReadEpLink: location.pathname,
lastReadAt: now,
nextEp: null,
nextEpLink: null,
addedAt: now,
latestUpdatedAt: now,
status: 'ongoing'
};
TokiUI.toast('소설을 로컬 북마크에 추가하고 책갈피를 꽂았습니다!', 'success');
} else {
_marks['novel'][workId].lastReadEp = extractShortEp(_currentEpText);
_marks['novel'][workId].lastReadEpLink = location.pathname;
_marks['novel'][workId].lastReadAt = now;
TokiUI.toast('회차 책갈피 저장이 완료되었습니다.', 'success');
}
saveBookmarks(_marks);
if (typeof GistSync !== 'undefined') GistSync.push(); // Trigger push
// UI update
customBtn.querySelector('svg').setAttribute('fill', '#ff4757');
customBtn.querySelector('span').innerText = '책갈피 완료';
};
}
}
}
}
const botActions = document.querySelector('.vw-bot-actions');
if (botActions) {
// Find original bookmark button if exists
const siteBookmarkBtn = Array.from(botActions.children).find(el => el.innerText.includes('책갈피') || el.getAttribute('aria-label')?.includes('책갈피') || el.title?.includes('책갈피'));
// Create or get custom bookmark link
let favLink = botActions.querySelector(`#${ID_VIEWER_UI}`);
if (!favLink) {
const globalFavLink = document.getElementById(ID_VIEWER_UI);
if (globalFavLink && globalFavLink.isConnected) {
favLink = globalFavLink;
} else {
favLink = document.createElement('a');
favLink.className = 'vw-act';
favLink.id = ID_VIEWER_UI;
favLink.href = '/favorites';
favLink.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon></svg><span>북마크</span>`;
}
}
if (siteBookmarkBtn) {
botActions.replaceChild(favLink, siteBookmarkBtn);
} else if (!favLink.parentNode) {
const listBtn = Array.from(botActions.children).find(el => el.innerText.includes('목록') || el.getAttribute('aria-label')?.includes('목록') || el.title?.includes('목록'));
if (listBtn) botActions.insertBefore(favLink, listBtn);
else botActions.appendChild(favLink);
}
}
}
// ==========================================
// 3. Title Detail Page (Bookmark Button & Main CTA Hijack)
// ==========================================
function injectManhwaButton() {
if (!/^\/(manhwa|webtoon|novel)\/[^/]+$/i.test(location.pathname)) return;
const pathParts = location.pathname.split('/');
if (pathParts.length !== 3) return;
const category = pathParts[1];
const workId = pathParts[2];
const isNovel = category === 'novel';
const actionBoxSelector = isNovel ? '.nd-actions' : '.hero-v2-actions';
const thumbContainerSelector = isNovel ? '.nd-thumb' : '.hero-v2-thumb';
// 1. Inject main CTA button
let localBtn = document.getElementById(ID_MANHWA_BTN);
const actionBox = document.querySelector(actionBoxSelector);
if (actionBox && !localBtn) {
const originalFav = actionBox.querySelector('.cta-fav, .nd-fav');
if (originalFav && originalFav.style.display !== 'none') originalFav.style.display = 'none';
const mainCtaBtn = actionBox.querySelector('.cta-primary, .nd-primary, .btn-primary');
const marks = getBookmarks();
if (mainCtaBtn && marks[category] && marks[category][workId]) {
const item = marks[category][workId];
if (item.nextEpLink) {
mainCtaBtn.setAttribute('href', item.nextEpLink);
const epText = item.nextEp === '다음화' ? '다음화' : item.nextEp;
mainCtaBtn.innerHTML = `<span class="ic">▶</span><span class="t">이어보기</span><span class="s">${epText}</span>`;
} else if (item.lastReadEpLink) {
mainCtaBtn.setAttribute('href', item.lastReadEpLink);
mainCtaBtn.innerHTML = `<span class="ic">▶</span><span class="t">이어보기</span><span class="s">${item.lastReadEp}</span>`;
}
}
localBtn = document.createElement('button');
localBtn.id = ID_MANHWA_BTN;
localBtn.type = 'button';
localBtn.className = 'cta cta-fav';
localBtn.style.border = isNovel ? '1px solid var(--toki-border)' : 'none';
localBtn.style.cursor = 'pointer';
actionBox.appendChild(localBtn);
}
// 2. Inject thumbnail button
const ID_THUMB_BTN = 'my-local-thumb-btn';
let thumbBtn = document.getElementById(ID_THUMB_BTN);
const thumbContainer = document.querySelector(thumbContainerSelector);
if (thumbContainer && !thumbBtn) {
const originalFavHeart = thumbContainer.querySelector('.fav-heart, .nd-fav-heart');
if (originalFavHeart) originalFavHeart.style.display = 'none';
thumbBtn = document.createElement('button');
thumbBtn.id = ID_THUMB_BTN;
thumbBtn.type = 'button';
// Apply same circular translucent button class as manhwa/webtoon
thumbBtn.className = 'fav-heart my-local-fav-heart';
thumbBtn.style.border = 'none';
thumbBtn.style.cursor = 'pointer';
// Fix location to bottom-right (original style) for novels
if (isNovel) {
thumbBtn.style.position = 'absolute';
thumbBtn.style.bottom = '10px';
thumbBtn.style.right = '10px';
if (window.getComputedStyle(thumbContainer).position === 'static') {
thumbContainer.style.position = 'relative';
}
}
thumbContainer.appendChild(thumbBtn);
}
// 3. Keep both in sync with a single update function
const updateAllButtonsUI = () => {
const _marks = getBookmarks();
const isMarked = !!(_marks[category] && _marks[category][workId]);
if (localBtn) {
if (isNovel) {
localBtn.className = 'cta cta-fav';
localBtn.innerHTML = `
<span class="ic">
<svg width="20" height="20" viewBox="0 0 24 24" fill="${isMarked ? '#ff4757' : 'none'}" stroke="${isMarked ? '#ff4757' : 'currentColor'}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
</span>
<span class="t">${isMarked ? '북마크 완료' : '북마크'}</span>
`;
localBtn.style.color = isMarked ? '#ff4757' : 'inherit';
localBtn.style.backgroundColor = isMarked ? 'rgba(255, 71, 87, 0.05)' : 'transparent';
localBtn.style.border = isMarked ? '1px solid #ff4757' : '1px solid var(--toki-border)';
} else {
localBtn.className = isMarked ? 'cta cta-primary' : 'cta cta-ghost';
localBtn.innerHTML = `<span class="ic">${isMarked ? '⭐' : '☆'}</span><span class="t">${isMarked ? '북마크 취소' : '북마크 추가'}</span>`;
localBtn.style.backgroundColor = isMarked ? '#ff4757' : '';
localBtn.style.color = isMarked ? '#ffffff' : '';
localBtn.style.border = isMarked ? 'none' : '';
}
}
if (thumbBtn) {
thumbBtn.innerHTML = isMarked ? `
<svg width="22" height="22" viewBox="0 0 24 24" fill="#ffd700" stroke="#ffd700" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
` : `
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
`;
}
};
updateAllButtonsUI();
// 4. Shared click logic
const toggleBookmark = async () => {
const _marks = getBookmarks();
if (_marks[category] && _marks[category][workId]) {
const confirmDelete = await TokiUI.confirm('북마크 해제', '해당 작품을 로컬 북마크에서 제거합니다.');
if(confirmDelete) {
delete _marks[category][workId];
TokiUI.toast('작품이 북마크에서 제외되었습니다.');
} else return;
} else {
const titleEl = document.querySelector('.hero-v2-title, .nd-title, .crumb strong');
const title = titleEl ? titleEl.innerText.trim() : document.title.split('|')[0].trim();
const thumbEl = document.querySelector('.hero-v2-thumb img, .nd-thumb img');
const thumb = thumbEl ? thumbEl.src : '';
const genreEls = document.querySelectorAll('.hero-v2-tags .hero-v2-tag, .nd-tags .nd-tag, .nd-genres .nd-genre');
const genres = Array.from(genreEls).map(el => el.innerText.trim().replace(/\s+/g, '')).join(' ');
const latestEpEl = document.querySelector('.ep-list-v2 .ep-row-v2-title strong, .nd-ep-list .nd-ep-row strong, .ep-row-v2-link strong, .novel-eps li a .ne-title, .novel-eps li a .ne-num');
const latestEp = latestEpEl ? extractShortEp(latestEpEl.textContent) : '';
const latestEpLinkEl = document.querySelector('.ep-list-v2 .ep-row-v2-link, .nd-ep-list .nd-ep-row-link, .ep-row-v2-link, .novel-eps li a');
const latestEpLink = latestEpLinkEl ? latestEpLinkEl.getAttribute('href') : '';
const latestDateEl = document.querySelector('.ep-list-v2 .ep-row-v2-date, .nd-ep-list .nd-ep-row-date, .ep-row-v2-date, .novel-eps .ne-date');
const latestDate = latestDateEl ? latestDateEl.innerText.trim() : '';
const firstEpBtn = document.querySelector('.cta-ghost, .nd-first-ep, .nd-actions .btn--primary');
const firstEpLink = firstEpBtn ? firstEpBtn.getAttribute('href') : '';
// Premium Author Metadata!
const author = TokiUtils.extractAuthor(document.documentElement.innerHTML, document.title);
const now = Date.now();
const exactTime = parseExactUpdatedAt(document.documentElement.innerHTML);
_marks[category][workId] = {
id: workId, title: title, author: author, genres: genres, url: location.pathname, thumb: thumb,
latestEp: latestEp, latestEpLink: latestEpLink, latestDate: latestDate, firstEpLink: firstEpLink,
lastReadEp: null, lastReadEpLink: null, lastReadAt: null, nextEp: null, nextEpLink: null,
exactUpdatedAt: exactTime, parsedUpdatedAt: exactTime || parseSiteDateToTimestamp(latestDate),
addedAt: now, latestUpdatedAt: now, status: parseStatus(document)
};
TokiUI.toast('성공적으로 북마크에 등록되었습니다.', 'success');
}
saveBookmarks(_marks);
if(typeof GistSync !== 'undefined') GistSync.push();
updateAllButtonsUI();
};
if (localBtn) localBtn.onclick = toggleBookmark;
if (thumbBtn) thumbBtn.onclick = toggleBookmark;
}
// ==========================================
// 4. Background Scanning (BroadcastChannel, SWR)
// ==========================================
async function runBackgroundScan(scanType = 'auto') { // 'auto', 'smart', 'force'
if (tokiState.isScanning) {
if (scanType !== 'auto') TokiUI.toast('이미 스캔 중입니다.', 'error');
return;
}
tokiState.isScanning = true;
try {
if (!navigator.locks) {
await executeScan(scanType);
} else {
await new Promise(resolve => {
navigator.locks.request('toki_bg_scan_lock', { ifAvailable: true }, async (lock) => {
if (!lock) {
if (scanType !== 'auto') TokiUI.toast('다른 탭에서 이미 스캔 중입니다.', 'error');
resolve();
return;
}
await executeScan(scanType);
resolve();
});
});
}
} finally {
tokiState.isScanning = false;
}
}
async function executeScan(scanType) {
tokiState.isScanning = true;
const currentMarks = getBookmarks();
let queue = [];
try {
queue = JSON.parse(localStorage.getItem(SCAN_QUEUE_KEY) || '[]');
} catch(e) { queue = []; }
if (queue.length === 0) {
const now = Date.now();
const categories = ['manhwa', 'webtoon', 'novel'];
for (const cat of categories) {
const catMarks = currentMarks[cat] || {};
for (const key of Object.keys(catMarks)) {
const m = catMarks[key];
if (scanType === 'force') {
queue.push({ id: key, category: cat });
continue;
}
if (scanType === 'smart') {
if (m.status === 'completed') continue;
const isReading = m.lastReadEp != null;
if (isReading) {
queue.push({ id: key, category: cat });
continue;
}
}
const nextScan = (m.lastScannedAt || 0) + getTTL(m);
if (now >= nextScan) {
queue.push({ id: key, category: cat });
}
}
}
localStorage.setItem(SCAN_QUEUE_KEY, JSON.stringify(queue));
}
const totalKeys = queue.length;
if (totalKeys === 0) {
dispatchSync({ type: 'DONE', count: 0, force: scanType === 'force', scanType: scanType, totalKeys: 0 });
return;
}
let updateCount = 0;
let originalTotal = parseInt(localStorage.getItem('toki_scan_total_v20') || totalKeys);
if (queue.length === totalKeys) {
localStorage.setItem('toki_scan_total_v20', totalKeys);
originalTotal = totalKeys;
}
const processItem = async () => {
let q = [];
try {
q = JSON.parse(localStorage.getItem(SCAN_QUEUE_KEY) || '[]');
} catch(e) { q = []; }
if (q.length === 0) return;
const item = q.shift();
localStorage.setItem(SCAN_QUEUE_KEY, JSON.stringify(q));
const latestMarks = getBookmarks();
const m = latestMarks[item.category]?.[item.id];
if (!m) return;
const scannedIdx = originalTotal - q.length;
dispatchSync({ type: 'PROGRESS', current: scannedIdx, total: originalTotal });
try {
const res = await fetch(m.url, { cache: 'no-store' });
const text = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
// Handle novel structure support
const epList = Array.from(doc.querySelectorAll('.ep-list-v2 .ep-row-v2-link, .novel-eps li a'));
const latestDateEl = doc.querySelector('.ep-list-v2 .ep-row-v2-date, .novel-eps .ne-date');
const genreEls = doc.querySelectorAll('.hero-v2-tags .hero-v2-tag');
m.status = parseStatus(doc);
m.lastScannedAt = Date.now();
if (!m.genres && genreEls.length > 0) m.genres = Array.from(genreEls).map(el => el.innerText.trim().replace(/\s+/g, '')).join(' ');
// Auto extract premium author if not present
if (!m.author) {
m.author = TokiUtils.extractAuthor(text, doc.title);
}
if (epList.length > 0) {
const getEpNode = (el) => {
if (item.category === 'novel') {
return el.querySelector('.ne-title') || el.querySelector('.ne-num') || el;
}
return el.querySelector('.ne-title, .ep-row-v2-title strong, .ep-row-v2-title, .ne-num') || el;
};
const epNode = getEpNode(epList[0]);
const fetchedLatestEp = epNode ? extractShortEp(epNode.textContent) : '';
const fetchedLatestLink = epList[0].getAttribute('href');
const fetchedDate = latestDateEl ? latestDateEl.innerText.trim() : m.latestDate;
const exactTime = parseExactUpdatedAt(text);
const newParsedDate = exactTime || parseSiteDateToTimestamp(fetchedDate);
const isNewEp = (fetchedLatestEp !== m.latestEp);
if (scanType === 'force' || isNewEp || !m.exactUpdatedAt) {
m.latestEp = fetchedLatestEp;
m.latestEpLink = fetchedLatestLink;
m.latestDate = fetchedDate;
if (exactTime !== null) m.exactUpdatedAt = exactTime;
m.parsedUpdatedAt = newParsedDate;
m.latestUpdatedAt = Date.now();
if (isNewEp) updateCount++;
}
if (m.lastReadEpLink || m.lastReadEp) {
let lastIdx = -1;
if (m.lastReadEpLink) {
lastIdx = epList.findIndex(el => {
const href = el.getAttribute('href');
return href && href.includes(m.lastReadEpLink);
});
}
if (lastIdx === -1 && m.lastReadEp) {
lastIdx = epList.findIndex(el => {
const titleNode = getEpNode(el);
return extractShortEp(titleNode.textContent) === m.lastReadEp;
});
}
if (lastIdx !== -1) {
m.lastReadEp = extractShortEp(getEpNode(epList[lastIdx]).textContent);
}
if (lastIdx > 0) {
const nextEl = epList[lastIdx - 1];
const nextNode = getEpNode(nextEl);
m.nextEp = extractShortEp(nextNode.textContent);
m.nextEpLink = nextEl.getAttribute('href');
} else if (lastIdx === 0) {
m.nextEp = null; m.nextEpLink = null;
}
}
}
} catch(e) { console.error('체크 실패:', m.title); }
await new Promise(resolve => setTimeout(resolve, 300));
const currentLatestMarks = getBookmarks();
if (currentLatestMarks[item.category]?.[item.id]) {
currentLatestMarks[item.category][item.id] = {
...currentLatestMarks[item.category][item.id],
status: m.status,
lastScannedAt: m.lastScannedAt,
genres: m.genres,
author: m.author,
latestEp: m.latestEp,
latestEpLink: m.latestEpLink,
latestDate: m.latestDate,
exactUpdatedAt: m.exactUpdatedAt,
parsedUpdatedAt: m.parsedUpdatedAt,
latestUpdatedAt: m.latestUpdatedAt,
lastReadEp: m.lastReadEp,
nextEp: m.nextEp,
nextEpLink: m.nextEpLink
};
saveBookmarks(currentLatestMarks);
}
};
const enqueue = async () => {
let q = [];
try {
q = JSON.parse(localStorage.getItem(SCAN_QUEUE_KEY) || '[]');
} catch(e) { q = []; }
if (q.length === 0) return;
await processItem();
await enqueue();
};
const activePromises = [];
const CONCURRENCY_LIMIT = 15;
for (let i = 0; i < CONCURRENCY_LIMIT; i++) { activePromises.push(enqueue()); }
await Promise.all(activePromises);
let finalQ = [];
try {
finalQ = JSON.parse(localStorage.getItem(SCAN_QUEUE_KEY) || '[]');
} catch(e) { finalQ = []; }
if (finalQ.length === 0) {
localStorage.removeItem('toki_scan_total_v20');
if (GistSync.getMode()) GistSync.push();
tokiState.isScanning = false;
dispatchSync({ type: 'DONE', count: updateCount, force: scanType === 'force', scanType: scanType, totalKeys: totalKeys });
}
}
// Broadcast Sync UI Status Updates
function updateScanUI(msg) {
const btn = document.getElementById('checkUpdatesBtn');
const showAutoNotice = localStorage.getItem('toki_auto_scan_notice') !== 'false';
if (msg.type === 'PROGRESS') {
tokiState.isScanning = true;
if (btn) {
if (tokiState.btnTimeout) { clearTimeout(tokiState.btnTimeout); tokiState.btnTimeout = null; }
btn.disabled = true;
btn.style.background = '#b2bec3';
btn.innerText = `스캔 중... (${msg.current}/${msg.total})`;
}
} else if (msg.type === 'DONE') {
tokiState.isScanning = false;
const isAuto = msg.scanType === 'auto';
if (msg.count > 0) {
if (!isAuto || showAutoNotice) {
TokiUI.toast(`${msg.count}개의 작품에 새로운 회차가 있습니다.`, 'success');
}
if (btn) {
if (tokiState.btnTimeout) clearTimeout(tokiState.btnTimeout);
btn.innerText = `완료! (${msg.count}건 갱신) 🎉`;
btn.style.background = '#00b894';
tokiState.btnTimeout = setTimeout(() => { renderFavoritesList(); }, 1500);
}
} else {
if (msg.force) {
TokiUI.toast('모든 북마크가 최신 상태입니다.');
} else if (!isAuto) {
if (location.pathname !== '/favorites') {
TokiUI.toast('백그라운드 북마크 스캔 완료 (최신상태 유지중)', 'success');
}
}
if (btn) {
if (tokiState.btnTimeout) clearTimeout(tokiState.btnTimeout);
btn.innerText = '모두 최신입니다 ✨';
btn.style.background = '#0984e3';
tokiState.btnTimeout = setTimeout(() => { btn.innerText = '🔄 스마트 스캔'; btn.disabled = false; }, 3000);
}
}
}
}
function dispatchSync(msg) {
updateScanUI(msg);
if (syncChannel) syncChannel.postMessage(msg);
}
if (syncChannel) {
syncChannel.onmessage = (e) => updateScanUI(e.data);
}
// ==========================================
// 5. Bookmarks Tab Rendering
// ==========================================
// [State Handled in tokiState]
function applyFilters() {
const searchInput = document.getElementById('toki-search-input');
const cards = document.querySelectorAll('.toki-card');
const { states, genres } = tokiState.filters;
const normalizeString = (str) => (str || '').toLowerCase().replace(/\s+/g, '');
const searchQueryNormalized = normalizeString(tokiState.searchQuery);
let visibleGenres = new Set();
let matchCount = 0; // Track matched card counts
const evaluateFilter = (f, isUnread, isReading, isCompleted, cardGenres) => {
if (f.type === 'state') {
if (f.val === 'unread') return isUnread;
if (f.val === 'reading') return isReading;
if (f.val === 'ongoing') return !isCompleted;
if (f.val === 'completed') return isCompleted;
}
return cardGenres.includes(f.val);
};
const filters = [
...states.map(s => ({ type: 'state', val: s })),
...genres.map(g => ({ type: 'genre', val: g }))
];
cards.forEach(card => {
const isCompleted = card.dataset.status === 'completed';
const isReading = card.dataset.reading === 'true';
const isUnread = card.dataset.reading === 'false';
const cardGenres = card.dataset.genres ? card.dataset.genres.split(' ') : [];
const cardTitleNormalized = normalizeString(card.dataset.title);
const cardAuthorNormalized = normalizeString(card.dataset.author);
const matchesSearch = !searchQueryNormalized || cardTitleNormalized.includes(searchQueryNormalized) || cardAuthorNormalized.includes(searchQueryNormalized);
const finalMatch = filters.length === 0 || (
tokiState.filterMode === 'AND'
? filters.every(f => evaluateFilter(f, isUnread, isReading, isCompleted, cardGenres))
: filters.some(f => evaluateFilter(f, isUnread, isReading, isCompleted, cardGenres))
);
if (finalMatch && matchesSearch) {
card.style.display = 'flex';
cardGenres.forEach(g => { if(g) visibleGenres.add(g); });
matchCount++;
} else {
card.style.display = 'none';
}
});
// Toggle "No Search Results" state UI
const emptyState = document.getElementById('toki-search-empty');
if (emptyState) {
emptyState.style.display = matchCount === 0 ? 'block' : 'none';
}
const btnGenre = document.getElementById('btnFilterGenre');
if (btnGenre) {
if (genres.length > 0) {
btnGenre.classList.add('active');
btnGenre.innerHTML = `장르 선택 (${genres.length}) ▾`;
} else {
btnGenre.classList.remove('active');
btnGenre.innerHTML = `장르 선택 ▾`;
}
}
document.querySelectorAll('.toki-genre-item').forEach(item => {
const val = item.dataset.val;
if (!visibleGenres.has(val) && !tokiState.filters.genres.includes(val)) {
item.style.display = 'none';
} else {
item.style.display = 'flex';
}
});
}
async function renderFavoritesPage() {
if (location.pathname !== '/favorites') return;
if (tokiState.isScanning) return;
const mainContainer = document.querySelector('main.container');
if (!mainContainer) return;
const emptyState = document.querySelector('.ep-empty');
if (emptyState && emptyState.style.display !== 'none') emptyState.style.display = 'none';
if (document.getElementById('toki-sync-loading')) return;
// Auto PULL sync
if (GistSync.getMode() && !tokiState.isSyncing) {
tokiState.isSyncing = true;
await GistSync.checkAndRecoverSync();
const needPull = await GistSync.checkNeedPull();
if (needPull) {
TokiUI.toast('☁️ 새로운 데이터가 확인되어 동기화합니다...');
const success = await GistSync.pull();
if (success) {
TokiUI.toast('✅ 동기화 완료!', 'success');
localStorage.setItem(DIRTY_KEY, '1');
renderFavoritesList();
}
}
tokiState.isSyncing = false;
}
const dirtyCount = parseInt(localStorage.getItem(DIRTY_KEY) || '-1');
if (document.getElementById('my-local-bookmarks') && dirtyCount === 0) {
localStorage.setItem('toki_fav_last_viewed', Date.now());
return;
}
localStorage.setItem(DIRTY_KEY, '0');
localStorage.setItem('toki_fav_last_viewed', Date.now());
document.querySelector('.page-top h1').innerHTML = '<span class="em" style="color:#ff4757;">★</span>로컬 북마크';
const pageDesc = document.querySelector('.page-top .desc');
if(pageDesc) pageDesc.innerText = '개인정보 보호를 위해 기기에 안전하게 저장된 트래커입니다.';
renderFavoritesList();
}
function renderFavoritesList() {
const lastFavViewAt = parseInt(localStorage.getItem('toki_fav_last_viewed') || '0');
const ONE_DAY = 24 * 60 * 60 * 1000;
const mainContainer = document.querySelector('main.container');
let wrapper = document.getElementById(ID_BOOKMARKS_WRAPPER);
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.id = ID_BOOKMARKS_WRAPPER;
if (isMobile) wrapper.classList.add('toki-mobile');
else wrapper.classList.add('toki-pc');
wrapper.style.maxWidth = isMobile ? '100%' : '1200px';
mainContainer.appendChild(wrapper);
}
const allMarks = getBookmarks();
const counts = { all: 0, manhwa: 0, webtoon: 0, novel: 0 };
const upCounts = { all: 0, manhwa: 0, webtoon: 0, novel: 0 };
const listByCategory = { manhwa: [], webtoon: [], novel: [] };
for (const cat of ['manhwa', 'webtoon', 'novel']) {
const list = Object.values(allMarks[cat] || {});
counts[cat] = list.length;
counts.all += list.length;
list.forEach(m => {
m.category = cat; // Category tag
const lastInteraction = m.lastReadAt || m.addedAt || 0;
const updateTime = m.exactUpdatedAt || m.parsedUpdatedAt || m.latestUpdatedAt || m.addedAt || 0;
const isRead = !!m.lastReadEp;
if (isRead) {
m.hasNewUp = updateTime > lastInteraction && m.latestEp !== m.lastReadEp;
} else {
const hasSeenUpdate = lastFavViewAt > updateTime;
const isOneDayOld = (Date.now() - updateTime) >= ONE_DAY;
if (updateTime > lastInteraction) {
m.hasNewUp = !(hasSeenUpdate && isOneDayOld);
} else {
m.hasNewUp = false;
}
}
m.isClr = m.latestEp && m.latestEp === m.lastReadEp && !m.hasNewUp;
if (m.hasNewUp) {
upCounts[cat]++;
upCounts.all++;
}
listByCategory[cat].push(m);
});
}
// Active tab filtering
let markList = listByCategory[tokiState.activeTab] || [];
// Sort configuration
const currentSort = getSortPref();
const pinUpMode = localStorage.getItem(PIN_UP_KEY) !== 'false';
const clrKeyVal = localStorage.getItem(PIN_CLR_KEY);
const pinClrMode = clrKeyVal === null ? true : (clrKeyVal === 'true');
const sortAsc = localStorage.getItem(SORT_ASC_KEY) === 'true';
markList.sort((a, b) => {
if (pinUpMode) {
if (a.hasNewUp && !b.hasNewUp) return -1;
if (!a.hasNewUp && b.hasNewUp) return 1;
}
if (pinClrMode) {
if (a.isClr && !b.isClr) return 1;
if (!a.isClr && b.isClr) return -1;
}
const getSortTime = (x) => x.exactUpdatedAt || x.parsedUpdatedAt || x.latestUpdatedAt || x.addedAt || 0;
let cmp = 0;
if (currentSort === 'siteUpdateDesc') {
cmp = getSortTime(a) - getSortTime(b);
} else if (currentSort === 'readDesc') {
cmp = (a.lastReadAt || 0) - (b.lastReadAt || 0);
} else if (currentSort === 'addedDesc') {
cmp = (a.addedAt || 0) - (b.addedAt || 0);
} else if (currentSort === 'titleAsc') {
cmp = b.title.localeCompare(a.title);
}
return sortAsc ? cmp : -cmp;
});
// Genres extraction from filtered list
const allGenres = new Set();
markList.forEach(m => { if(m.genres) m.genres.split(' ').forEach(g => { if(g) allGenres.add(g); }) });
const sortedGenres = Array.from(allGenres).sort();
// Calculate active tab stats
const stats = { total: markList.length, ongoing: 0, completed: 0, unread: 0, reading: 0 };
markList.forEach(m => {
if (m.status === 'completed') stats.completed++; else stats.ongoing++;
if (m.lastReadEp) stats.reading++; else stats.unread++;
});
// Stats grid
let html = `
<div class="toki-stats-grid">
<div class="toki-stat-item total"><span class="label">📚 전체</span><span class="value">${stats.total}</span></div>
<div class="toki-stat-item ongoing"><span class="label">▶ 연재중</span><span class="value" style="color:#0984e3">${stats.ongoing}</span></div>
<div class="toki-stat-item completed"><span class="label">🏁 완결</span><span class="value" style="color:#e1b12c">${stats.completed}</span></div>
<div class="toki-stat-item reading"><span class="label">📖 읽는중</span><span class="value" style="color:#00b894">${stats.reading}</span></div>
<div class="toki-stat-item unread"><span class="label">📦 안읽음</span><span class="value" style="color:#ff7675">${stats.unread}</span></div>
</div>
`;
// Premium Category Tabs UI
html += `
<div class="toki-category-tabs">
<button class="toki-tab-btn ${tokiState.activeTab === 'manhwa' ? 'active' : ''}" data-category="manhwa">
🌸 만화 ${upCounts.manhwa > 0 ? `<span class="tab-badge has-up">${upCounts.manhwa}</span>` : ''}
</button>
<button class="toki-tab-btn ${tokiState.activeTab === 'webtoon' ? 'active' : ''}" data-category="webtoon">
⚡ 웹툰 ${upCounts.webtoon > 0 ? `<span class="tab-badge has-up">${upCounts.webtoon}</span>` : ''}
</button>
<button class="toki-tab-btn ${tokiState.activeTab === 'novel' ? 'active' : ''}" data-category="novel">
✍️ 소설 ${upCounts.novel > 0 ? `<span class="tab-badge has-up">${upCounts.novel}</span>` : ''}
</button>
</div>
`;
if (isMobile) {
// [Mobile UI Template]
html += `
<div class="toki-control-bar" style="margin-bottom:10px;">
<div class="toki-search-wrap">
<span class="toki-search-icon">🔍</span>
<input type="text" id="toki-search-input" placeholder="제목/작가 검색..." value="${tokiState.searchQuery || ''}" autocomplete="off">
<button id="toki-search-clear" class="toki-search-clear" style="display: ${tokiState.searchQuery ? 'flex' : 'none'};">✕</button>
</div>
<div class="toki-filter-bar">
<button id="btnFilterReset" class="toki-filter-btn reset">↺ 초기화</button>
<button id="btnFilterMode" class="toki-mode-btn ${tokiState.filterMode === 'OR' ? 'or-mode' : ''}">${tokiState.filterMode} 🔄</button>
<div class="toki-dropdown">
<button id="btnFilterGenre" class="toki-filter-btn">장르 선택 ▾</button>
<div id="genreDropdown" class="toki-dropdown-menu">
${sortedGenres.map(g => `<div class="toki-genre-item" data-val="${g}"><span class="chk">✓</span> ${g}</div>`).join('')}
<div class="toki-mobile-close" id="btnCloseGenre">선택 완료 및 닫기</div>
</div>
</div>
<button class="toki-filter-btn state-toggle" data-val="unread"><div class="chk"></div>안읽음</button>
<button class="toki-filter-btn state-toggle" data-val="reading"><div class="chk"></div>읽는중</button>
<button class="toki-filter-btn state-toggle" data-val="ongoing"><div class="chk"></div>연재</button>
<button class="toki-filter-btn state-toggle" data-val="completed"><div class="chk"></div>완결</button>
</div>
<div class="toki-sort-row">
<div class="toki-sort-main">
<select id="sortSelect">
<option value="siteUpdateDesc" ${currentSort === 'siteUpdateDesc' ? 'selected' : ''}>최근 업데이트순</option>
<option value="readDesc" ${currentSort === 'readDesc' ? 'selected' : ''}>최근 열람순</option>
<option value="addedDesc" ${currentSort === 'addedDesc' ? 'selected' : ''}>최근 등록순</option>
<option value="titleAsc" ${currentSort === 'titleAsc' ? 'selected' : ''}>가나다순</option>
</select>
<button id="sortDirBtn" class="toki-btn">
${sortAsc ? '🔼 오름차순' : '🔽 내림차순'}
</button>
</div>
<div class="toki-pin-group">
<label class="toki-pin-toggle">
<input type="checkbox" id="pinUpCheck" ${pinUpMode ? 'checked' : ''}> 📌 UP 고정
</label>
<label class="toki-pin-toggle clr-pin">
<input type="checkbox" id="pinClrCheck" ${pinClrMode ? 'checked' : ''}> ⬇️ CLR 고정
</label>
</div>
</div>
<div class="toki-sync-group">
<div class="toki-action-row">
<button id="importDataBtn" class="btn-import">가져오기</button>
<button id="exportDataBtn" class="btn-export">내보내기</button>
</div>
<div class="toki-action-row">
<button id="syncSettingsBtn">☁️ 웹 동기화</button>
<button id="checkUpdatesBtn">🔄 스마트 스캔</button>
</div>
</div>
</div>
`;
} else {
// [PC UI Template]
html += `
<div class="toki-control-bar" style="margin-bottom:10px;">
<div class="toki-filter-bar">
<button id="btnFilterReset" class="toki-filter-btn reset">↺ 초기화</button>
<button id="btnFilterMode" class="toki-mode-btn ${tokiState.filterMode === 'OR' ? 'or-mode' : ''}">${tokiState.filterMode} 🔄</button>
<div class="toki-dropdown">
<button id="btnFilterGenre" class="toki-filter-btn">장르 선택 ▾</button>
<div id="genreDropdown" class="toki-dropdown-menu">
${sortedGenres.map(g => `<div class="toki-genre-item" data-val="${g}"><span class="chk">✓</span> ${g}</div>`).join('')}
</div>
</div>
<button class="toki-filter-btn state-toggle" data-val="unread"><div class="chk"></div>안읽음</button>
<button class="toki-filter-btn state-toggle" data-val="reading"><div class="chk"></div>읽는중</button>
<button class="toki-filter-btn state-toggle" data-val="ongoing"><div class="chk"></div>연재</button>
<button class="toki-filter-btn state-toggle" data-val="completed"><div class="chk"></div>완결</button>
</div>
<div style="flex-grow:1"></div>
<div class="toki-search-wrap">
<span class="toki-search-icon">🔍</span>
<input type="text" id="toki-search-input" placeholder="제목/작가 검색..." value="${tokiState.searchQuery || ''}" autocomplete="off">
<button id="toki-search-clear" class="toki-search-clear" style="display: ${tokiState.searchQuery ? 'flex' : 'none'};">✕</button>
</div>
</div>
<div class="toki-control-bar" style="margin-top:0;">
<div class="toki-dropdown">
<button id="btnSortSelect" class="toki-filter-btn" style="min-width:140px; justify-content:space-between;">
${({siteUpdateDesc:'최근 업데이트순', readDesc:'최근 열람순', addedDesc:'최근 등록순', titleAsc:'가나다순'})[currentSort]} ▾
</button>
<div id="sortDropdown" class="toki-dropdown-menu" style="width:160px; grid-template-columns: 1fr;">
<div class="toki-sort-item ${currentSort === 'siteUpdateDesc' ? 'active' : ''}" data-val="siteUpdateDesc">최근 업데이트순</div>
<div class="toki-sort-item ${currentSort === 'readDesc' ? 'active' : ''}" data-val="readDesc">최근 열람순</div>
<div class="toki-sort-item ${currentSort === 'addedDesc' ? 'active' : ''}" data-val="addedDesc">최근 등록순</div>
<div class="toki-sort-item ${currentSort === 'titleAsc' ? 'active' : ''}" data-val="titleAsc">가나다순</div>
</div>
</div>
<button id="sortDirBtn" class="toki-btn" style="padding:6px 10px; font-size:12px; background:var(--toki-bg); border:1px solid var(--toki-filter-border); color:var(--toki-text); margin:0 5px;">
${sortAsc ? '🔼 오름차순' : '🔽 내림차순'}
</button>
<label class="toki-pin-toggle" style="margin-right:5px;">
<input type="checkbox" id="pinUpCheck" ${pinUpMode ? 'checked' : ''}> 📌 UP 고정
</label>
<label class="toki-pin-toggle clr-pin">
<input type="checkbox" id="pinClrCheck" ${pinClrMode ? 'checked' : ''}> ⬇️ CLR 고정
</label>
<div style="flex-grow:1"></div>
<div class="toki-sync-group" style="display:flex; gap:8px;">
<button id="syncSettingsBtn" style="padding:8px 12px; background:#4b6584; color:white; border:none; border-radius:6px; font-weight:bold; cursor:pointer; font-size:12px;">☁️ 웹 동기화</button>
<button id="importDataBtn" class="btn-import" style="padding:8px 12px; background:#2d3436; color:white; border:none; border-radius:6px; font-weight:bold; cursor:pointer; font-size:12px;">가져오기</button>
<button id="exportDataBtn" class="btn-export" style="padding:8px 12px; background:#2d3436; color:white; border:none; border-radius:6px; font-weight:bold; cursor:pointer; font-size:12px;">내보내기</button>
<button id="checkUpdatesBtn" style="padding:8px 16px; background:#0984e3; color:white; border:none; border-radius:6px; font-weight:bold; cursor:pointer; box-shadow:0 2px 5px rgba(9, 132, 227, 0.3);">🔄 스마트 스캔</button>
</div>
</div>
`;
}
// Top-level container wrapper to prevent layout collapse during category switching or filtering
html += `<div style="min-height: 85vh;">`;
if (markList.length === 0) {
html += `<div style="text-align:center; padding:80px 0; background:var(--toki-empty-bg); border-radius:12px; border:1px dashed var(--toki-border-dashed);">
<div style="font-size:50px; margin-bottom:15px; opacity:0.5;">📁</div>
<div style="font-size:18px; font-weight:bold; color:var(--toki-text-sub);">표시할 북마크가 비어있습니다.</div>
</div>`;
} else {
// Remove min-height from the list wrapper to allow the empty state to float up when the list is hidden
html += `<div id="toki-card-list-wrap" style="display:flex; flex-direction:column;">`;
markList.forEach(m => {
const badgeHtml = m.hasNewUp ? `<span class="toki-up-badge">UP</span>` : (m.isClr ? `<span class="toki-clr-badge">CLR</span>` : '');
const genreHtml = m.genres ? `<div class="toki-genre">${m.genres}</div>` : '';
const authorHtml = m.author ? `<div class="toki-author">✍️ ${m.author}</div>` : '';
const displayTime = m.exactUpdatedAt || m.parsedUpdatedAt;
const siteDateText = displayTime ? `(${formatDateTime(displayTime)})` : (m.latestDate ? `(${m.latestDate})` : '');
const readDateText = m.lastReadAt ? `(${formatDateTime(m.lastReadAt)})` : '';
const statusBadge = m.status === 'completed'
? `<span style="font-size:10px; background:#e1b12c; color:white; border-radius:4px; padding:0 5px; height:18px; display:inline-flex; align-items:center; justify-content:center; font-weight:800; line-height:1; flex-shrink:0;">완결</span>`
: `<span style="font-size:10px; background:#0984e3; color:white; border-radius:4px; padding:0 5px; height:18px; display:inline-flex; align-items:center; justify-content:center; font-weight:800; line-height:1; flex-shrink:0;">연재</span>`;
let actionBtns = '';
if (!m.lastReadEpLink) {
actionBtns = `<a href="${m.firstEpLink || m.url}" class="toki-btn toki-btn-main" style="background:#00b894; color:white;">첫화부터 보기</a>`;
} else if (m.nextEpLink) {
const nextText = m.nextEp === '다음화' ? '다음화 보기' : `다음화 보기 (${m.nextEp})`;
actionBtns = `
<a href="${m.lastReadEpLink}" class="toki-btn toki-btn-main" style="background:#0984e3; color:white;">이어보기 (${m.lastReadEp})</a>
<a href="${m.nextEpLink}" class="toki-btn toki-btn-sub" style="background:#ff4757; color:white;">${nextText}</a>
`;
} else if (m.latestEp !== m.lastReadEp) {
actionBtns = `
<a href="${m.lastReadEpLink}" class="toki-btn toki-btn-main" style="background:#0984e3; color:white;">이어보기 (${m.lastReadEp})</a>
<a href="${m.latestEpLink}" class="toki-btn toki-btn-sub" style="background:#ff4757; color:white;">최신화 보기 (${m.latestEp})</a>
`;
} else {
actionBtns = `
<a href="${m.lastReadEpLink}" class="toki-btn toki-btn-main" style="background:#0984e3; color:white;">이어보기 (${m.lastReadEp})</a>
<a href="javascript:void(0);" class="toki-btn toki-btn-sub" style="background:var(--toki-done-bg); color:var(--toki-done-text); cursor:default;">정주행 완료</a>
`;
}
html += `
<div class="toki-card" id="bookmark-${m.id}" data-title="${m.title.replace(/"/g, '"')}" data-author="${m.author ? m.author.replace(/"/g, '"') : ''}" data-status="${m.status}" data-reading="${m.lastReadEp != null}" data-genres="${m.genres}" style="${m.hasNewUp ? 'background:var(--toki-highlight-bg); border-color:var(--toki-highlight-border);' : ''}">
<a class="toki-card-top" href="${m.url}">
<div class="toki-thumb">
<img src="${m.thumb}" alt="thumb">
</div>
<div class="toki-info">
<div class="toki-title">
${statusBadge}
<span class="title-text" style="word-break:keep-all;">${m.title}</span>
${badgeHtml}
</div>
${genreHtml}
${authorHtml}
<div class="toki-meta-box">
<div class="toki-meta-row">📡 업데이트: <strong style="color:#ff4757; margin-left:5px;">${m.latestEp || '없음'}</strong> <span class="toki-date-sub">${siteDateText}</span></div>
<div class="toki-meta-row">👀 마지막 열람: <strong style="color:#00b894; margin-left:5px;">${m.lastReadEp || '기록없음'}</strong> <span class="toki-date-sub">${readDateText}</span></div>
</div>
</div>
</a>
<div class="toki-actions">
${actionBtns}
<a href="javascript:void(0);" data-id="${m.id}" data-category="${m.category}" class="toki-btn toki-btn-del toki-del-btn">삭제</a>
</div>
</div>`;
});
html += `</div>`;
// No Search Results state UI (placed below list; displays when list has display: none)
html += `<div id="toki-search-empty" style="display:none; text-align:center; padding:80px 0; background:var(--toki-empty-bg); border-radius:12px; border:1px dashed var(--toki-border-dashed);">
<div style="font-size:50px; margin-bottom:15px; opacity:0.5;">🔍</div>
<div style="font-size:18px; font-weight:bold; color:var(--toki-text-sub);">검색 결과가 없습니다.</div>
</div>`;
}
html += `</div>`; // Close top-level container wrapper
wrapper.innerHTML = html;
// Event Delegation for deletion buttons (Sandbox safe)
if (!wrapper.dataset.listener) {
wrapper.addEventListener('click', async (e) => {
const delBtn = e.target.closest('.toki-del-btn');
if (delBtn) {
const id = delBtn.getAttribute('data-id');
const category = delBtn.getAttribute('data-category');
await removeTokiLocalBookmark(id, category, e);
}
});
wrapper.dataset.listener = 'true';
}
applyFilters(); // Apply filter states immediately
// Tab click bindings
document.querySelectorAll('.toki-tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
tokiState.activeTab = this.dataset.category;
localStorage.setItem('toki_fav_last_tab', tokiState.activeTab);
renderFavoritesList();
});
});
// Search Bar Event Handlers
const searchInput = document.getElementById('toki-search-input');
const searchClear = document.getElementById('toki-search-clear');
if (searchInput && searchClear) {
// [Core Fix] Stop site-native keyboard shortcuts/scroll behaviors from intercepting input entries
const preventSiteInterference = (e) => e.stopPropagation();
searchInput.addEventListener('keydown', (e) => {
preventSiteInterference(e);
if (e.key === 'Enter') e.target.blur();
});
searchInput.addEventListener('keyup', preventSiteInterference);
searchInput.addEventListener('keypress', preventSiteInterference);
searchInput.addEventListener('input', (e) => {
preventSiteInterference(e);
tokiState.searchQuery = e.target.value;
searchClear.style.display = tokiState.searchQuery ? 'flex' : 'none';
// Prevent page jumps by preserving scroll position during filtering
const currentScrollY = window.scrollY;
applyFilters();
window.scrollTo(0, currentScrollY);
});
searchClear.addEventListener('click', (e) => {
e.preventDefault();
searchInput.value = '';
tokiState.searchQuery = '';
searchClear.style.display = 'none';
const currentScrollY = window.scrollY;
applyFilters();
window.scrollTo(0, currentScrollY);
if (!isMobile) searchInput.focus();
});
}
// Filters and event configurations
const btnFilterMode = document.getElementById('btnFilterMode');
btnFilterMode.addEventListener('click', () => {
tokiState.filterMode = tokiState.filterMode === 'AND' ? 'OR' : 'AND';
btnFilterMode.innerText = `${tokiState.filterMode} 🔄`;
if (tokiState.filterMode === 'OR') btnFilterMode.classList.add('or-mode');
else btnFilterMode.classList.remove('or-mode');
applyFilters();
});
document.querySelectorAll('.state-toggle').forEach(btn => {
const val = btn.dataset.val;
if (tokiState.filters.states.includes(val)) btn.classList.add('active');
btn.addEventListener('click', function() {
this.classList.toggle('active');
if (this.classList.contains('active')) tokiState.filters.states.push(val);
else tokiState.filters.states = tokiState.filters.states.filter(v => v !== val);
applyFilters();
});
});
const genreDropdown = document.getElementById('genreDropdown');
const genreBtn = document.getElementById('btnFilterGenre');
genreBtn.addEventListener('click', (e) => {
e.stopPropagation();
genreDropdown.classList.toggle('show');
});
const btnCloseGenre = document.getElementById('btnCloseGenre');
if (btnCloseGenre) {
btnCloseGenre.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
genreDropdown.classList.remove('show');
});
}
document.querySelectorAll('.toki-genre-item').forEach(item => {
const val = item.dataset.val;
if (tokiState.filters.genres.includes(val)) item.classList.add('active');
item.addEventListener('click', (e) => {
e.stopPropagation();
item.classList.toggle('active');
const isActive = item.classList.contains('active');
if (isActive && !tokiState.filters.genres.includes(val)) tokiState.filters.genres.push(val);
else if (!isActive) tokiState.filters.genres = tokiState.filters.genres.filter(v => v !== val);
applyFilters();
});
});
document.getElementById('btnFilterReset').addEventListener('click', () => {
tokiState.filters = { states: [], genres: [] };
tokiState.searchQuery = '';
const sInput = document.getElementById('toki-search-input');
const sClear = document.getElementById('toki-search-clear');
if (sInput) sInput.value = '';
if (sClear) sClear.style.display = 'none';
document.querySelectorAll('.state-toggle').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.toki-genre-item').forEach(b => b.classList.remove('active'));
applyFilters();
});
// PC custom sort dropdown
const sortDropdown = document.getElementById('sortDropdown');
const sortBtn = document.getElementById('btnSortSelect');
if (sortBtn && sortDropdown) {
sortBtn.addEventListener('click', (e) => {
e.stopPropagation();
sortDropdown.classList.toggle('show');
});
document.querySelectorAll('.toki-sort-item').forEach(item => {
item.addEventListener('click', () => {
saveSortPref(item.dataset.val);
renderFavoritesList();
});
});
}
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) sortSelect.addEventListener('change', function() { saveSortPref(this.value); renderFavoritesList(); });
document.getElementById('sortDirBtn').addEventListener('click', function() { localStorage.setItem(SORT_ASC_KEY, !sortAsc); renderFavoritesList(); });
document.getElementById('pinUpCheck').addEventListener('change', function() { localStorage.setItem(PIN_UP_KEY, this.checked); renderFavoritesList(); });
document.getElementById('pinClrCheck').addEventListener('change', function() { localStorage.setItem(PIN_CLR_KEY, this.checked); renderFavoritesList(); });
document.getElementById('syncSettingsBtn').addEventListener('click', () => GistSync.showSettingsModal());
document.getElementById('checkUpdatesBtn').addEventListener('click', (e) => {
const isForce = e.shiftKey;
if (isForce) {
localStorage.removeItem(SCAN_QUEUE_KEY);
runBackgroundScan('force');
} else {
localStorage.removeItem(SCAN_QUEUE_KEY);
runBackgroundScan('smart');
}
});
document.getElementById('exportDataBtn').addEventListener('click', exportData);
document.getElementById('importDataBtn').addEventListener('click', importData);
}
// ==========================================
// 6. SPA Routing Observer
// ==========================================
let currentPath = location.pathname;
const observer = new MutationObserver(() => {
clearTimeout(tokiState.debounceTimer);
tokiState.debounceTimer = setTimeout(() => {
const newPath = location.pathname;
const isDetail = /^\/(manhwa|webtoon|novel)\/[^/]+$/i.test(newPath);
if (newPath !== currentPath) {
currentPath = newPath;
injectViewerUI();
if (isDetail) injectManhwaButton();
if (newPath === '/favorites') renderFavoritesPage();
return;
}
if (isViewerPage()) {
if (!isViewerUIApplied()) injectViewerUI();
} else if (isDetail) {
if (!document.getElementById(ID_MANHWA_BTN)) injectManhwaButton();
} else if (newPath === '/favorites') {
const dirty = localStorage.getItem(DIRTY_KEY);
if (!document.getElementById(ID_BOOKMARKS_WRAPPER) || dirty !== '0') {
renderFavoritesPage();
}
}
}, 150);
});
observer.observe(document.body, { childList: true, subtree: true });
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
const genreDropdown = document.getElementById('genreDropdown');
const genreBtn = document.getElementById('btnFilterGenre');
const sortDropdown = document.getElementById('sortDropdown');
const sortBtn = document.getElementById('btnSortSelect');
if (genreDropdown && genreBtn && !genreDropdown.contains(e.target) && e.target !== genreBtn) {
genreDropdown.classList.remove('show');
}
if (sortDropdown && sortBtn && !sortDropdown.contains(e.target) && e.target !== sortBtn) {
sortDropdown.classList.remove('show');
}
});
// Run emergency recovery and periodic backup before initializing layout
TokiSafetyGuard.performEmergencyRecovery();
injectViewerUI();
if (/^\/(manhwa|webtoon|novel)\/[^/]+$/i.test(location.pathname)) injectManhwaButton();
if (location.pathname === '/favorites') renderFavoritesPage();
// Trigger Automatic Background Scan on page load
runBackgroundScan('auto');
// ==========================================
// 🧪 Test Automation Bridge for Test Suite
// ==========================================
if (typeof window !== 'undefined') {
window.tokiState = tokiState;
window.renderFavoritesList = renderFavoritesList;
window.renderFavoritesPage = renderFavoritesPage;
window.injectViewerUI = injectViewerUI;
window.getBookmarks = getBookmarks;
window.saveBookmarks = saveBookmarks;
window.extractShortEp = extractShortEp;
window.TokiUtils = TokiUtils;
window.TokiSafetyGuard = TokiSafetyGuard;
}
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && typeof GistSync !== 'undefined' && GistSync.getMode()) {
if (localStorage.getItem('toki_unpushed_changes') === 'true') {
GistSync.push(true);
}
}
});
})();