코네에서 미구현된 유저 및 서브 차단 및 제외 기능을 구현하고 관리합니다.
// ==UserScript==
// @name KoneUserSubBlocker
// @name:ko 코네:유저&서브 Blocker
// @namespace http://tampermonkey.net/*
// @version 2.7
// @license MIT
// @description 코네에서 미구현된 유저 및 서브 차단 및 제외 기능을 구현하고 관리합니다.
// @author cloud67P
// @match https://kone.gg/*
// @exclude https://kone.gg/s/*/write
// @icon https://www.google.com/s2/favicons?sz=64&domain=kone.gg
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect api.github.com
// @grant unsafeWindow
// @run-at document-end
// ==/UserScript==
/* 찾은 버그나 수정사항 적어두는 곳
버그1
Ghost 탈퇴계정 01:57
[차단되어 숨겨진 내용]비밀번호가 바로 변경되는 건 이상하긴 하네
이런식으로 숨겨져야하는데 본 내용이 뒤에 붙어 나오는 버그 고쳐야함
버그2
제외한 서브페이지 제외설정으로 게시글 숨기기 처리해도 표시 아래에 다시 정상적으로 나올때가 존재함
개선사항
게시글에서는 댓글쪽 핸들을 우선 처리후 아래쪽에 게시물 차단 관리하기
지금은 게시물 처음 접근시 조금 늦게 처리되고 새로고침등으로 접근시 핸들이 안달리는 수준(간혹 기다리면 됨 조금 오래) 이거 원인 찾아보기
*/
(function () {
'use strict';
const ARTICLE_SELECTOR = 'div.overflow-hidden .flex.items-end a.hover\\:underline span';
const DB_NAME = 'koneUserNoteDB';
const STORE_NAME = 'users_v3';
const OLD_STORE_NAME = 'users';
const SETTINGS_STORE = 'settings';
// ====== 앱 상태 ======
const State = {
isHidden: true,
titlePatternsRaw: [],
titlePatterns: [],
titleExcludePatternsRaw: [],
titleExcludePatterns: [],
userCache: new Map(),
showButtonDebounceTimer: null,
isManageUIOpen: false,
isEnabled: GM_getValue('kone_block_enabled', true),
pendingUpdateReason: null,
updateDebounceTimer: null,
observer: null,
isUpdateRunning: false,
isInjectHandlesRunning: false,
shouldRerunInjectHandles: false,
hiddenUsers: new Set(),
excludedUsers: new Set(),
hiddenSubs: new Set(),
usernameToHandles: new Map(),
handleCache: (() => {
try {
const raw = sessionStorage.getItem('koneHandleCache');
return raw ? JSON.parse(raw) : {};
} catch (e) {
return {};
}
})(),
handleCacheOrder: (() => {
try {
const raw = sessionStorage.getItem('koneHandleCacheOrder');
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
})()
};
// ====== 탭 간 동기화 ======
const syncChannel = new BroadcastChannel('kone_user_sub_black_sync');
syncChannel.onmessage = async (event) => {
if (event.data === 'update_all') {
await refreshUserCache();
await loadTitleFilters();
if (typeof updateAll === 'function') {
await updateAll();
}
}
};
const HANDLE_CACHE_MAX = 1200;
function normalizeHandleCacheOrder() {
const keys = Object.keys(State.handleCache);
const keySet = new Set(keys);
const normalized = [];
const seen = new Set();
for (const k of State.handleCacheOrder) {
if (!keySet.has(k) || seen.has(k)) continue;
seen.add(k);
normalized.push(k);
}
for (const k of keys) {
if (seen.has(k)) continue;
normalized.push(k);
}
State.handleCacheOrder = normalized;
}
function touchHandleCacheKey(key) {
const idx = State.handleCacheOrder.indexOf(key);
if (idx >= 0) State.handleCacheOrder.splice(idx, 1);
State.handleCacheOrder.push(key);
}
function getHandleCacheValue(url) {
if (State.handleCache[url] === undefined) return undefined;
touchHandleCacheKey(url);
return State.handleCache[url];
}
function setHandleCacheValue(url, value) {
State.handleCache[url] = value;
touchHandleCacheKey(url);
if (State.handleCacheOrder.length > HANDLE_CACHE_MAX) {
const removeCount = State.handleCacheOrder.length - HANDLE_CACHE_MAX;
for (let i = 0; i < removeCount; i++) {
const oldest = State.handleCacheOrder.shift();
if (oldest !== undefined) delete State.handleCache[oldest];
}
}
}
function pruneOldestHandleCacheEntries(count) {
for (let i = 0; i < count && State.handleCacheOrder.length > 0; i++) {
const oldest = State.handleCacheOrder.shift();
if (oldest !== undefined) delete State.handleCache[oldest];
}
}
function saveHandleCache() {
normalizeHandleCacheOrder();
for (let attempt = 0; attempt < 6; attempt++) {
try {
sessionStorage.setItem('koneHandleCache', JSON.stringify(State.handleCache));
sessionStorage.setItem('koneHandleCacheOrder', JSON.stringify(State.handleCacheOrder));
return;
} catch (e) {
const len = State.handleCacheOrder.length;
if (len === 0) return;
const pruneCount = Math.max(20, Math.ceil(len * 0.2));
pruneOldestHandleCacheEntries(pruneCount);
}
}
}
normalizeHandleCacheOrder();
function getPostTargets() {
return [
...document.querySelectorAll('div.group\\/post-wrapper'),
...document.querySelectorAll('div.grow.flex div.flex-col div.grid > div.contents'),
...Array.from(document.querySelectorAll('div.grow.grid > div.relative')).filter(el =>
el.querySelector('.aspect-square')
)
];
}
function getRowMeta(el) {
const isGalleryView = !!el.querySelector('.aspect-square');
if (isGalleryView) {
const writerDiv = el.querySelector('#writer_link') ||
el.querySelector('.overflow-hidden.text-center.text-nowrap.whitespace-nowrap.text-ellipsis') ||
el.querySelector('.overflow-hidden.text-center.whitespace-nowrap.text-ellipsis');
const subSpan = el.querySelector('.shrink-0 > span');
const titleSpan = el.querySelector('.text-ellipsis.line-clamp-2');
const subText = subSpan ? subSpan.textContent.trim() : '';
return {
handle: writerDiv?.dataset.handle || writerDiv?.textContent.trim() || '',
subName: subText.replace(/\s*\|.*$/, '').trim().toLowerCase(),
title: titleSpan ? titleSpan.textContent.trim() : '',
isGalleryView
};
}
const writerEl = el.querySelector('#writer_link') ||
el.querySelector('.overflow-hidden.text-center.text-nowrap.whitespace-nowrap.text-ellipsis') ||
el.querySelector('.overflow-hidden.text-center.whitespace-nowrap.text-ellipsis') ||
el.querySelector('div.overflow-hidden .flex.items-end a.hover\\:underline span');
const tabNameEl = el.querySelector('.col-span-2 > div');
const titleSpan = el.querySelector('span.overflow-hidden.text-nowrap.text-ellipsis');
return {
handle: writerEl?.dataset.handle || '',
subName: tabNameEl?.innerText.trim().toLowerCase() || '',
title: titleSpan ? titleSpan.textContent.trim() : '',
isGalleryView
};
}
function getHiddenSets() {
return {
hiddenUsers: State.hiddenUsers,
excludedUsers: State.excludedUsers,
hiddenSubs: State.hiddenSubs
};
}
function classifyRow(el, context = {}) {
const {
hiddenUsers = State.hiddenUsers,
excludedUsers = State.excludedUsers,
hiddenSubs = State.hiddenSubs,
excludedSubs = [],
titlePatterns = State.titlePatterns,
titleExcludePatterns = State.titleExcludePatterns
} = context;
const { handle, subName, title, isGalleryView } = getRowMeta(el);
const normalizedHandle = handle ? handle.toLowerCase() : '';
const isUserExcluded = normalizedHandle && excludedUsers.has(normalizedHandle);
const isSubExcluded = subName && excludedSubs.includes(subName);
const isTitleExcluded = titleExcludePatterns.some(p => p.test(title));
const isBlocked =
(normalizedHandle && hiddenUsers.has(normalizedHandle)) ||
(subName && hiddenSubs.has(subName)) ||
titlePatterns.some(p => p.test(title));
return {
handle,
normalizedHandle,
subName,
title,
isGalleryView,
isUserExcluded,
isSubExcluded,
isTitleExcluded,
isExcluded: !!(isUserExcluded || isSubExcluded || isTitleExcluded),
isBlocked: !!isBlocked
};
}
function buildUsernameIndex() {
const index = new Map();
State.userCache.forEach((u, handle) => {
if (u.type !== 'user') return;
if (!u.block && !u.excluded) return;
const usernames = Array.isArray(u.username) ? u.username : [];
usernames.forEach(name => {
const key = (name || '').toLowerCase().trim();
if (!key) return;
if (!index.has(key)) index.set(key, new Set());
index.get(key).add(handle);
});
});
State.usernameToHandles = index;
}
function rebuildHiddenSetsFromCache() {
const hiddenUsers = new Set();
const excludedUsers = new Set();
const hiddenSubs = new Set();
State.userCache.forEach((u, uid) => {
const handle = (u.handle || '').toString().toLowerCase();
if (!handle) return;
if (u.type === 'user') {
if (u.block) hiddenUsers.add(handle);
if (u.excluded) excludedUsers.add(handle);
} else if (u.type === 'sub' && u.block) {
hiddenSubs.add(handle);
}
});
State.hiddenUsers = hiddenUsers;
State.excludedUsers = excludedUsers;
State.hiddenSubs = hiddenSubs;
}
async function reRenderManageUI() {
const modal = document.getElementById('kone-manage-modal');
if (modal) {
modal.remove();
State.isManageUIOpen = false;
await showManageUI();
State.isManageUIOpen = true;
}
}
const USER_BLOCK_MENU_STYLE = {
position: 'absolute',
background: '#222',
color: '#fff',
padding: '10px 18px',
borderRadius: '8px',
fontSize: '14px',
zIndex: 999999,
boxShadow: '0 2px 8px rgba(0,0,0,0.16)'
};
// ====== IndexedDB 함수들 ======
const DB = (() => {
let dbPromise = null;
function open() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 3);
request.onupgradeneeded = (e) => {
const db = e.target.result;
const transaction = e.target.transaction;
const oldVersion = e.oldVersion;
if (!db.objectStoreNames.contains(SETTINGS_STORE)) {
db.createObjectStore(SETTINGS_STORE, { keyPath: 'key' });
}
if (oldVersion < 3) {
// users_v3가 없으면 생성
let newStore;
if (!db.objectStoreNames.contains(STORE_NAME)) {
newStore = db.createObjectStore(STORE_NAME, { keyPath: 'uid' });
} else {
newStore = transaction.objectStore(STORE_NAME);
}
// 기존 'users' (v2 이하) 데이터 마이그레이션
if (db.objectStoreNames.contains(OLD_STORE_NAME)) {
const oldStore = transaction.objectStore(OLD_STORE_NAME);
oldStore.openCursor().onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const record = cursor.value;
const type = record.type || 'user';
const handle = record.handle;
if (handle && typeof handle === 'string') {
record.uid = `${type}:${handle.toLowerCase()}`;
newStore.put(record);
}
cursor.continue();
}
};
}
} else if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'uid' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
}).catch((err) => {
dbPromise = null;
throw err;
});
return dbPromise;
}
return { open };
})();
async function openDB() {
return DB.open();
}
const SettingsRepo = {
async get(key, defaultValue = null) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(SETTINGS_STORE, 'readonly');
const req = tx.objectStore(SETTINGS_STORE).get(key);
req.onsuccess = () => resolve(req.result ? req.result.value : defaultValue);
req.onerror = () => reject(req.error);
});
},
async set(key, value) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(SETTINGS_STORE, 'readwrite');
tx.objectStore(SETTINGS_STORE).put({ key, value });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
};
const UserRepo = {
async getAllRaw() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
},
async get(handle, type = 'user') {
if (!handle) return null;
const uid = `${type}:${handle.toString().toLowerCase()}`;
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const req = tx.objectStore(STORE_NAME).get(uid);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
},
async put(record) {
if (!record.handle) throw new Error('Handle is required');
const type = record.type || 'user';
const handle = record.handle.toString();
record.uid = `${type}:${handle.toLowerCase()}`;
record.updatedAt = Date.now();
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(record);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async bulkPut(records) {
const now = Date.now();
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
(records || []).forEach(record => {
if (!record.handle) return;
const type = record.type || 'user';
const handle = record.handle.toString();
record.uid = `${type}:${handle.toLowerCase()}`;
if (!record.updatedAt) record.updatedAt = now;
store.put(record);
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async delete(handle, type = 'user') {
if (!handle) return;
const uid = `${type}:${handle.toString().toLowerCase()}`;
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(uid);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async clearAll() {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const req = tx.objectStore(STORE_NAME).clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
};
async function getTitleFilters() {
return SettingsRepo.get('titleFilters', []);
}
function normalizeStringArray(values, { lowerCase = true, sort = true } = {}) {
if (!Array.isArray(values)) return [];
const seen = new Set();
const normalized = [];
values.forEach(v => {
if (typeof v !== 'string') return;
let value = v.trim();
if (!value) return;
if (lowerCase) value = value.toLowerCase();
if (seen.has(value)) return;
seen.add(value);
normalized.push(value);
});
if (sort) normalized.sort((a, b) => a.localeCompare(b, 'ko-KR'));
return normalized;
}
async function saveTitleFilters(filters) {
return SettingsRepo.set('titleFilters', normalizeStringArray(filters, { lowerCase: false }));
}
async function getTitleExcludeFilters() {
return SettingsRepo.get('titleExcludeFilters', []);
}
async function saveTitleExcludeFilters(filters) {
return SettingsRepo.set('titleExcludeFilters', normalizeStringArray(filters, { lowerCase: false }));
}
function getSubModeFromContext(subName, context = {}) {
const subLower = (subName || '').toString().trim().toLowerCase();
if (!subLower) return 'none';
const hiddenSubs = context.hiddenSubs || State.hiddenSubs;
const excludedSubs = context.excludedSubs || [];
const excludedSubSet = excludedSubs instanceof Set
? excludedSubs
: new Set(normalizeStringArray(excludedSubs));
if (excludedSubSet.has(subLower)) return 'excluded';
if (hiddenSubs && hiddenSubs.has(subLower)) return 'blocked';
return 'none';
}
async function getSubMode(subName) {
const { hiddenSubs } = getHiddenSets();
const excludedSubs = await getExcludedSubs();
return getSubModeFromContext(subName, { hiddenSubs, excludedSubs });
}
async function getExcludedSubs() {
const subs = await SettingsRepo.get('excludedSubs', []);
return normalizeStringArray(subs);
}
async function saveExcludedSubs(subs) {
return SettingsRepo.set('excludedSubs', normalizeStringArray(subs));
}
async function _toggleExcludedSub(subName) {
const excluded = await getExcludedSubs();
const index = excluded.indexOf(subName);
if (index > -1) {
excluded.splice(index, 1);
} else {
excluded.push(subName);
}
await saveExcludedSubs(excluded);
await refreshUserCache();
return excluded;
}
async function toggleExcludedSub(subName) {
return new Promise(resolve => {
SyncQueue.enqueue(async () => {
const res = await _toggleExcludedSub(subName);
resolve(res);
});
});
}
async function _addExcludedSub(subName) {
const subLower = subName.trim().toLowerCase();
const excluded = await getExcludedSubs();
if (!excluded.includes(subLower)) {
excluded.push(subLower);
await saveExcludedSubs(excluded);
await refreshUserCache();
}
return excluded;
}
async function addExcludedSub(subName) {
return new Promise(resolve => {
SyncQueue.enqueue(async () => {
const res = await _addExcludedSub(subName);
resolve(res);
});
});
}
// [근본 해결] 서브 모드 통합 관리 (상호 배타적 적용)
async function _setSubMode(subName, mode) {
const subLower = subName.toLowerCase();
// 1. 제외 목록 관리
const excluded = await getExcludedSubs();
const exIndex = excluded.indexOf(subLower);
if (mode === 'excluded') {
if (exIndex === -1) excluded.push(subLower);
} else {
if (exIndex > -1) excluded.splice(exIndex, 1);
}
await saveExcludedSubs(excluded);
// 2. 차단 DB 관리
if (mode === 'blocked') {
await _addBlockUser(subLower, '', 'sub');
} else if (mode === 'none' || mode === 'excluded') {
await _removeUser(subLower, 'sub');
}
await refreshUserCache();
}
async function setSubMode(subName, mode) {
return new Promise(resolve => {
SyncQueue.enqueue(async () => {
await _setSubMode(subName, mode);
resolve();
});
});
}
async function loadTitleFilters() {
State.titlePatternsRaw = normalizeStringArray(await getTitleFilters(), { lowerCase: false });
State.titlePatterns = State.titlePatternsRaw.map(p => {
if (typeof p === 'string') {
const escaped = p.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
return new RegExp(escaped, 'i');
}
return p;
});
State.titleExcludePatternsRaw = normalizeStringArray(await getTitleExcludeFilters(), { lowerCase: false });
State.titleExcludePatterns = State.titleExcludePatternsRaw.map(p => {
if (typeof p === 'string') {
const escaped = p.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
return new RegExp(escaped, 'i');
}
return p;
});
}
async function getAllUser() {
const allUsers = await UserRepo.getAllRaw();
return allUsers.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).map(u => ({
handle: u.handle,
block: u.block,
excluded: u.excluded || false,
username: u.username,
note: u.note,
type: u.type || 'user',
updatedAt: u.updatedAt || 0
}));
}
async function getUser(handle, type = 'user') {
handle = handle.toLowerCase();
return UserRepo.get(handle, type);
}
async function getNote(handle, type = 'user') {
handle = handle.toLowerCase();
const user = await UserRepo.get(handle, type);
return user && user.note ? user.note : '';
}
async function _addBlockUser(handle, username, type = 'user', note = '') {
handle = handle.toLowerCase();
username = username ? username.toLowerCase() : '';
let user = await getUser(handle, type);
let usernameArr = [];
if (user) {
const set = new Set(user.username ? user.username.slice() : []);
if (username) set.add(username);
usernameArr = Array.from(set);
await UserRepo.put({
handle,
block: true,
excluded: type === 'user' ? false : (user.excluded || false),
username: usernameArr,
note: note ? note : (user.note || ''),
type: type
});
await refreshUserCache();
return;
} else {
usernameArr = username ? [username] : [];
await UserRepo.put({
handle,
block: true,
excluded: false,
username: usernameArr,
note: note || '',
type: type
});
await refreshUserCache();
return;
}
}
async function addBlockUser(handle, username, type = 'user', note = '') {
return new Promise(resolve => {
SyncQueue.enqueue(async () => {
await _addBlockUser(handle, username, type, note);
resolve();
});
});
}
async function _addExcludedUser(handle, username, note = '') {
handle = handle.toLowerCase();
username = username ? username.toLowerCase() : '';
const user = await getUser(handle, 'user');
const set = new Set((user && Array.isArray(user.username)) ? user.username : []);
if (username) set.add(username);
await UserRepo.put({
handle,
block: false,
excluded: true,
username: Array.from(set),
note: note ? note : (user?.note || ''),
type: 'user'
});
await refreshUserCache();
}
async function addExcludedUser(handle, username, note = '') {
return new Promise(resolve => {
SyncQueue.enqueue(async () => {
await _addExcludedUser(handle, username, note);
resolve();
});
});
}
async function _addNote(handle, username, note, overwrite = false, type = 'user') {
handle = handle.toLowerCase();
// username이 없거나 빈 문자열이면 빈 문자열로 처리
username = (username && typeof username === 'string') ? username.toLowerCase() : '';
let user = await getUser(handle, type);
let usernameArr = [];
if (user) {
// user.username이 배열이 아닌 경우도 방어
const existingUsernames = Array.isArray(user.username) ? user.username : [];
const set = new Set(existingUsernames);
if (username) set.add(username); // 빈 문자열은 Set에 추가 안 함
usernameArr = Array.from(set);
await UserRepo.put({
handle,
block: user.block,
excluded: user.excluded || false,
username: usernameArr,
note: overwrite ? note : (user.note ? (user.note + '\n' + note) : note),
type: type
});
await refreshUserCache();
return;
} else {
usernameArr = username ? [username] : []; // 빈 문자열이면 빈 배열
await UserRepo.put({
handle,
block: false,
excluded: false,
username: usernameArr,
note: note,
type: type
});
await refreshUserCache();
return;
}
}
async function addNote(handle, username, note, overwrite = false, type = 'user') {
return new Promise(resolve => {
SyncQueue.enqueue(async () => {
await _addNote(handle, username, note, overwrite, type);
resolve();
});
});
}
async function _removeUser(handle, type = 'user') {
handle = handle.toLowerCase();
const res = await UserRepo.delete(handle, type);
await refreshUserCache();
return res;
}
async function removeUser(handle, type = 'user') {
return new Promise(resolve => {
SyncQueue.enqueue(async () => {
const res = await _removeUser(handle, type);
resolve(res);
});
});
}
// ====== GitHub Gist 동기화 ======
function extractRemoteTitleFilters(remote) {
const blockTitle = Array.isArray(remote?.filters?.blockTitle) ? remote.filters.blockTitle : remote?.titleFilters;
const excludeTitle = Array.isArray(remote?.filters?.excludeTitle) ? remote.filters.excludeTitle : remote?.titleExcludeFilters;
return {
titleFilters: normalizeStringArray(blockTitle || [], { lowerCase: false }),
titleExcludeFilters: normalizeStringArray(excludeTitle || [], { lowerCase: false })
};
}
function extractRemoteTransferData(remote) {
const blockedUsers = Array.isArray(remote?.users?.blocked)
? remote.users.blocked
: (Array.isArray(remote?.users) ? remote.users.filter(u => u?.type === 'user' && u?.block) : []);
const excludedUsers = Array.isArray(remote?.users?.excluded)
? remote.users.excluded
: (Array.isArray(remote?.users) ? remote.users.filter(u => u?.type === 'user' && u?.excluded) : []);
const notes = Array.isArray(remote?.users?.notes)
? remote.users.notes
: (Array.isArray(remote?.users) ? remote.users.filter(u => u?.type === 'user' && typeof u?.note === 'string' && u.note.trim() !== '').map(u => ({ handle: u.handle, note: u.note })) : []);
let subs = [];
if (Array.isArray(remote?.subs)) {
subs = remote.subs;
} else {
const legacyBlockedSubs = Array.isArray(remote?.users)
? remote.users.filter(u => u?.type === 'sub' && u?.block).map(u => ({ handle: u.handle, mode: 'blocked' }))
: [];
const legacyExcludedSubs = Array.isArray(remote?.excludedSubs)
? remote.excludedSubs.map(handle => ({ handle, mode: 'excluded' }))
: [];
subs = [...legacyBlockedSubs, ...legacyExcludedSubs];
}
return {
users: {
blocked: blockedUsers,
excluded: excludedUsers,
notes
},
subs
};
}
function buildUserRepoRecordsFromTransferData(transferData) {
const blockedSubs = transferData.subs.filter(item => item?.mode === 'blocked');
const excludedSubs = normalizeStringArray(
transferData.subs.filter(item => item?.mode === 'excluded').map(item => item.handle)
);
const blockedUsers = normalizeUserRuleItems(transferData.users.blocked);
const excludedUsers = normalizeUserRuleItems(transferData.users.excluded);
const noteUsers = normalizeUserNoteItems(transferData.users.notes);
const userMap = new Map();
const getOrCreateUserRecord = (handle) => {
if (!userMap.has(handle)) {
userMap.set(handle, {
handle,
block: false,
excluded: false,
username: [],
note: '',
type: 'user',
updatedAt: 0
});
}
return userMap.get(handle);
};
blockedUsers.forEach(item => {
const rec = getOrCreateUserRecord(item.handle);
rec.block = true;
rec.username = normalizeStringArray([...rec.username, ...item.usernames], { lowerCase: true });
if (item.updatedAt) rec.updatedAt = Math.max(rec.updatedAt, item.updatedAt);
});
excludedUsers.forEach(item => {
const rec = getOrCreateUserRecord(item.handle);
rec.excluded = true;
rec.username = normalizeStringArray([...rec.username, ...item.usernames], { lowerCase: true });
if (item.updatedAt) rec.updatedAt = Math.max(rec.updatedAt, item.updatedAt);
});
noteUsers.forEach(item => {
const rec = getOrCreateUserRecord(item.handle);
rec.note = item.note;
if (item.updatedAt) rec.updatedAt = Math.max(rec.updatedAt, item.updatedAt);
});
const subRecords = blockedSubs.map(item => ({
handle: item.handle,
block: true,
excluded: false,
username: [],
note: '',
type: 'sub',
updatedAt: item.updatedAt || 0
}));
return {
records: [...subRecords, ...Array.from(userMap.values())],
excludedSubs
};
}
const GistSync = {
getToken: () => GM_getValue('gist_token', ''),
getGistId: () => GM_getValue('gist_id', ''),
getLastSync: () => GM_getValue('gist_last_sync', 0),
getLocalUpdatedAt: () => GM_getValue('gist_local_updated_at', 0),
setToken: (v) => GM_setValue('gist_token', v),
setGistId: (v) => GM_setValue('gist_id', v),
setLastSync: (v) => GM_setValue('gist_last_sync', v),
setLocalUpdatedAt: (v) => GM_setValue('gist_local_updated_at', v),
async request(method, gistId, data) {
const token = this.getToken();
if (!token) throw new Error('토큰이 설정되지 않았습니다.');
const url = gistId
? `https://api.github.com/gists/${gistId}`
: 'https://api.github.com/gists';
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers: {
'Authorization': `token ${token}`,
'Content-Type': 'application/json'
},
data: data ? JSON.stringify(data) : undefined,
timeout: 15000,
onload: (res) => {
try { resolve(JSON.parse(res.responseText)); }
catch (e) { reject(new Error('응답 파싱 실패')); }
},
onerror: () => reject(new Error('네트워크 오류')),
ontimeout: () => reject(new Error('요청 시간 초과'))
});
});
},
async buildPayload() {
const allUsers = await getAllUser();
const excludedSubs = await getExcludedSubs();
const titleFilters = await getTitleFilters();
const titleExcludeFilters = await getTitleExcludeFilters();
const transferData = await buildOptimizedExportData(
allUsers,
excludedSubs,
titleFilters,
titleExcludeFilters
);
const kstTime = new Date(Date.now() + 9 * 3600 * 1000).toISOString().replace('T', ' ').substring(0, 19);
return {
version: transferData.version,
updatedAt: Date.now(),
updatedDate: kstTime,
users: transferData.users,
subs: transferData.subs,
filters: transferData.filters
};
},
async upload() {
const token = this.getToken();
if (!token) throw new Error('토큰이 설정되지 않았습니다.');
const payload = await this.buildPayload();
const fileContent = { files: { 'kone-settings.json': { content: JSON.stringify(payload, null, 2) } } };
let gistId = this.getGistId();
let result;
if (gistId) {
result = await this.request('PATCH', gistId, fileContent);
} else {
result = await this.request('POST', null, { ...fileContent, public: false, description: 'kone userscript settings' });
this.setGistId(result.id);
}
this.setLocalUpdatedAt(payload.updatedAt);
this.setLastSync(Date.now());
return result;
},
async download() {
const gistId = this.getGistId();
if (!gistId) return { updated: false, reason: 'no_gist_id' };
const result = await this.request('GET', gistId);
const file = result.files?.['kone-settings.json'];
if (!file) return { updated: false, reason: 'no_file' };
const remote = JSON.parse(file.content);
const localUpdatedAt = this.getLocalUpdatedAt();
if (remote.updatedAt <= localUpdatedAt) {
this.setLastSync(Date.now());
return { updated: false, reason: 'already_latest' };
}
// 원격이 더 최신 → 적용
const remoteFilters = extractRemoteTitleFilters(remote);
const transferData = extractRemoteTransferData(remote);
const converted = buildUserRepoRecordsFromTransferData(transferData);
await UserRepo.clearAll();
await UserRepo.bulkPut(converted.records);
await saveExcludedSubs(converted.excludedSubs);
await saveTitleFilters(remoteFilters.titleFilters);
await saveTitleExcludeFilters(remoteFilters.titleExcludeFilters);
await loadTitleFilters();
await refreshUserCache(); // <-- 메모리 캐시 갱신 추가
this.setLocalUpdatedAt(remote.updatedAt);
this.setLastSync(Date.now());
return { updated: true };
},
async autoSync() {
if (!this.getToken() || !this.getGistId()) return;
const INTERVAL = 60 * 60 * 1000; // 1시간
if (Date.now() - this.getLastSync() < INTERVAL) return;
try {
const result = await this.download();
if (result.updated) {
await updateAll();
showSyncToast('☁️ 설정이 동기화되었습니다.');
}
} catch (e) {
console.warn('[GistSync] 자동 동기화 실패:', e.message);
}
}
};
const SyncQueue = (() => {
let queue = [];
let isProcessing = false;
let needsUpload = false;
async function processQueue() {
if (isProcessing || queue.length === 0) return;
isProcessing = true;
const isUIOpen = State.isManageUIOpen;
if (!isUIOpen) showSyncToast('🔄 동기화 중...', 0);
try {
if (!isUIOpen && GistSync.getToken() && GistSync.getGistId()) {
await GistSync.download();
}
while (queue.length > 0) {
const task = queue.shift();
try {
await task();
needsUpload = true;
} catch (e) {
console.error('[SyncQueue] Task Error:', e);
}
}
if (!isUIOpen && needsUpload) {
if (GistSync.getToken() && GistSync.getGistId()) {
await GistSync.upload();
showSyncToast('✅ 저장 및 동기화 완료');
} else {
showSyncToast('✅ 로컬에 저장 완료 (동기화 미설정)');
}
needsUpload = false;
}
if (needsUpload) {
await updateAll();
}
} catch (e) {
console.error('[SyncQueue] Sync Error:', e);
while (queue.length > 0) {
const task = queue.shift();
try {
await task();
needsUpload = true;
} catch (e) { }
}
if (needsUpload) {
await updateAll();
}
showSyncToast('⚠️ 로컬에 저장되었습니다 (동기화 실패)');
} finally {
isProcessing = false;
syncChannel.postMessage('update_all');
}
}
return {
enqueue: (taskFn) => {
queue.push(taskFn);
processQueue();
},
setNeedsUpload: (val) => { needsUpload = val; },
forceSync: async () => {
if (needsUpload) {
showSyncToast('🔄 종료 중 동기화...', 0);
try {
if (GistSync.getToken()) {
await GistSync.upload();
showSyncToast('✅ 저장 및 동기화 완료');
} else {
showSyncToast('✅ 로컬에 저장 완료');
}
needsUpload = false;
} catch (e) {
console.error('[SyncQueue] Force Sync Error:', e);
showSyncToast('⚠️ 동기화 실패');
}
}
}
};
})();
let _activeToast = null;
let _toastTimeout = null;
let _toastRemoveTimeout = null;
function showSyncToast(msg, duration = 2500) {
if (!_activeToast) {
_activeToast = document.createElement('div');
_activeToast.style.cssText = `
position:fixed;bottom:70px;right:20px;
background:#333;color:#fff;padding:8px 16px;
border-radius:6px;font-size:13px;z-index:9999999;
box-shadow:0 2px 8px rgba(0,0,0,0.4);
transition:opacity 0.5s ease;
`;
document.body.appendChild(_activeToast);
}
_activeToast.textContent = msg;
_activeToast.style.opacity = '1';
if (_toastTimeout) clearTimeout(_toastTimeout);
if (_toastRemoveTimeout) clearTimeout(_toastRemoveTimeout);
if (duration > 0) {
_toastTimeout = setTimeout(() => {
if (_activeToast) _activeToast.style.opacity = '0';
}, duration);
_toastRemoveTimeout = setTimeout(() => {
if (_activeToast) {
_activeToast.remove();
_activeToast = null;
}
}, duration + 500);
}
}
// ====== 핸들 주입 (fetch 방식) ======
// 게시글 URL에서 /u/{handle} 패턴으로 핸들 추출
function fetchHandleFromUrl(url) {
return new Promise((resolve) => {
const cached = getHandleCacheValue(url);
if (cached !== undefined) {
resolve(cached);
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 10000,
onload: (res) => {
const html = res.responseText || '';
// href="/u/handle" 속성값에서만 추출 (본문/스크립트 오탐 방지)
const match = html.match(/href=["']\/u\/([a-zA-Z0-9_\-.]+)["']/);
if (match) {
const handle = match[1].toLowerCase();
setHandleCacheValue(url, handle);
resolve(handle);
return;
}
setHandleCacheValue(url, null);
resolve(null);
},
onerror: () => { resolve(null); },
ontimeout: () => { resolve(null); }
});
});
}
// 게시글 목록 행에서 게시글 링크 URL 추출
function getArticleUrlFromRow(row) {
// 리스트 뷰: div.group/post-wrapper 안의 제목 링크
const titleLink = row.querySelector('a[href*="/s/"]');
if (titleLink && titleLink.href) return titleLink.href;
return null;
}
// 갤러리 뷰 행에서 게시글 링크 URL 추출
function getArticleUrlFromGalleryRow(row) {
const link = row.querySelector('a.group[href*="/s/"]');
if (link && link.href) return link.href;
return null;
}
// ====== 핸들 태그 삽입 헬퍼 ======
// userCache(메모리 Map)를 사용 - DB 접근 없음
async function insertHandleTag(writerEl, handle) {
if (!writerEl || !handle) return;
// 이미 삽입된 태그 탐색 (nextSibling 방식 우선 - 더 정확)
let tag = null;
let sib = writerEl.nextSibling;
while (sib) {
if (sib.nodeType === 1 && sib.classList && sib.classList.contains('kone-handle-tag')) {
tag = sib;
break;
}
sib = sib.nextSibling;
}
// userCache에서 메모/차단 정보 읽기 (DB 접근 없음)
const uid = `user:${handle.toLowerCase()}`;
const userData = State.userCache.get(uid);
const hasBlock = userData?.block || false;
const isExcluded = userData?.excluded || false;
const note = userData?.note || '';
const hasNote = note.trim() !== '';
const color = isExcluded ? '#f44' : hasBlock ? '#ff6c3b' : hasNote ? '#1fcc26' : '#888';
if (tag) {
// 이미 있는 태그: handle과 색상만 갱신
tag.dataset.handle = handle;
tag.style.color = color;
tag.title = hasNote ? `메모: ${note}` : handle;
return;
}
tag = document.createElement('span');
tag.className = 'kone-handle-tag';
tag.textContent = `(@${handle})`;
tag.dataset.handle = handle;
tag.style.cssText = `
color: ${color};
font-size: 11px;
margin-left: 4px;
cursor: pointer;
user-select: none;
flex-shrink: 0;
white-space: nowrap;
`;
tag.title = hasNote ? `메모: ${note}` : handle;
tag.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const h = tag.dataset.handle;
if (!h) return;
const username = writerEl.innerText
? writerEl.innerText.trim()
: writerEl.textContent.trim();
// 클릭 시점엔 최신 DB 값 조회 (메뉴는 드물게 열림)
const currentNote = await getNote(h);
const noteDisplay = currentNote || '현재 작성된 메모 없음';
createClickMenu(tag, [
['사용자 페이지', () => window.open(`/u/${h}`, '_blank')],
['사용자 차단', async () => {
const memo = prompt(
`[${username}(${h})]을(를) 차단합니다.\n메모를 남기시겠습니까? (선택사항)`,
""
);
if (memo !== null) {
await addBlockUser(h, username, 'user', memo.trim());
}
}],
['사용자 제외', async () => {
const memo = prompt(
`[${username}(${h})]을(를) 제외합니다.\n메모를 남기시겠습니까? (선택사항)`,
""
);
if (memo !== null) {
await addExcludedUser(h, username, memo.trim());
}
}],
['메모 수정/추가', async () => {
const newNote = prompt(
`[${username}(${h})]에게 남길 메모를 입력:`,
currentNote || ""
);
if (newNote !== null) await addNote(h, username, newNote.trim(), true);
}],
[`현재 메모: ${noteDisplay}`, null]
]);
}, true);
if (writerEl.parentNode) {
writerEl.parentNode.insertBefore(tag, writerEl.nextSibling);
}
}
// 병렬 fetch로 목록 전체 핸들 주입
const MAX_FETCH = 6;
const WRITER_SELECTORS = [
'#writer_link',
'.overflow-hidden.text-center.text-nowrap.whitespace-nowrap.text-ellipsis',
'.overflow-hidden.text-center.whitespace-nowrap.text-ellipsis'
];
function getWriterCandidates(row) {
const seen = new Set();
const result = [];
WRITER_SELECTORS.forEach(sel => {
row.querySelectorAll(sel).forEach(el => {
if (seen.has(el)) return;
seen.add(el);
result.push(el);
});
});
return result;
}
function getKnownHandleFromRow(row) {
const writers = getWriterCandidates(row);
for (const el of writers) {
if (el.dataset.handle) return el.dataset.handle.toLowerCase();
}
return null;
}
async function applyHandleToRowWriters(row, handle) {
const writers = getWriterCandidates(row);
for (const el of writers) {
el.dataset.handle = handle;
await insertHandleTag(el, handle);
}
}
const _injectingUrls = new Set(); // 현재 fetch 진행중인 URL 추적
async function injectHandlesIntoList() {
if (State.isInjectHandlesRunning) {
State.shouldRerunInjectHandles = true;
return;
}
State.isInjectHandlesRunning = true;
try {
do {
State.shouldRerunInjectHandles = false;
const listRows = [
...document.querySelectorAll('div.group\\/post-wrapper'),
...document.querySelectorAll('div.grow.flex div.flex-col div.grid > div.contents')
];
const galleryRows = Array.from(
document.querySelectorAll('div.grow.grid > div.relative')
).filter(el => el.querySelector('.aspect-square'));
const allRows = [...listRows, ...galleryRows];
// 이미 핸들 아는 행은 UI만 동기화
for (const row of allRows) {
const knownHandle = getKnownHandleFromRow(row);
if (!knownHandle) continue;
await applyHandleToRowWriters(row, knownHandle);
}
const pendingList = listRows.filter(row => {
if (row.dataset.koneDone) return false;
const writers = getWriterCandidates(row);
if (writers.length === 0) return false;
if (getKnownHandleFromRow(row)) return false;
const url = getArticleUrlFromRow(row);
if (url && _injectingUrls.has(url)) return false;
return true;
});
const pendingGallery = galleryRows.filter(row => {
if (row.dataset.koneDone) return false;
const writers = getWriterCandidates(row);
if (writers.length === 0) return false;
if (getKnownHandleFromRow(row)) return false;
const url = getArticleUrlFromGalleryRow(row);
if (url && _injectingUrls.has(url)) return false;
return true;
});
const tasks = [
...pendingList.map(row => ({ row, type: 'list' })),
...pendingGallery.map(row => ({ row, type: 'gallery' }))
];
if (tasks.length === 0) break;
const queue = tasks.slice();
async function worker() {
while (queue.length > 0) {
const task = queue.shift();
if (!task) break;
const { row, type } = task;
const url = type === 'gallery'
? getArticleUrlFromGalleryRow(row)
: getArticleUrlFromRow(row);
if (!url) continue;
if (_injectingUrls.has(url)) continue;
_injectingUrls.add(url);
try {
const writerElForPrecheck = row.querySelector('#writer_link') ||
row.querySelector('.overflow-hidden.text-center.text-nowrap.whitespace-nowrap.text-ellipsis') ||
row.querySelector('.overflow-hidden.text-center.whitespace-nowrap.text-ellipsis') ||
row.querySelector('div.overflow-hidden .flex.items-end a.hover\\:underline span');
const writerName = writerElForPrecheck
? (writerElForPrecheck.textContent || '').trim().toLowerCase()
: '';
if (State.isHidden && writerName) {
const handlesByName = State.usernameToHandles.get(writerName);
if (handlesByName && handlesByName.size > 0) {
const shouldPreHide = Array.from(handlesByName).some(h =>
State.hiddenUsers.has(h) || State.excludedUsers.has(h)
);
if (shouldPreHide) {
row.style.display = 'none';
row.dataset.toggled = 'true';
row.dataset.preblocked = 'true';
}
}
}
const handle = await fetchHandleFromUrl(url);
if (!handle) continue;
await applyHandleToRowWriters(row, handle);
const spanEl = row.querySelector(
'div.overflow-hidden .flex.items-end a.hover\\:underline span'
);
if (spanEl) {
spanEl.dataset.handle = handle;
const writerEl = row.querySelector('#writer_link') ||
row.querySelector('.overflow-hidden.text-center.text-nowrap.whitespace-nowrap.text-ellipsis') ||
row.querySelector('.overflow-hidden.text-center.whitespace-nowrap.text-ellipsis');
if (spanEl !== writerEl) {
await insertHandleTag(spanEl, handle);
}
}
row.dataset.koneDone = '1';
const isBlockedHandle = State.hiddenUsers.has(handle);
const isExcludedHandle = State.excludedUsers.has(handle);
if (row.dataset.preblocked === 'true' && !isBlockedHandle && !isExcludedHandle) {
row.style.display = '';
delete row.dataset.toggled;
delete row.dataset.preblocked;
delete row.dataset.userExcluded;
} else {
delete row.dataset.preblocked;
}
if (State.isHidden) await hideRow(row);
} finally {
_injectingUrls.delete(url);
}
}
}
const workers = [];
for (let i = 0; i < MAX_FETCH; i++) workers.push(worker());
await Promise.all(workers);
saveHandleCache();
} while (State.shouldRerunInjectHandles);
} finally {
State.isInjectHandlesRunning = false;
}
}
// 게시글 상세 페이지 핸들 주입
async function injectHandlesIntoArticle() {
// 현재 URL이 게시글 상세 페이지인지 확인
const isArticlePage = /^\/s\/[^\/]+\/[^\/]+/.test(location.pathname);
if (!isArticlePage) return;
// 게시글 작성자 (첫 번째 ARTICLE_SELECTOR span)
const spans = document.querySelectorAll(ARTICLE_SELECTOR);
if (spans.length === 0) return;
const articleSpan = spans[0];
if (!articleSpan.dataset.handle) {
// 현재 페이지 URL로 핸들 추출
const handle = await fetchHandleFromUrl(location.href);
if (handle) {
articleSpan.dataset.handle = handle;
await insertHandleTag(articleSpan, handle);
}
} else {
// 이미 handle은 있지만 태그가 없는 경우 보완
await insertHandleTag(articleSpan, articleSpan.dataset.handle);
}
// 댓글 작성자: 각 댓글 요소의 링크에서 /u/{handle} 추출
document.querySelectorAll('div.group\\/comment').forEach(async commentDiv => {
const commentSpan = commentDiv.querySelector(ARTICLE_SELECTOR);
if (!commentSpan) return;
// 댓글 내 유저 링크 찾기 (/u/{handle})
const userLink = commentDiv.querySelector('a[href^="/u/"]');
if (userLink) {
const match = userLink.href.match(/\/u\/([a-zA-Z0-9_\-.]+)/);
if (match) {
const handle = match[1].toLowerCase();
if (!commentSpan.dataset.handle) {
commentSpan.dataset.handle = handle;
}
await insertHandleTag(commentSpan, handle);
}
}
});
}
// ====== 메인 loadData 대체 ======
async function loadData() {
await injectHandlesIntoList();
await injectHandlesIntoArticle();
}
// ====== 필터링 함수들 ======
function scheduleShowButton() {
if (State.showButtonDebounceTimer) clearTimeout(State.showButtonDebounceTimer);
State.showButtonDebounceTimer = setTimeout(() => {
showButton();
}, 150);
}
function resetRowVisualState(targetEl) {
targetEl.style.display = '';
delete targetEl.dataset.highlighted;
delete targetEl.dataset.toggled;
delete targetEl.dataset.koneStatus;
delete targetEl.dataset.subExcluded;
delete targetEl.dataset.userExcluded;
const isGalleryView = !!targetEl.querySelector('.aspect-square');
if (isGalleryView) {
const linkEl = targetEl.querySelector('a.group');
if (linkEl) {
linkEl.style.border = '';
linkEl.style.backgroundColor = '';
linkEl.style.borderRadius = '';
}
} else {
targetEl.style.backgroundColor = '';
}
}
async function clearStaleHiddenRows() {
const { hiddenUsers, excludedUsers, hiddenSubs } = getHiddenSets();
const excludedSubs = (await getExcludedSubs()).map(s => s.toLowerCase());
const rowContext = {
hiddenUsers,
excludedUsers,
hiddenSubs,
excludedSubs,
titlePatterns: State.titlePatterns,
titleExcludePatterns: State.titleExcludePatterns
};
getPostTargets().forEach(el => {
const hasKoneState =
el.style.display === 'none' ||
el.dataset.highlighted ||
el.dataset.toggled ||
el.dataset.koneStatus ||
el.dataset.subExcluded;
if (!hasKoneState) return;
const rowState = classifyRow(el, rowContext);
if (!rowState.isBlocked && !rowState.isExcluded) {
resetRowVisualState(el);
}
});
}
async function hide() {
if (!State.isEnabled) return;
const { hiddenUsers, excludedUsers, hiddenSubs } = getHiddenSets();
const excludedSubs = (await getExcludedSubs()).map(s => s.toLowerCase());
const titlePatterns = State.titlePatterns;
const titleExcludePatterns = State.titleExcludePatterns;
if (
hiddenUsers.size === 0 &&
excludedUsers.size === 0 &&
hiddenSubs.size === 0 &&
excludedSubs.length === 0 &&
titlePatterns.length === 0 &&
titleExcludePatterns.length === 0
) return;
const allTargets = getPostTargets();
allTargets.forEach(el => {
const rowState = classifyRow(el, {
hiddenUsers,
excludedUsers,
hiddenSubs,
excludedSubs,
titlePatterns,
titleExcludePatterns
});
if (rowState.isBlocked || rowState.isExcluded) {
el.style.display = 'none';
el.dataset.toggled = 'true';
if (rowState.isExcluded) {
el.dataset.koneStatus = 'excluded';
el.dataset.subExcluded = 'true';
} else {
el.dataset.koneStatus = 'blocked';
delete el.dataset.subExcluded;
}
// 기존 stale 속성 제거
delete el.dataset.userExcluded;
}
});
document.querySelectorAll(`div.group\\/comment a.hover\\:underline span`).forEach(c => {
if (c.dataset.toggled === "true") return;
const handle = c.dataset.handle;
if (handle && (hiddenUsers.has(handle) || excludedUsers.has(handle))) {
const isExcluded = excludedUsers.has(handle);
const commentDiv = c.closest('div.group\\/comment');
if (isExcluded) {
commentDiv.style.display = 'none';
c.dataset.toggled = "true";
commentDiv.dataset.userExcluded = 'true';
return;
}
const userData = State.userCache.get('user:' + handle.toLowerCase());
const note = userData?.note || '';
const hasNote = note.trim() !== '';
c.dataset.hiddenUserName = c.textContent;
// 핸들 태그인지 닉네임인지 구분하여 텍스트 설정
if (c.classList.contains('kone-handle-tag')) {
c.textContent = '[차단 유저]';
} else {
c.textContent = hasNote ? note : '차단 유저';
}
c.dataset.toggled = true;
// 기존 메모 표시가 있다면 숨김 처리 (중복 방지)
const anchor = c.closest('a');
if (anchor) {
let sib = anchor.nextSibling;
while (sib) {
if (sib.nodeType === 1 && sib.classList && sib.classList.contains('note-span-wrapper')) {
sib.style.display = 'none';
sib.dataset.koneHiddenByBlock = 'true';
break;
}
sib = sib.nextSibling;
}
}
const profile = commentDiv.querySelector('img');
if (profile) {
profile.dataset.hiddenSrc = profile.src;
profile.src = '/images/profile.png';
profile.dataset.toggled = true;
}
const comment = commentDiv.querySelector('div.text-sm.whitespace-pre-wrap');
if (comment && !comment.dataset.toggled) {
comment.dataset.hiddenContent = comment.textContent;
comment.textContent = '[차단되어 숨겨진 내용]';
comment.classList.add('opacity-50');
comment.dataset.toggled = true;
comment.style.cursor = 'pointer';
if (!comment.dataset.toggleBound) {
comment.dataset.toggleBound = '1';
comment.addEventListener('click', function toggleComment(e) {
e.stopPropagation();
if (this.textContent === '[차단되어 숨겨진 내용]') {
this.textContent = this.dataset.hiddenContent;
this.classList.remove('opacity-50');
} else {
this.textContent = '[차단되어 숨겨진 내용]';
this.classList.add('opacity-50');
}
});
}
}
}
});
}
async function hideRow(el) {
const excludedSubs = (await getExcludedSubs()).map(s => s.toLowerCase());
const rowState = classifyRow(el, {
hiddenUsers: State.hiddenUsers,
excludedUsers: State.excludedUsers,
hiddenSubs: State.hiddenSubs,
excludedSubs,
titlePatterns: State.titlePatterns,
titleExcludePatterns: State.titleExcludePatterns
});
if (rowState.isBlocked || rowState.isExcluded) {
el.style.display = 'none';
el.dataset.toggled = 'true';
if (rowState.isExcluded) {
el.dataset.koneStatus = 'excluded';
el.dataset.subExcluded = 'true';
} else {
el.dataset.koneStatus = 'blocked';
delete el.dataset.subExcluded;
}
delete el.dataset.userExcluded;
}
scheduleShowButton();
}
async function show() {
const { hiddenUsers, excludedUsers, hiddenSubs } = getHiddenSets();
const excludedSubs = (await getExcludedSubs()).map(s => s.toLowerCase());
const titlePatterns = State.titlePatterns;
const titleExcludePatterns = State.titleExcludePatterns;
const targets = getPostTargets();
const isDarkMode = document.documentElement.classList.contains('dark');
const rowContext = {
hiddenUsers,
excludedUsers,
hiddenSubs,
excludedSubs,
titlePatterns,
titleExcludePatterns
};
targets.forEach(el => {
const rowState = classifyRow(el, rowContext);
const applyHighlight = (targetEl, status) => {
targetEl.style.display = '';
targetEl.dataset.highlighted = 'true';
targetEl.dataset.koneStatus = status;
const color = (status === 'blocked')
? (isDarkMode ? '#4d4d4d' : '#b0b0b0')
: (isDarkMode ? '#8f4f4f' : '#e3a1a1');
const opacityColor = (status === 'blocked')
? (isDarkMode ? 'rgba(77, 77, 77, 0.4)' : 'rgba(176, 176, 176, 0.4)')
: (isDarkMode ? 'rgba(143, 79, 79, 0.4)' : 'rgba(227, 161, 161, 0.4)');
const isGalleryView = !!targetEl.querySelector('.aspect-square');
if (isGalleryView) {
const linkEl = targetEl.querySelector('a.group');
if (linkEl) {
linkEl.style.border = `3px solid ${color}`;
linkEl.style.backgroundColor = opacityColor;
linkEl.style.borderRadius = '8px';
linkEl.style.transition = 'all 0.3s ease';
}
} else {
targetEl.style.backgroundColor = color;
targetEl.style.transition = 'all 0.3s ease';
}
};
if (State.isEnabled) {
// [필터 ON] -> 카운터 클릭으로 인한 숨김 해제 상태
if (rowState.isExcluded) {
// 제외 항목은 최우선적으로 계속 숨김 유지
el.style.display = 'none';
el.dataset.toggled = 'true';
el.dataset.koneStatus = 'excluded';
el.dataset.subExcluded = 'true';
} else if (rowState.isBlocked) {
applyHighlight(el, 'blocked');
} else {
resetRowVisualState(el);
}
} else {
// [필터 OFF] -> 모든 필터 대상 강조 노출
if (rowState.isExcluded) {
applyHighlight(el, 'excluded');
} else if (rowState.isBlocked) {
applyHighlight(el, 'blocked');
} else {
resetRowVisualState(el);
}
}
});
document.querySelectorAll('div.group\\/comment a.hover\\:underline span').forEach(c => {
if (c.dataset.toggled === "true") {
const commentDiv = c.closest('div.group\\/comment');
if (commentDiv && commentDiv.dataset.userExcluded === 'true') {
const handle = c.dataset.handle;
if (State.isEnabled && handle && excludedUsers.has(handle)) return; // Keep hidden
commentDiv.style.display = '';
delete commentDiv.dataset.userExcluded;
delete c.dataset.toggled;
return;
}
if (c.dataset.hiddenUserName) {
c.textContent = c.dataset.hiddenUserName;
delete c.dataset.hiddenUserName;
}
delete c.dataset.toggled;
// 숨겼던 메모 표시 다시 노출
const anchor = c.closest('a');
if (anchor) {
let sib = anchor.nextSibling;
while (sib) {
if (sib.nodeType === 1 && sib.classList && sib.classList.contains('note-span-wrapper')) {
if (sib.dataset.koneHiddenByBlock === 'true') {
sib.style.display = '';
delete sib.dataset.koneHiddenByBlock;
}
break;
}
sib = sib.nextSibling;
}
}
const profile = commentDiv.querySelector('img');
if (profile && profile.dataset.toggled === "true") {
if (profile.dataset.hiddenSrc) {
profile.src = profile.dataset.hiddenSrc;
delete profile.dataset.hiddenSrc;
}
delete profile.dataset.toggled;
}
const comment = commentDiv.querySelector('div.text-sm.whitespace-pre-wrap');
if (comment && comment.dataset.toggled === "true") {
if (comment.dataset.hiddenContent) {
comment.textContent = comment.dataset.hiddenContent;
delete comment.dataset.hiddenContent;
}
comment.classList.remove('opacity-50');
delete comment.dataset.toggled;
comment.style.cursor = '';
}
}
});
}
async function displayNote() {
// userCache에서 바로 읽음 (DB 접근 없음)
const userDict = Object.fromEntries(State.userCache);
// 메모 텍스트 표시 (기존 동작)
document.querySelectorAll(ARTICLE_SELECTOR).forEach(s => {
const handle = s.dataset.handle;
if (!handle) return;
const uid = `user:${handle.toLowerCase()}`;
const user = userDict[uid];
if (!user || !user.note) return;
const anchor = s.closest('a');
if (!anchor) return;
// 댓글창에서 차단된 유저인 경우 닉네임 자리에 메모가 표시되므로 별도 메모 div는 제거/스킵
if (user.block && s.closest('div.group/comment')) {
let wrapper = anchor.nextSibling;
if (wrapper && wrapper.classList && wrapper.classList.contains('note-span-wrapper')) {
wrapper.remove();
}
return;
}
let wrapper = anchor.nextSibling;
if (wrapper && wrapper.classList && wrapper.classList.contains('note-span-wrapper')) {
wrapper.querySelector('span').textContent = user.note;
return;
}
wrapper = document.createElement('div');
wrapper.className = 'note-span-wrapper flex';
const span = document.createElement('span');
span.className = 'text-xs';
span.textContent = user.note;
wrapper.appendChild(span);
anchor.parentNode.insertBefore(wrapper, anchor.nextSibling);
});
// 핸들 태그 색상 갱신 (메모/차단 상태 반영)
document.querySelectorAll('.kone-handle-tag').forEach(tag => {
const handle = tag.dataset.handle;
if (!handle) return;
const uid = `user:${handle.toLowerCase()}`;
const user = userDict[uid];
const hasBlock = user?.block || false;
const isExcluded = user?.excluded || false;
const hasNote = user?.note && user.note.trim() !== '';
tag.style.color = isExcluded ? '#f44' : hasBlock ? '#ff6c3b' : hasNote ? '#4caf50' : '#888';
tag.title = hasNote ? `메모: ${user.note}` : handle;
});
}
// ====== UI 메뉴 생성 ======
function el(tag, opts = {}) {
const node = document.createElement(tag);
const { text, css, on, ...rest } = opts;
if (text !== undefined) node.textContent = text;
if (css) node.style.cssText = css;
if (on) {
Object.entries(on).forEach(([ev, fn]) => node.addEventListener(ev, fn));
}
Object.entries(rest).forEach(([k, v]) => {
node[k] = v;
});
return node;
}
function createButton({ text, className = '', css = '', onClick } = {}) {
const btn = document.createElement('button');
if (className) btn.className = className;
if (css) btn.style.cssText = css;
if (text !== undefined) btn.textContent = text;
if (onClick) btn.addEventListener('click', onClick);
return btn;
}
function createDiv(text, styleObj) {
const div = document.createElement('div');
div.textContent = text;
if (styleObj) Object.assign(div.style, styleObj);
return div;
}
function createClickMenu(el, items) {
if (window.currentClickMenu) {
window.currentClickMenu.remove();
}
const menu = document.createElement('div');
menu.className = 'kone-click-menu';
Object.assign(menu.style, USER_BLOCK_MENU_STYLE);
items.forEach(([label, fn]) => {
const btn = createDiv(label, {
padding: '4px 0',
cursor: fn ? 'pointer' : 'default',
color: fn ? '#fff' : '#aaa',
fontSize: '13px'
});
if (fn) {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
await fn();
} catch (err) {
console.error(err);
}
menu.remove();
window.currentClickMenu = null;
await refreshAfterMenuAction();
});
}
menu.appendChild(btn);
});
document.body.appendChild(menu);
const rect = el.getBoundingClientRect();
menu.style.left = Math.max(window.scrollX + rect.right + 8, 6) + 'px';
menu.style.top = Math.max(window.scrollY + rect.top, 6) + 'px';
menu.addEventListener('mouseleave', () => {
setTimeout(() => {
if (window.currentClickMenu) {
window.currentClickMenu.remove();
window.currentClickMenu = null;
}
}, 500);
});
window.currentClickMenu = menu;
const closeMenuOnScroll = () => {
if (window.currentClickMenu) {
window.currentClickMenu.remove();
window.currentClickMenu = null;
window.removeEventListener('scroll', closeMenuOnScroll, true);
document.removeEventListener('touchmove', closeMenuOnScroll, true);
}
};
window.addEventListener('scroll', closeMenuOnScroll, true);
document.addEventListener('touchmove', closeMenuOnScroll, true);
}
async function refreshAfterMenuAction() {
await refreshUserCache();
await loadTitleFilters();
await applyVisibilityPhase();
await displayNote();
await showButton();
// 설정창이 열려있으면 최신 데이터로 재렌더링
const modal = document.getElementById('kone-manage-modal');
if (modal) {
modal.remove();
State.isManageUIOpen = false;
await showManageUI();
State.isManageUIOpen = true;
}
}
// ====== 유저 및 서브 클릭 이벤트 ======
// ※ 유저 메뉴는 핸들 태그(@handle) 클릭 시 표시 (insertHandleTag 내부에서 처리)
// 닉네임 자체는 원래 동작(사용자 페이지 이동 등)을 그대로 유지
function setupClickMenus() {
if (setupClickMenus._bound) return;
setupClickMenus._bound = true;
document.addEventListener('click', async (e) => {
if (location.pathname !== '/s/all') return;
if (e.target.closest('.kone-click-menu')) return;
const pcEl = e.target.closest('.col-span-2 > div');
if (pcEl) {
const sub = (pcEl.innerText || '').trim();
if (!sub) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const excludedSubs = await getExcludedSubs();
const subMode = getSubModeFromContext(sub, {
hiddenSubs: State.hiddenSubs,
excludedSubs
});
const isBlocked = subMode === 'blocked';
const isExcluded = subMode === 'excluded';
const menuItems = [['해당 서브 이동', () => window.open(`/s/${sub}`, '_blank')]];
// 차단 메뉴
menuItems.push([isBlocked ? '⭕ 차단 해제' : '🚫 서브 차단', async () => {
await setSubMode(sub, isBlocked ? 'none' : 'blocked');
}]);
// 제외 메뉴
menuItems.push([isExcluded ? '⭕ 제외 해제' : '❌ 서브 제외', async () => {
await setSubMode(sub, isExcluded ? 'none' : 'excluded');
}]);
createClickMenu(pcEl, menuItems);
return false;
}
const mobileEl = e.target.closest('.shrink-0 > span');
if (!mobileEl) return;
const sub = (mobileEl.innerText || '').trim();
const cleanSub = sub.replace(/\s*\|.*$/, '').trim();
if (!cleanSub || cleanSub.includes('분') || cleanSub.includes('전')) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const excludedSubs = await getExcludedSubs();
const subMode = getSubModeFromContext(cleanSub, {
hiddenSubs: State.hiddenSubs,
excludedSubs
});
const isBlocked = subMode === 'blocked';
const isExcluded = subMode === 'excluded';
const menuItems = [['해당 서브 이동', () => window.open(`/s/${cleanSub}`, '_blank')]];
menuItems.push([isBlocked ? '⭕ 차단 해제' : '🚫 서브 차단', async () => {
await setSubMode(cleanSub, isBlocked ? 'none' : 'blocked');
}]);
menuItems.push([isExcluded ? '⭕ 제외 해제' : '❌ 서브 제외', async () => {
await setSubMode(cleanSub, isExcluded ? 'none' : 'excluded');
}]);
createClickMenu(mobileEl, menuItems);
return false;
}, true);
}
// ====== 서브 페이지 게시물 차단/제외 처리 ======
function isSubListPage() {
const p = location.pathname;
return /^\/s\/([^\/]+)\/?$/.test(p) && p !== '/s/all';
}
let _subPageBlurDismissed = false;
function applySubPageBlur(posts) {
if (_subPageBlurDismissed) return; // 해제된 상태면 재적용 안 함
if (document.querySelector('.kone-blur-overlay-banner')) return;
posts.forEach(el => {
if (el.dataset.koneSubBlurred) return;
el.dataset.koneSubBlurred = '1';
const anchor = el.querySelector('a[href*="/s/"]');
if (!anchor) return;
anchor.style.filter = 'blur(5px)';
anchor.style.userSelect = 'none';
anchor.style.pointerEvents = 'none';
});
const container = document.querySelector('div.grow.flex.flex-col.relative');
if (!container) return;
const banner = document.createElement('div');
banner.className = 'kone-blur-overlay-banner';
banner.style.cssText = `
display:flex;align-items:center;justify-content:center;
padding:10px;
background:rgba(30,30,30,0.85);
border-radius:8px;
margin:8px 8px 0 8px;
`;
const btn = document.createElement('button');
btn.textContent = '🔓 차단된 서브 - 클릭하여 전체 보기';
btn.style.cssText = `
background:transparent;
color:#fff;border:none;
font-size:13px;cursor:pointer;
`;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
_subPageBlurDismissed = true; // 해제 플래그 설정
document.querySelectorAll('[data-kone-sub-blurred]').forEach(el => {
const anchor = el.querySelector('a[href*="/s/"]');
if (!anchor) return;
anchor.style.filter = '';
anchor.style.userSelect = '';
anchor.style.pointerEvents = '';
delete el.dataset.koneSubBlurred;
});
banner.remove();
});
banner.appendChild(btn);
container.insertBefore(banner, container.firstChild);
}
function applySubPageExclude(posts) {
// 이미 경고문 있으면 스킵
if (document.querySelector('.kone-excluded-warning')) return;
posts.forEach(el => {
el.style.display = 'none';
el.dataset.koneSubExcluded = '1';
});
// 게시물 목록 컨테이너 찾기 (div.grow.flex.flex-col...)
const container = document.querySelector('div.grow.flex.flex-col.relative');
if (!container) return;
const warning = document.createElement('div');
warning.className = 'kone-excluded-warning';
warning.style.cssText = `
margin:24px 16px;padding:32px 16px;
text-align:center;
color:#f44;font-size:15px;font-weight:bold;
background:rgba(255,0,0,0.05);
border:2px dashed #f44;border-radius:8px;
`;
warning.textContent = '⛔ 이 서브는 제외 설정되어 있어 게시물이 표시되지 않습니다.';
container.insertBefore(warning, container.firstChild);
}
async function handleSubPageVisibility() {
if (!isSubListPage()) return;
const pathMatch = location.pathname.match(/^\/s\/([^\/]+)/);
if (!pathMatch) return;
const subName = pathMatch[1].toLowerCase();
const subMode = await getSubMode(subName);
const isExcluded = subMode === 'excluded';
// [근본 해결] 제외된 서브라면 차단 여부와 상관없이 즉시 숨김
if (isExcluded) {
const posts = getPostTargets();
if (posts.length > 0) applySubPageExclude(posts);
return;
}
// 제외는 아니지만 차단된 경우라면 블러 처리
const isBlocked = subMode === 'blocked';
if (isBlocked) {
const posts = getPostTargets();
if (posts.length > 0) applySubPageBlur(posts);
}
}
// ====== 서브 페이지에 차단/제외 버튼 추가 ======
async function addSubPageButtons() {
const pathMatch = location.pathname.match(/^\/s\/([^\/]+)/);
if (!pathMatch || pathMatch[1] === 'all' || pathMatch[1] === 'somisoft') return;
if (addSubPageButtons._running) return;
addSubPageButtons._running = true;
try {
const subName = pathMatch[1].toLowerCase();
const subHeader = document.querySelector('.flex.justify-between.gap-2.h-8');
if (!subHeader) return;
subHeader.querySelectorAll('.sub-block-buttons').forEach(el => el.remove());
const subMode = await getSubMode(subName);
const isBlocked = subMode === 'blocked';
const isExcluded = subMode === 'excluded';
const btnContainer = document.createElement('div');
btnContainer.className = 'sub-block-buttons';
btnContainer.style.cssText = 'display: flex; gap: 8px; align-items: center;';
if (!isBlocked && !isExcluded) {
const blockBtn = createSubButton('🚫 서브 차단', '#f44', async () => {
if (confirm(`${subName} 서브를 차단하시겠습니까?`)) {
await setSubMode(subName, 'blocked');
await updateAll();
}
});
const excludeBtn = createSubButton('❌ 서브 제외', '#2196f3', async () => {
if (confirm(`${subName} 서브를 제외하시겠습니까?`)) {
await setSubMode(subName, 'excluded');
await updateAll();
}
});
btnContainer.appendChild(blockBtn);
btnContainer.appendChild(excludeBtn);
} else {
// 동적 버튼 구성
const blockToggleBtn = createSubButton(isBlocked ? '⭕ 차단 해제' : '🚫 차단', isBlocked ? '#ff9800' : '#f44', async () => {
await setSubMode(subName, isBlocked ? 'none' : 'blocked');
await updateAll();
});
const excludeToggleBtn = createSubButton(isExcluded ? '⭕ 제외 해제' : '❌ 제외', isExcluded ? '#4caf50' : '#2196f3', async () => {
await setSubMode(subName, isExcluded ? 'none' : 'excluded');
await updateAll();
});
btnContainer.appendChild(blockToggleBtn);
btnContainer.appendChild(excludeToggleBtn);
}
const buttonGroup = subHeader.querySelector('.flex.gap-2');
if (buttonGroup) {
buttonGroup.insertBefore(btnContainer, buttonGroup.firstChild);
}
} finally {
addSubPageButtons._running = false;
}
}
function createSubButton(text, bgColor, onClick) {
return createButton({
text,
className: 'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 shrink-0 outline-none rounded-full px-3 size-8 md:size-auto shadow-none cursor-pointer',
css: `
background: ${bgColor};
color: #fff;
border: none;
padding: 6px 12px;
font-size: 13px;
white-space: nowrap;
`,
onClick: async (e) => {
e.preventDefault();
e.stopPropagation();
await onClick();
}
});
}
async function handleSubListVisibility() {
if (location.pathname !== '/sub-list') return;
const { hiddenSubs } = getHiddenSets();
const excludedSubs = await getExcludedSubs();
document.querySelectorAll('div.overflow-hidden.rounded-lg.w-full').forEach(el => {
const link = el.querySelector('a.flex.items-center[href^="/s/"]');
if (link) {
const subName = link.getAttribute('href').replace('/s/', '').toLowerCase();
const subMode = getSubModeFromContext(subName, { hiddenSubs, excludedSubs });
const isHidden = subMode !== 'none';
if (isHidden && State.isHidden) {
el.style.display = 'none';
el.dataset.toggled = 'true';
} else {
el.style.display = '';
delete el.dataset.toggled;
}
}
});
}
async function addSubListButtons() {
if (location.pathname !== '/sub-list') return;
if (addSubListButtons._running) return;
addSubListButtons._running = true;
try {
const { hiddenSubs } = getHiddenSets();
const excludedSubs = await getExcludedSubs();
const els = document.querySelectorAll('div.overflow-hidden.rounded-lg.w-full');
for (const el of Array.from(els)) {
const link = el.querySelector('a.flex.items-center[href^="/s/"]');
if (!link) continue;
const subName = link.getAttribute('href').replace('/s/', '').toLowerCase();
const btnContainerWrap = el.querySelector('.flex.justify-between.gap-2.h-8 > .flex.gap-2');
if (!btnContainerWrap) continue;
if (btnContainerWrap.querySelector('.sublist-block-buttons')) continue;
const subMode = getSubModeFromContext(subName, { hiddenSubs, excludedSubs });
const isBlocked = subMode === 'blocked';
const isExcluded = subMode === 'excluded';
const btnContainer = document.createElement('div');
btnContainer.className = 'sublist-block-buttons';
btnContainer.style.cssText = 'display: flex; gap: 8px; align-items: center;';
if (!isBlocked && !isExcluded) {
const blockBtn = createSubButton('🚫 차단', '#f44', async () => {
if (confirm(`${subName} 서브를 차단하시겠습니까?`)) {
await setSubMode(subName, 'blocked');
await updateAll();
}
});
const excludeBtn = createSubButton('❌ 제외', '#2196f3', async () => {
if (confirm(`${subName} 서브를 제외하시겠습니까?`)) {
await setSubMode(subName, 'excluded');
await updateAll();
}
});
btnContainer.appendChild(blockBtn);
btnContainer.appendChild(excludeBtn);
} else {
const blockToggleBtn = createSubButton(isBlocked ? '⭕ 차단 해제' : '🚫 차단', isBlocked ? '#ff9800' : '#f44', async () => {
await setSubMode(subName, isBlocked ? 'none' : 'blocked');
await updateAll();
});
const excludeToggleBtn = createSubButton(isExcluded ? '⭕ 제외 해제' : '❌ 제외', isExcluded ? '#4caf50' : '#2196f3', async () => {
await setSubMode(subName, isExcluded ? 'none' : 'excluded');
await updateAll();
});
btnContainer.appendChild(blockToggleBtn);
btnContainer.appendChild(excludeToggleBtn);
}
btnContainerWrap.insertBefore(btnContainer, btnContainerWrap.firstChild);
}
} finally {
addSubListButtons._running = false;
}
}
async function closeManageUI() {
const modal = document.getElementById('kone-manage-modal');
if (modal) {
modal.remove();
State.isManageUIOpen = false;
await SyncQueue.forceSync();
}
}
function createManageModalBase() {
const existingModal = document.getElementById('kone-manage-modal');
if (existingModal) existingModal.remove();
const modal = document.createElement('div');
modal.id = 'kone-manage-modal';
modal.style.cssText = `
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
background:#222;color:#fff;padding:16px 20px;border-radius:8px;
max-height:80vh;overflow:auto;z-index:999999;font-size:14px;
box-shadow:0 4px 12px rgba(0,0,0,0.5);
min-width:min(500px, 90vw);max-width:min(700px, 95vw);
`;
const header = document.createElement('div');
header.style.cssText = `
position:sticky;top:-16px;
display:flex;align-items:center;justify-content:space-between;
background:#222;
margin:-16px -20px 12px -20px;
padding:12px 20px;
border-bottom:1px solid #444;
z-index:2;
`;
const title = document.createElement('div');
title.textContent = '차단 관리';
title.style.cssText = 'font-weight:bold;font-size:16px;';
header.appendChild(title);
const closeXBtn = document.createElement('div');
closeXBtn.textContent = '✕';
closeXBtn.style.cssText = `
width:28px;height:28px;
display:flex;align-items:center;justify-content:center;
background:#f44;color:#fff;
border-radius:50%;
cursor:pointer;
font-size:16px;font-weight:bold;
transition:all 0.2s ease;
`;
closeXBtn.addEventListener('mouseenter', () => {
closeXBtn.style.transform = 'scale(1.1)';
closeXBtn.style.background = '#ff5555';
});
closeXBtn.addEventListener('mouseleave', () => {
closeXBtn.style.transform = 'scale(1)';
closeXBtn.style.background = '#f44';
});
closeXBtn.addEventListener('click', () => {
closeManageUI();
});
header.appendChild(closeXBtn);
modal.appendChild(header);
return modal;
}
function appendImportExportSection(modal) {
const section = document.createElement('div');
section.style.cssText = 'margin-bottom:16px;padding:12px;background:rgba(100,100,100,0.2);border-radius:6px;';
// ── 백업/복원 타이틀
const title1 = document.createElement('div');
title1.textContent = '데이터 백업/복원(Json파일 수동 백업)';
title1.style.cssText = 'font-weight:bold;margin-bottom:8px;font-size:14px;color:#4FC3F7;';
section.appendChild(title1);
const desc1 = document.createElement('div');
desc1.textContent = '차단 유저, 서브, 메모 등 모든 설정을 Json파일로 저장하거나 불러올 수 있습니다(수동).';
desc1.style.cssText = 'font-size:12px;color:#999;margin-bottom:10px;line-height:1.4;';
section.appendChild(desc1);
const btnWrap1 = document.createElement('div');
btnWrap1.style.cssText = 'display:flex;gap:8px;margin-bottom:16px;';
const exportBtn = document.createElement('button');
exportBtn.textContent = '💾 내보내기';
exportBtn.style.cssText = 'flex:1;background:#4caf50;color:#fff;border:none;padding:8px 12px;border-radius:4px;cursor:pointer;font-size:13px;font-weight:bold;';
exportBtn.addEventListener('click', async () => { await exportData(); });
const importBtn = document.createElement('button');
importBtn.textContent = '📥 가져오기';
importBtn.style.cssText = 'flex:1;background:#2196f3;color:#fff;border:none;padding:8px 12px;border-radius:4px;cursor:pointer;font-size:13px;font-weight:bold;';
importBtn.addEventListener('click', async () => { await importData(); });
btnWrap1.appendChild(exportBtn);
btnWrap1.appendChild(importBtn);
section.appendChild(btnWrap1);
// ── Gist 동기화 타이틀
const title2 = document.createElement('div');
title2.textContent = '☁️ GitHub Gist 동기화';
title2.style.cssText = 'font-weight:bold;margin-bottom:6px;font-size:14px;color:#ce93d8;';
section.appendChild(title2);
const desc2 = document.createElement('div');
desc2.textContent = 'GitHub Personal Access Token(gist 권한)과 Gist ID를 입력하면 기기 간 설정을 동기화할 수 있습니다. Gist ID는 첫 업로드 시 자동 생성됩니다. 생성되는 Token은 따로 저장해두는 것이 좋습니다';
desc2.style.cssText = 'font-size:12px;color:#999;margin-bottom:8px;line-height:1.4;';
section.appendChild(desc2);
// 최근 동기화 시간 표시 추가
const syncTimeRow = document.createElement('div');
syncTimeRow.style.cssText = 'font-size:12px;color:#bbb;margin-bottom:10px;padding:6px 10px;background:rgba(0,0,0,0.2);border-radius:4px;display:flex;align-items:center;gap:6px;';
const updateSyncTimeText = () => {
const lastSync = GistSync.getLastSync();
if (lastSync) {
const date = new Date(lastSync);
const timeStr = date.toLocaleString('ko-KR');
syncTimeRow.textContent = `🕒 최근 동기화: ${timeStr}`;
} else {
syncTimeRow.textContent = '🕒 최근 동기화: 기록 없음';
}
};
updateSyncTimeText();
section.appendChild(syncTimeRow);
// 토큰 입력
const tokenRow = document.createElement('div');
tokenRow.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:6px;';
const tokenLabel = document.createElement('span');
tokenLabel.textContent = 'Token';
tokenLabel.style.cssText = 'font-size:12px;color:#aaa;width:48px;flex-shrink:0;';
const tokenInput = document.createElement('input');
tokenInput.type = 'password';
tokenInput.value = GistSync.getToken();
tokenInput.placeholder = 'ghp_xxxxxxxxxxxx';
tokenInput.style.cssText = 'flex:1;padding:5px 8px;background:#333;border:1px solid #555;border-radius:4px;color:#fff;font-size:12px;';
tokenRow.appendChild(tokenLabel);
tokenRow.appendChild(tokenInput);
section.appendChild(tokenRow);
// Gist ID 입력
const gistRow = document.createElement('div');
gistRow.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:10px;';
const gistLabel = document.createElement('span');
gistLabel.textContent = 'Gist ID';
gistLabel.style.cssText = 'font-size:12px;color:#aaa;width:48px;flex-shrink:0;';
const gistInput = document.createElement('input');
gistInput.type = 'text';
gistInput.value = GistSync.getGistId();
gistInput.placeholder = '비워두면 첫 업로드 시 자동 생성';
gistInput.style.cssText = 'flex:1;padding:5px 8px;background:#333;border:1px solid #555;border-radius:4px;color:#fff;font-size:12px;';
gistRow.appendChild(gistLabel);
gistRow.appendChild(gistInput);
section.appendChild(gistRow);
// 잠금 상태 관리
let isLocked = !!(GistSync.getToken() && GistSync.getGistId());
const updateLockUI = () => {
if (isLocked) {
tokenRow.style.display = 'none';
gistRow.style.display = 'none';
saveCredBtn.textContent = '🔒 저장되고 잠금됨';
saveCredBtn.style.background = '#444';
saveCredBtn.title = '클릭하여 수정을 위해 잠금 해제';
} else {
tokenRow.style.display = 'flex';
gistRow.style.display = 'flex';
saveCredBtn.textContent = '🔑 저장(토큰, ID)';
saveCredBtn.style.background = '#7b1fa2';
saveCredBtn.title = '';
}
};
// 동기화 버튼들
const btnWrap2 = document.createElement('div');
btnWrap2.style.cssText = 'display:flex;gap:8px;';
const saveCredBtn = document.createElement('button');
updateLockUI();
saveCredBtn.style.cssText = 'flex:1;color:#fff;border:none;padding:8px 10px;border-radius:4px;cursor:pointer;font-size:12px;transition:all 0.2s;';
saveCredBtn.addEventListener('click', () => {
if (isLocked) {
isLocked = false;
updateLockUI();
} else {
const token = tokenInput.value.trim();
const gistId = gistInput.value.trim();
GistSync.setToken(token);
GistSync.setGistId(gistId);
showSyncToast('✅ 토큰/Gist ID가 저장되었습니다.');
if (token && gistId) {
isLocked = true;
updateLockUI();
}
}
});
const uploadBtn = document.createElement('button');
uploadBtn.textContent = '⬆️ 업로드(설정 온라인 저장)';
uploadBtn.style.cssText = 'flex:1;background:#388e3c;color:#fff;border:none;padding:8px 10px;border-radius:4px;cursor:pointer;font-size:12px;';
uploadBtn.addEventListener('click', async () => {
GistSync.setToken(tokenInput.value.trim());
GistSync.setGistId(gistInput.value.trim());
uploadBtn.disabled = true;
uploadBtn.textContent = '업로드 중...';
try {
await GistSync.upload();
SyncQueue.setNeedsUpload(false); // 업로드 성공했으므로 추가 동기화 불필요
gistInput.value = GistSync.getGistId(); // 신규 생성된 ID 반영
showSyncToast('✅ 업로드 완료!');
updateSyncTimeText(); // 시간 갱신
} catch (e) {
alert('업로드 실패: ' + e.message);
} finally {
uploadBtn.disabled = false;
uploadBtn.textContent = '⬆️ 업로드';
}
});
const downloadBtn = document.createElement('button');
downloadBtn.textContent = '⬇️ 다운로드(설정 온라인 동기화)';
downloadBtn.style.cssText = 'flex:1;background:#1565c0;color:#fff;border:none;padding:8px 10px;border-radius:4px;cursor:pointer;font-size:12px;';
downloadBtn.addEventListener('click', async () => {
GistSync.setToken(tokenInput.value.trim());
GistSync.setGistId(gistInput.value.trim());
downloadBtn.disabled = true;
downloadBtn.textContent = '다운로드 중...';
try {
const result = await GistSync.download();
if (result.updated) {
await updateAll();
showSyncToast('✅ 설정이 업데이트되었습니다.');
} else {
showSyncToast('✔️ 이미 최신 상태입니다.');
}
updateSyncTimeText(); // 시간 갱신
} catch (e) {
alert('다운로드 실패: ' + e.message);
} finally {
downloadBtn.disabled = false;
downloadBtn.textContent = '⬇️ 다운로드';
}
});
btnWrap2.appendChild(saveCredBtn);
btnWrap2.appendChild(uploadBtn);
btnWrap2.appendChild(downloadBtn);
section.appendChild(btnWrap2);
modal.appendChild(section);
}
function appendTitleFilterSection(modal) {
const parseFilterInput = (rawText) => normalizeStringArray(
rawText ? rawText.split(',') : [],
{ lowerCase: false }
);
const filterTitle = document.createElement('div');
filterTitle.textContent = '차단할 제목 필터링 문구';
filterTitle.style.cssText = 'font-weight:bold;margin-top:12px;margin-bottom:8px;font-size:15px;color:#ff6c3b;';
modal.appendChild(filterTitle);
const subFT = document.createElement('div');
subFT.textContent = '차단규칙은 해당 단어의 포함 여부만 확인하여 차단됩니다, Enter를 통해 저장 됩니다';
subFT.style.cssText = 'font-size:12px;color:#999;margin-bottom:8px;line-height:1.4;';
modal.appendChild(subFT);
const filterInputWrap = document.createElement('div');
filterInputWrap.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;';
const filterInput = document.createElement('input');
filterInput.type = 'text';
filterInput.value = State.titlePatternsRaw.join(', ');
filterInput.placeholder = '예: 테스트, 테스트2, 테스트3';
filterInput.style.cssText = 'flex:1;padding:8px;background:#333;border:1px solid #555;border-radius:4px;color:#fff;font-size:13px;';
const saveTitleFiltersFromInput = () => {
const filters = parseFilterInput(filterInput.value.trim());
filterInput.value = filters.join(', ');
SyncQueue.enqueue(async () => {
await saveTitleFilters(filters);
await loadTitleFilters();
await updateAll();
});
};
filterInput.addEventListener('keypress', async (e) => {
if (e.key !== 'Enter') return;
saveTitleFiltersFromInput();
});
filterInputWrap.appendChild(filterInput);
const saveBtn = document.createElement('button');
saveBtn.textContent = '저장';
saveBtn.style.cssText = 'background:#4caf50;color:#fff;border:none;padding:8px 10px;border-radius:4px;cursor:pointer;font-size:12px;white-space:nowrap;';
saveBtn.addEventListener('click', saveTitleFiltersFromInput);
filterInputWrap.appendChild(saveBtn);
modal.appendChild(filterInputWrap);
const excludeTitle = document.createElement('div');
excludeTitle.textContent = '제외할 제목 필터링 문구';
excludeTitle.style.cssText = 'font-weight:bold;margin-top:12px;margin-bottom:8px;font-size:15px;color:#cc5555;';
modal.appendChild(excludeTitle);
const excludeDesc = document.createElement('div');
excludeDesc.textContent = '제외 규칙은 숨김 목록에서 제외처리로 집계됩니다, Enter를 통해 저장 됩니다';
excludeDesc.style.cssText = 'font-size:12px;color:#999;margin-bottom:8px;line-height:1.4;';
modal.appendChild(excludeDesc);
const excludeInputWrap = document.createElement('div');
excludeInputWrap.style.cssText = 'display:flex;gap:6px;align-items:center;margin-bottom:8px;';
const excludeInput = document.createElement('input');
excludeInput.type = 'text';
excludeInput.value = State.titleExcludePatternsRaw.join(', ');
excludeInput.placeholder = '예: 스포일러, 미리보기';
excludeInput.style.cssText = 'flex:1;padding:8px;background:#333;border:1px solid #555;border-radius:4px;color:#fff;font-size:13px;';
const saveTitleExcludeFiltersFromInput = () => {
const filters = parseFilterInput(excludeInput.value.trim());
excludeInput.value = filters.join(', ');
SyncQueue.enqueue(async () => {
await saveTitleExcludeFilters(filters);
await loadTitleFilters();
await updateAll();
});
};
excludeInput.addEventListener('keypress', async (e) => {
if (e.key !== 'Enter') return;
saveTitleExcludeFiltersFromInput();
});
excludeInputWrap.appendChild(excludeInput);
const excludeSaveBtn = document.createElement('button');
excludeSaveBtn.textContent = '저장';
excludeSaveBtn.style.cssText = 'background:#4caf50;color:#fff;border:none;padding:8px 10px;border-radius:4px;cursor:pointer;font-size:12px;white-space:nowrap;';
excludeSaveBtn.addEventListener('click', saveTitleExcludeFiltersFromInput);
excludeInputWrap.appendChild(excludeSaveBtn);
modal.appendChild(excludeInputWrap);
}
function makeListCollapsible(container, max = 4) {
if (!container || container.children.length <= max) return null;
const items = Array.from(container.children);
items.forEach((child, idx) => {
if (!child.dataset.koneOriginalDisplay) {
child.dataset.koneOriginalDisplay = child.style.display || '';
}
if (idx >= max) child.style.display = 'none';
});
let expanded = false;
const hiddenCount = items.length - max;
const toggleBtn = el('button', {
text: `더보기 (${hiddenCount}개)`,
css: 'margin-top:6px;background:#555;color:#fff;border:none;padding:5px 10px;border-radius:4px;cursor:pointer;font-size:12px;'
});
toggleBtn.addEventListener('click', () => {
expanded = !expanded;
items.forEach((child, idx) => {
child.style.display = (expanded || idx < max)
? (child.dataset.koneOriginalDisplay || '')
: 'none';
});
toggleBtn.textContent = expanded ? '접기' : `더보기 (${hiddenCount}개)`;
});
return toggleBtn;
}
function placeToggleNextToTitle(titleEl, toggleBtn) {
if (!titleEl || !toggleBtn || !titleEl.parentNode) return;
const row = el('div', {
css: 'display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:12px;margin-bottom:8px;'
});
titleEl.style.marginTop = '0';
titleEl.style.marginBottom = '0';
toggleBtn.style.marginTop = '0';
toggleBtn.style.flexShrink = '0';
titleEl.parentNode.insertBefore(row, titleEl);
row.appendChild(titleEl);
row.appendChild(toggleBtn);
}
function appendSubSections(modal, blockedSubs, excludedSubsList) {
// 차단된 서브 섹션
if (blockedSubs.length > 0) {
const subTitle = el('div', {
text: '차단된 서브',
css: 'font-weight:bold;margin-top:12px;margin-bottom:8px;font-size:15px;color:#ff6c3b;'
});
modal.appendChild(subTitle);
const subDesc = el('div', {
text: '서브 차단시 기본적으로 게시물이 숨겨지지만 숨김처리 된 게시물을 클릭하여 볼 수 있습니다',
css: 'font-size:12px;color:#999;margin-bottom:8px;line-height:1.4;'
});
modal.appendChild(subDesc);
const subGrid = el('div', { css: 'display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-bottom:16px;' });
blockedSubs.forEach(item => {
const { handle } = item;
const subItem = el('div', { css: 'display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;column-gap:8px;padding:6px 8px;background:#333;border-radius:4px;min-width:0;width:100%;box-sizing:border-box;overflow:hidden;' });
const subName = el('span', {
text: handle,
css: 'font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;color:#ff6c3b;text-decoration:underline;min-width:0;',
on: { click: () => { window.open(`/s/${handle}`, '_blank'); } }
});
subItem.appendChild(subName);
const btnWrap = el('div', { css: 'display:flex;gap:4px;flex-wrap:nowrap;align-items:center;white-space:nowrap;' });
const excludeBtn = el('button', {
text: '제외',
css: 'background:#ff9800;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;',
on: {
click: async () => {
// [배타적 전환] 차단 -> 제외
await setSubMode(handle, 'excluded');
await updateAll();
const modal = document.getElementById('kone-manage-modal');
if (modal) { modal.remove(); State.isManageUIOpen = false; }
showManageUI();
}
}
});
btnWrap.appendChild(excludeBtn);
const removeBtn = el('button', {
text: '해제',
css: 'background:#f44;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;',
on: {
click: async () => {
await removeUser(handle, 'sub');
await updateAll();
await reRenderManageUI();
}
}
});
btnWrap.appendChild(removeBtn);
subItem.appendChild(btnWrap);
subGrid.appendChild(subItem);
});
modal.appendChild(subGrid);
const blockedMoreBtn = makeListCollapsible(subGrid, 4);
if (blockedMoreBtn) placeToggleNextToTitle(subTitle, blockedMoreBtn);
}
// 제외된 서브 섹션
if (excludedSubsList.length > 0) {
const excludedTitle = el('div', {
text: '제외된 서브',
css: 'font-weight:bold;margin-top:12px;margin-bottom:8px;font-size:15px;color:#f44;'
});
modal.appendChild(excludedTitle);
const excludedDesc = el('div', {
text: '서브 제외시 숨김처리된 게시물을 클릭하여 보여지지 않습니다',
css: 'font-size:12px;color:#999;margin-bottom:8px;line-height:1.4;'
});
modal.appendChild(excludedDesc);
const excludedGrid = el('div', { css: 'display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-bottom:16px;' });
excludedSubsList.forEach(item => {
const { handle } = item;
const subItem = el('div', { css: 'display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;column-gap:8px;padding:6px 8px;background:#333;border-radius:4px;min-width:0;width:100%;box-sizing:border-box;overflow:hidden;' });
const subName = el('span', {
text: handle,
css: 'font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;color:#f44;text-decoration:underline;min-width:0;',
on: { click: () => { window.open(`/s/${handle}`, '_blank'); } }
});
subItem.appendChild(subName);
const btnWrap = el('div', { css: 'display:flex;gap:4px;flex-wrap:nowrap;align-items:center;white-space:nowrap;' });
const blockBtn = el('button', {
text: '차단',
css: 'background:#ff9800;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;',
on: {
click: async () => {
// [배타적 전환] 제외 -> 차단
await setSubMode(handle, 'blocked');
await updateAll();
const modal = document.getElementById('kone-manage-modal');
if (modal) { modal.remove(); State.isManageUIOpen = false; }
showManageUI();
}
}
});
btnWrap.appendChild(blockBtn);
const removeBtn = el('button', {
text: '해제',
css: 'background:#f44;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;font-size:11px;white-space:nowrap;flex-shrink:0;',
on: {
click: async () => {
await removeUser(handle, 'sub');
const excluded = await getExcludedSubs();
const index = excluded.indexOf(handle);
if (index > -1) {
excluded.splice(index, 1);
await saveExcludedSubs(excluded);
}
await updateAll();
await reRenderManageUI();
}
}
});
btnWrap.appendChild(removeBtn);
subItem.appendChild(btnWrap);
excludedGrid.appendChild(subItem);
});
modal.appendChild(excludedGrid);
const excludedMoreBtn = makeListCollapsible(excludedGrid, 4);
if (excludedMoreBtn) placeToggleNextToTitle(excludedTitle, excludedMoreBtn);
}
}
function createNoteEditorUI({
initialNote,
color,
emptyLabel,
onSave
}) {
const noteContainer = document.createElement('div');
noteContainer.style.cssText = 'margin-bottom:6px;';
const noteDiv = document.createElement('div');
noteDiv.style.cssText = `padding:4px 8px;background:#333;border-radius:4px;font-size:12px;color:#eee;cursor:pointer;position:relative;`;
noteDiv.textContent = initialNote && initialNote.trim() !== '' ? initialNote : emptyLabel;
const noteTextarea = document.createElement('textarea');
noteTextarea.style.cssText = `display:none;width:100%;padding:4px 8px;background:#444;border:1px solid #666;border-radius:4px;font-size:12px;color:#eee;resize:vertical;min-height:60px;font-family:inherit;`;
noteTextarea.value = initialNote || '';
const noteBtnWrap = document.createElement('div');
noteBtnWrap.style.cssText = 'display:none;gap:4px;margin-top:4px;';
const saveNoteBtn = document.createElement('button');
saveNoteBtn.textContent = '저장';
saveNoteBtn.style.cssText = 'background:#4caf50;color:#fff;border:none;padding:3px 8px;border-radius:3px;cursor:pointer;font-size:11px;';
const cancelNoteBtn = document.createElement('button');
cancelNoteBtn.textContent = '취소';
cancelNoteBtn.style.cssText = 'background:#666;color:#fff;border:none;padding:3px 8px;border-radius:3px;cursor:pointer;font-size:11px;';
noteBtnWrap.appendChild(saveNoteBtn);
noteBtnWrap.appendChild(cancelNoteBtn);
noteDiv.addEventListener('click', () => {
noteDiv.style.display = 'none';
noteTextarea.style.display = 'block';
noteBtnWrap.style.display = 'flex';
noteTextarea.focus();
});
saveNoteBtn.addEventListener('click', async () => {
const newNote = noteTextarea.value.trim();
const saveResult = await onSave(newNote);
if (saveResult && saveResult.remove) return;
const finalNote = saveResult && typeof saveResult.displayNote === 'string'
? saveResult.displayNote
: newNote;
noteDiv.textContent = finalNote !== '' ? finalNote : emptyLabel;
noteDiv.style.display = 'block';
noteTextarea.style.display = 'none';
noteBtnWrap.style.display = 'none';
});
cancelNoteBtn.addEventListener('click', () => {
noteTextarea.value = initialNote || '';
noteDiv.style.display = 'block';
noteTextarea.style.display = 'none';
noteBtnWrap.style.display = 'none';
});
noteContainer.appendChild(noteDiv);
noteContainer.appendChild(noteTextarea);
noteContainer.appendChild(noteBtnWrap);
return noteContainer;
}
function appendUserSections(modal, users, blocked) {
// 메모 유저 섹션 (차단되지 않은 유저)
const memoOnlyUsers = blocked.filter(u => !u.block && !u.excluded && u.note && u.note.trim() !== '');
if (memoOnlyUsers.length > 0) {
const memoTitle = el('div', {
text: '메모 유저 (차단 안됨)',
css: 'font-weight:bold;margin-top:12px;margin-bottom:8px;font-size:15px;color:#4caf50;'
});
modal.appendChild(memoTitle);
const memoList = el('ul', { css: 'list-style:none;padding:0;margin:0;' });
memoOnlyUsers.forEach(item => {
const { handle, username, note } = item;
const li = el('li', { css: 'margin:8px 0;padding:8px;border-bottom:1px solid #444;' });
const infoDiv = el('div', { css: 'margin-bottom:4px;' });
const displayName = (username && username.length > 0) ? `${username[0]}(@${handle})` : `@${handle}`;
infoDiv.innerHTML = `<strong style="color:#4caf50;">${displayName}</strong>`;
li.appendChild(infoDiv);
const noteContainer = createNoteEditorUI({
initialNote: note || '',
color: '#4caf50',
emptyLabel: '메모: (없음 - 클릭하여 추가)',
onSave: async (newNote) => {
const usernameForSave = (username && username.length > 0) ? username[0] : '';
await addNote(handle, usernameForSave, newNote, true);
if (newNote === '') {
await refreshAfterNoteChange();
await reRenderManageUI();
return { remove: true };
}
await refreshAfterNoteChange();
await reRenderManageUI();
}
});
li.appendChild(noteContainer);
const btnWrap = el('div', { css: 'display:flex;justify-content:space-between;gap:4px;margin-top:6px;' });
const leftBtnWrap = el('div', { css: 'display:flex;gap:4px;' });
const visitUserBtn = el('button', {
text: '페이지 방문',
css: 'background:#2196f3;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: { click: () => { window.open(`/u/${handle}`, '_blank'); } }
});
leftBtnWrap.appendChild(visitUserBtn);
const rightBtnWrap = el('div', { css: 'display:flex;gap:4px;' });
const deleteNoteBtn = el('button', {
text: '메모 삭제',
css: 'background:#f44;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: {
click: async () => {
if (confirm('메모를 삭제하시겠습니까?')) {
await removeUser(handle);
await refreshAfterNoteChange();
await reRenderManageUI();
}
}
}
});
rightBtnWrap.appendChild(deleteNoteBtn);
btnWrap.appendChild(leftBtnWrap);
btnWrap.appendChild(rightBtnWrap);
li.appendChild(btnWrap);
memoList.appendChild(li);
});
modal.appendChild(memoList);
const memoMoreBtn = makeListCollapsible(memoList, 4);
if (memoMoreBtn) placeToggleNextToTitle(memoTitle, memoMoreBtn);
}
// 유저 섹션
if (users.length > 0) {
const userTitle = el('div', {
text: '차단된 유저',
css: 'font-weight:bold;margin-top:12px;margin-bottom:8px;font-size:15px;color:#ff6c3b;'
});
modal.appendChild(userTitle);
const userList = el('ul', { css: 'list-style:none;padding:0;margin:0;' });
users.forEach(item => {
const { handle, username, note } = item;
const li = el('li', { css: 'margin:8px 0;padding:8px;border-bottom:1px solid #444;' });
const infoDiv = el('div', { css: 'margin-bottom:4px;' });
const displayName = `@${handle}`;
infoDiv.innerHTML = `<strong style="color:#ff6c3b;">${displayName}</strong>`;
li.appendChild(infoDiv);
const noteContainer = createNoteEditorUI({
initialNote: note || '',
color: '#ffeb3b',
emptyLabel: '메모: (없음 - 클릭하여 추가)',
onSave: async (newNote) => {
const usernameForSave = (username && username.length > 0) ? username[0] : '';
await addNote(handle, usernameForSave, newNote, true);
await refreshAfterNoteChange();
await reRenderManageUI();
// 메모 저장 후 "메모삭제+차단해제" 버튼 동적 갱신
const existingRmBtn = rightBtnWrap.querySelector('.kone-rm-memo-unblock-btn');
if (newNote !== '') {
if (!existingRmBtn) {
const newRmBtn = el('button', {
text: '메모삭제+차단해제',
css: 'background:#e91e63;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;'
});
newRmBtn.className += ' kone-rm-memo-unblock-btn';
newRmBtn.addEventListener('click', async () => {
await removeUser(handle);
await updateAll();
await reRenderManageUI();
});
rightBtnWrap.appendChild(newRmBtn);
}
} else {
if (existingRmBtn) existingRmBtn.remove();
}
}
});
li.appendChild(noteContainer);
const btnWrap = el('div', { css: 'display:flex;justify-content:space-between;gap:4px;margin-top:6px;' });
const leftBtnWrap = el('div', { css: 'display:flex;gap:4px;' });
const visitUserBtn = el('button', {
text: '페이지 방문',
css: 'background:#2196f3;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: { click: () => { window.open(`/u/${handle}`, '_blank'); } }
});
leftBtnWrap.appendChild(visitUserBtn);
const rightBtnWrap = el('div', { css: 'display:flex;gap:4px;' });
const unblockBtn = el('button', {
text: '차단 해제',
css: 'background:#f44;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: {
click: async () => {
await removeUser(handle);
await updateAll();
await reRenderManageUI();
}
}
});
rightBtnWrap.appendChild(unblockBtn);
if (note && note.trim() !== '') {
const removeMemoUnblockBtn = el('button', {
text: '메모삭제+차단해제',
css: 'background:#e91e63;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: {
click: async () => {
await removeUser(handle);
await updateAll();
await reRenderManageUI();
}
}
});
removeMemoUnblockBtn.className += ' kone-rm-memo-unblock-btn';
rightBtnWrap.appendChild(removeMemoUnblockBtn);
}
btnWrap.appendChild(leftBtnWrap);
btnWrap.appendChild(rightBtnWrap);
li.appendChild(btnWrap);
userList.appendChild(li);
});
modal.appendChild(userList);
const userMoreBtn = makeListCollapsible(userList, 4);
if (userMoreBtn) placeToggleNextToTitle(userTitle, userMoreBtn);
}
const excludedOnlyUsers = blocked.filter(u => u.type === 'user' && u.excluded);
if (excludedOnlyUsers.length > 0) {
const excludedUserTitle = el('div', {
text: '제외된 유저',
css: 'font-weight:bold;margin-top:12px;margin-bottom:8px;font-size:15px;color:#f44;'
});
modal.appendChild(excludedUserTitle);
const excludedUserList = el('ul', { css: 'list-style:none;padding:0;margin:0;' });
excludedOnlyUsers.forEach(item => {
const { handle, username, note } = item;
const li = el('li', { css: 'margin:8px 0;padding:8px;border-bottom:1px solid #444;' });
const infoDiv = el('div', { css: 'margin-bottom:4px;' });
const displayName = `@${handle}`;
infoDiv.innerHTML = `<strong style="color:#f44;">${displayName}</strong>`;
li.appendChild(infoDiv);
const noteContainer = createNoteEditorUI({
initialNote: note || '',
color: '#f44',
emptyLabel: '메모: (없음 - 클릭하여 추가)',
onSave: async (newNote) => {
const usernameForSave = (username && username.length > 0) ? username[0] : '';
await addNote(handle, usernameForSave, newNote, true);
await refreshAfterNoteChange();
await reRenderManageUI();
// 메모 저장 후 "메모삭제+제외해제" 버튼 동적 갱신
const existingRmBtn = rightBtnWrap.querySelector('.kone-rm-memo-unexclude-btn');
if (newNote !== '') {
if (!existingRmBtn) {
const newRmBtn = el('button', {
text: '메모삭제+제외해제',
css: 'background:#e91e63;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;'
});
newRmBtn.className += ' kone-rm-memo-unexclude-btn';
newRmBtn.addEventListener('click', async () => {
await removeUser(handle);
await updateAll();
await reRenderManageUI();
});
rightBtnWrap.appendChild(newRmBtn);
}
} else {
if (existingRmBtn) existingRmBtn.remove();
}
}
});
li.appendChild(noteContainer);
const btnWrap = el('div', { css: 'display:flex;justify-content:space-between;gap:4px;margin-top:6px;' });
const leftBtnWrap = el('div', { css: 'display:flex;gap:4px;' });
leftBtnWrap.appendChild(el('button', {
text: '페이지 방문',
css: 'background:#2196f3;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: { click: () => { window.open(`/u/${handle}`, '_blank'); } }
}));
const rightBtnWrap = el('div', { css: 'display:flex;gap:4px;' });
rightBtnWrap.appendChild(el('button', {
text: '제외 해제',
css: 'background:#f44;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: {
click: async () => {
const user = await getUser(handle);
if (!user) return;
await UserRepo.put({
handle,
block: user.block || false,
excluded: false,
username: user.username || [],
note: user.note || '',
type: user.type || 'user'
});
await refreshUserCache();
await updateAll();
await reRenderManageUI();
}
}
}));
if (note && note.trim() !== '') {
const rmMemoUnexcludeBtn = el('button', {
text: '메모삭제+제외해제',
css: 'background:#e91e63;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:12px;',
on: {
click: async () => {
await removeUser(handle);
await updateAll();
await reRenderManageUI();
}
}
});
rmMemoUnexcludeBtn.className += ' kone-rm-memo-unexclude-btn';
rightBtnWrap.appendChild(rmMemoUnexcludeBtn);
}
btnWrap.appendChild(leftBtnWrap);
btnWrap.appendChild(rightBtnWrap);
li.appendChild(btnWrap);
excludedUserList.appendChild(li);
});
modal.appendChild(excludedUserList);
const excludedUserMoreBtn = makeListCollapsible(excludedUserList, 4);
if (excludedUserMoreBtn) placeToggleNextToTitle(excludedUserTitle, excludedUserMoreBtn);
}
}
// ====== 차단 관리 UI ======
async function showManageUI() {
State.isManageUIOpen = true;
const blocked = await getAllUser();
const blockedList = blocked.filter(u => u.block);
const modal = createManageModalBase();
appendImportExportSection(modal);
appendTitleFilterSection(modal);
// 서브와 유저 분리
const excludedSubs = await getExcludedSubs();
const subs = blockedList.filter(item => item.type === 'sub');
const users = blockedList.filter(item => item.type === 'user');
const blockedSubs = subs.filter(item => !excludedSubs.includes(item.handle));
// SettingsRepo의 excludedSubs를 기준으로 최근에 추가/토글된 항목이 앞에 오도록 역순으로 매핑
const excludedSubsList = await Promise.all(excludedSubs.slice().reverse().map(async sub => {
const user = await getUser(sub, 'sub');
return {
handle: sub,
type: 'sub',
block: user ? user.block : false
};
}));
appendUserSections(modal, users, blocked);
appendSubSections(modal, blockedSubs, excludedSubsList);
document.body.appendChild(modal);
}
async function showButton() {
const excludedSubs = (await getExcludedSubs()).map(s => s.toLowerCase());
const rowContext = {
hiddenUsers: State.hiddenUsers,
excludedUsers: State.excludedUsers,
hiddenSubs: State.hiddenSubs,
excludedSubs,
titlePatterns: State.titlePatterns,
titleExcludePatterns: State.titleExcludePatterns
};
const allHiddenTargets = getPostTargets().filter(el =>
el.style.display === 'none' || el.dataset.highlighted === 'true'
);
let excludedCount = 0;
let normalHiddenCount = 0;
allHiddenTargets.forEach(el => {
const rowState = classifyRow(el, rowContext);
if (rowState.isExcluded) {
excludedCount++;
el.dataset.koneStatus = 'excluded';
} else if (rowState.isBlocked) {
normalHiddenCount++;
el.dataset.koneStatus = 'blocked';
}
});
const table = document.querySelector('div.h-full.flex');
if (!table) return;
let oldRow = table.querySelector('.hidden-post-counter');
if (oldRow) oldRow.remove();
const newRow = document.createElement('div');
newRow.classList.add('hidden-post-counter', 'ml-auto');
newRow.style.display = 'flex';
newRow.style.alignItems = 'center';
newRow.style.gap = '10px';
const toggleWrapper = document.createElement('label');
toggleWrapper.style.cssText = 'display:flex;align-items:center;gap:4px;cursor:pointer;margin-right:4px;user-select:none;';
const toggleInput = document.createElement('input');
toggleInput.type = 'checkbox';
toggleInput.checked = State.isEnabled;
toggleInput.style.cssText = 'width:14px;height:14px;cursor:pointer;margin:0;';
toggleInput.addEventListener('change', async (e) => {
e.stopPropagation();
State.isEnabled = !!e.target.checked;
GM_setValue('kone_block_enabled', State.isEnabled);
await updateAll();
});
const toggleLabel = document.createElement('span');
toggleLabel.textContent = '필터';
toggleLabel.classList.add('text-[13px]', 'text-center', 'pt-0.5', 'text-nowrap', 'font-bold');
toggleLabel.style.color = State.isEnabled ? '#4FC3F7' : '#999';
toggleWrapper.appendChild(toggleInput);
toggleWrapper.appendChild(toggleLabel);
newRow.appendChild(toggleWrapper);
const innerSpan1 = document.createElement('span');
innerSpan1.textContent = `숨김처리 된 게시물: ${normalHiddenCount} / 제외처리 된 게시물: ${excludedCount}`;
innerSpan1.classList.add('text-[13px]', 'text-center', 'pt-0.5', 'text-nowrap');
newRow.appendChild(innerSpan1);
newRow.style.cursor = 'pointer';
newRow.style.opacity = '1';
newRow.addEventListener('click', async () => {
State.isHidden = !State.isHidden;
await updateAll();
});
if (!showButton._docClickBound) {
showButton._docClickBound = true;
document.addEventListener('click', async (e) => {
if (e.target.closest('.hidden-post-counter')) return;
// 필터가 활성화된 상태에서 숨김 해제 중일 때만 밖을 클릭 시 다시 숨김
if (State.isEnabled && !State.isHidden) {
State.isHidden = true;
await updateAll();
}
}, true);
}
table.insertBefore(newRow, table.firstChild);
}
// ====== 라우터 제거 (페이지 새로고침) ======
function isRouterBypassTarget(target) {
if (!target) return true;
if (target.closest('.kone-click-menu')) return true;
if (target.closest('.kone-floating-btn')) return true;
if (target.closest('.kone-handle-tag')) return true;
if (target.closest('a[data-kone-download]')) return true;
if (location.pathname === '/s/all') {
if (target.closest('.col-span-2 > div')) return true;
const mobileSub = target.closest('.shrink-0 > span');
if (mobileSub) {
const mobileText = (mobileSub.innerText || '').trim();
const cleanSub = mobileText.replace(/\s*\|.*$/, '').trim();
if (cleanSub && !cleanSub.includes('분') && !cleanSub.includes('전')) return true;
}
}
return false;
}
function shouldSkipRouterNavigation(currentUrl, targetUrl) {
return currentUrl.pathname === targetUrl.pathname &&
(targetUrl.searchParams.has('p') || targetUrl.searchParams.has('c'));
}
(function removeRouter() {
document.addEventListener('click', function (e) {
if (isRouterBypassTarget(e.target)) return;
const a = e.target.closest('a');
if (a && a.href && a.target !== '_blank') {
try {
const currentUrl = new URL(location.href);
const targetUrl = new URL(a.href);
if (shouldSkipRouterNavigation(currentUrl, targetUrl)) {
return;
}
} catch (err) {
console.error('URL 파싱 오류:', err);
}
e.preventDefault();
window.location.href = a.href;
}
}, true);
})();
async function unblockUser(handle) {
handle = handle.toLowerCase();
const user = await getUser(handle);
if (!user) return;
return UserRepo.put({
handle,
block: false,
excluded: user.excluded || false,
username: user.username,
note: user.note || '',
type: user.type || 'user'
});
}
// ====== 사용자 프로필 페이지 메모 표시 ======
async function displayUserProfileNote() {
const pathMatch = location.pathname.match(/^\/u\/([^\/]+)/);
if (!pathMatch) return;
if (displayUserProfileNote._running) return;
displayUserProfileNote._running = true;
try {
const handle = pathMatch[1].toLowerCase();
const profileHeader = document.querySelector('.mt-16.mb-4.p-4.flex.flex-col');
if (!profileHeader) return;
profileHeader.querySelectorAll('.profile-memo-ui').forEach(el => el.remove());
const user = await getUser(handle);
const note = user?.note || '';
const isBlocked = user?.block || false;
const isExcluded = user?.excluded || false;
const memoContainer = document.createElement('div');
memoContainer.className = 'profile-memo-ui';
memoContainer.style.cssText = `
margin-top: 16px;padding: 12px;
background: rgba(120, 120, 120, 0.1);
border-radius: 8px;border: 1px solid rgba(120, 120, 120, 0.2);
`;
const rowContainer = document.createElement('div');
rowContainer.style.cssText = 'display: flex;align-items: center;gap: 8px;flex-wrap: wrap;';
const memoDisplay = document.createElement('div');
memoDisplay.style.cssText = `
flex: 1;min-width: 200px;padding: 8px 12px;
background: rgba(100, 100, 100, 0.1);border-radius: 4px;
cursor: pointer;font-size: 14px;color: #ff6c3b;
min-height: 36px;display: flex;align-items: center;
`;
memoDisplay.textContent = note ? `📝 ${note}` : '📝 메모 없음 (클릭하여 추가)';
const memoTextarea = document.createElement('textarea');
memoTextarea.style.cssText = `
display: none;flex: 1;min-width: 200px;padding: 8px;
background: rgba(68, 68, 68, 0.8);border: 1px solid rgba(120, 120, 120, 0.5);
border-radius: 4px;font-size: 14px;color: #ffeb3b;resize: vertical;min-height: 80px;
`;
memoTextarea.value = note;
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = 'display: flex; gap: 6px; flex-shrink: 0;';
const editButtonWrap = document.createElement('div');
editButtonWrap.style.cssText = 'display: none; gap: 6px;';
const saveButton = document.createElement('button');
saveButton.textContent = '💾 저장';
saveButton.style.cssText = `background: #4caf50;color: #fff;border: none;padding: 8px 14px;border-radius: 4px;cursor: pointer;font-size: 13px;white-space: nowrap;height: 36px;`;
const cancelButton = document.createElement('button');
cancelButton.textContent = '❌ 취소';
cancelButton.style.cssText = `background: #666;color: #fff;border: none;padding: 8px 14px;border-radius: 4px;cursor: pointer;font-size: 13px;white-space: nowrap;height: 36px;`;
editButtonWrap.appendChild(saveButton);
editButtonWrap.appendChild(cancelButton);
const normalButtonWrap = document.createElement('div');
normalButtonWrap.style.cssText = 'display: flex; gap: 6px;';
const blockButton = document.createElement('button');
blockButton.textContent = isBlocked ? '⭕ 차단 해제' : '🚫 차단';
blockButton.style.cssText = `background: ${isBlocked ? '#ff9800' : '#f44'};color: #fff;border: none;padding: 8px 14px;border-radius: 4px;cursor: pointer;font-size: 13px;white-space: nowrap;height: 36px;`;
const excludeButton = document.createElement('button');
excludeButton.textContent = isExcluded ? '⭕ 제외 해제' : '❌ 제외';
excludeButton.style.cssText = `background: ${isExcluded ? '#2e7d32' : '#4caf50'};color: #fff;border: none;padding: 8px 14px;border-radius: 4px;cursor: pointer;font-size: 13px;white-space: nowrap;height: 36px;`;
const deleteAllButton = document.createElement('button');
if (isBlocked && note) {
deleteAllButton.textContent = '🗑️ 메모+차단 해제';
deleteAllButton.style.cssText = `background: #e91e63;color: #fff;border: none;padding: 8px 14px;border-radius: 4px;cursor: pointer;font-size: 13px;white-space: nowrap;height: 36px;`;
}
normalButtonWrap.appendChild(blockButton);
normalButtonWrap.appendChild(excludeButton);
if (isBlocked && note) normalButtonWrap.appendChild(deleteAllButton);
buttonContainer.appendChild(editButtonWrap);
buttonContainer.appendChild(normalButtonWrap);
memoDisplay.addEventListener('click', () => {
memoDisplay.style.display = 'none';
memoTextarea.style.display = 'block';
normalButtonWrap.style.display = 'none';
editButtonWrap.style.display = 'flex';
memoTextarea.focus();
});
saveButton.addEventListener('click', async () => {
const newNote = memoTextarea.value.trim();
const usernameEl = profileHeader.querySelector('h1');
const username = usernameEl ? usernameEl.textContent.trim() : '';
await addNote(handle, username, newNote, true);
alert('메모가 저장되었습니다.');
await displayUserProfileNote();
});
cancelButton.addEventListener('click', () => {
memoTextarea.value = note;
memoDisplay.style.display = 'flex';
memoTextarea.style.display = 'none';
normalButtonWrap.style.display = 'flex';
editButtonWrap.style.display = 'none';
});
blockButton.addEventListener('click', async () => {
const usernameEl = profileHeader.querySelector('h1');
const username = usernameEl ? usernameEl.textContent.trim() : '';
if (isBlocked) {
if (confirm(`${username}(${handle}) 사용자의 차단을 해제하시겠습니까?\n(메모는 유지됩니다)`)) {
await unblockUser(handle);
alert('차단이 해제되었습니다.');
await displayUserProfileNote();
}
} else {
const memo = prompt(`${username}(${handle}) 사용자를 차단합니다.\n메모를 남기시겠습니까? (선택사항)`, note);
if (memo !== null) {
await addBlockUser(handle, username, 'user');
if (memo.trim() !== '') await addNote(handle, username, memo.trim(), true);
alert('차단되었습니다.');
await displayUserProfileNote();
}
}
});
excludeButton.addEventListener('click', async () => {
const usernameEl = profileHeader.querySelector('h1');
const username = usernameEl ? usernameEl.textContent.trim() : '';
if (isExcluded) {
if (confirm(`${username}(${handle}) 사용자의 제외를 해제하시겠습니까?\n(메모는 유지됩니다)`)) {
const current = await getUser(handle);
if (!current) return;
await UserRepo.put({
handle,
block: current.block || false,
excluded: false,
username: current.username || [],
note: current.note || '',
type: current.type || 'user'
});
await refreshUserCache();
alert('제외가 해제되었습니다.');
await displayUserProfileNote();
}
} else {
if (confirm(`${username}(${handle}) 사용자를 제외하시겠습니까?`)) {
await addExcludedUser(handle, username);
alert('제외되었습니다.');
await displayUserProfileNote();
}
}
});
if (isBlocked && note) {
deleteAllButton.addEventListener('click', async () => {
const usernameEl = profileHeader.querySelector('h1');
const username = usernameEl ? usernameEl.textContent.trim() : '';
if (confirm(`${username}(${handle}) 사용자의 메모와 차단을 모두 해제하시겠습니까?`)) {
await removeUser(handle);
alert('메모와 차단이 모두 해제되었습니다.');
await displayUserProfileNote();
}
});
}
rowContainer.appendChild(memoDisplay);
rowContainer.appendChild(memoTextarea);
rowContainer.appendChild(buttonContainer);
memoContainer.appendChild(rowContainer);
profileHeader.appendChild(memoContainer);
} finally {
displayUserProfileNote._running = false;
}
}
// ====== 데이터 내보내기/가져오기 ======
function normalizeUserRuleItems(items) {
if (!Array.isArray(items)) return [];
const byHandle = new Map();
items.forEach(item => {
if (!item || typeof item !== 'object') return;
const handle = typeof item.handle === 'string' ? item.handle.trim().toLowerCase() : '';
if (!handle) return;
const usernames = normalizeStringArray(item.usernames, { lowerCase: true });
const updatedAt = item.updatedAt || 0;
if (!byHandle.has(handle)) {
byHandle.set(handle, { handle, usernames, updatedAt });
return;
}
const existing = byHandle.get(handle);
const merged = normalizeStringArray(
[...existing.usernames, ...usernames],
{ lowerCase: true }
);
byHandle.set(handle, {
handle,
usernames: merged,
updatedAt: Math.max(existing.updatedAt, updatedAt)
});
});
return Array.from(byHandle.values()).sort((a, b) => a.handle.localeCompare(b.handle, 'ko-KR'));
}
function normalizeUserNoteItems(items) {
if (!Array.isArray(items)) return [];
const byHandle = new Map();
items.forEach(item => {
if (!item || typeof item !== 'object') return;
const handle = typeof item.handle === 'string' ? item.handle.trim().toLowerCase() : '';
const note = typeof item.note === 'string' ? item.note.trim() : '';
const updatedAt = item.updatedAt || 0;
if (!handle || !note) return;
if (!byHandle.has(handle)) {
byHandle.set(handle, { handle, note, updatedAt });
} else {
const existing = byHandle.get(handle);
byHandle.set(handle, {
handle,
note,
updatedAt: Math.max(existing.updatedAt, updatedAt)
});
}
});
return Array.from(byHandle.values()).sort((a, b) => a.handle.localeCompare(b.handle, 'ko-KR'));
}
async function buildOptimizedExportData(allUsers, excludedSubs, titleFilters, titleExcludeFilters) {
const blockedSubSet = new Set();
const excludedSubList = normalizeStringArray(excludedSubs);
const excludedSubSet = new Set(excludedSubList);
const blockedUserItems = [];
const excludedUserItems = [];
const noteItems = [];
allUsers.forEach(user => {
const handle = (user?.handle || '').toString().trim().toLowerCase();
if (!handle) return;
const type = user?.type || 'user';
const updatedAt = user?.updatedAt || 0;
if (type === 'sub') {
if (user.block) blockedSubSet.add(handle);
return;
}
const usernames = normalizeStringArray(user?.username, { lowerCase: true });
if (user.block) blockedUserItems.push({ handle, usernames, updatedAt });
if (user.excluded) excludedUserItems.push({ handle, usernames, updatedAt });
const note = typeof user.note === 'string' ? user.note.trim() : '';
if (note) noteItems.push({ handle, note, updatedAt });
});
const blockedSubs = Array.from(blockedSubSet)
.filter(sub => !excludedSubSet.has(sub));
// sub 항목들에도 updatedAt 포함 (sub용 레코드를 찾아 매핑)
const subItems = await Promise.all([
...blockedSubs.map(async handle => {
const u = await getUser(handle, 'sub');
return { handle, mode: 'blocked', updatedAt: u?.updatedAt || 0 };
}),
...excludedSubList.map(async handle => {
const u = await getUser(handle, 'sub');
return { handle, mode: 'excluded', updatedAt: u?.updatedAt || 0 };
})
]);
subItems.sort((a, b) => a.handle.localeCompare(b.handle, 'ko-KR'));
return {
version: '2.0',
exportedAt: new Date().toISOString(),
subs: subItems,
users: {
blocked: normalizeUserRuleItems(blockedUserItems),
excluded: normalizeUserRuleItems(excludedUserItems),
notes: normalizeUserNoteItems(noteItems)
},
filters: {
blockTitle: normalizeStringArray(titleFilters, { lowerCase: false }),
excludeTitle: normalizeStringArray(titleExcludeFilters, { lowerCase: false })
}
};
}
function validateNewImportSchema(data) {
if (!data || typeof data !== 'object') throw new Error('JSON 객체가 아닙니다.');
if (data.version !== '2.0') throw new Error('지원하지 않는 버전입니다. version: 2.0 파일만 지원합니다.');
if (!Array.isArray(data.subs)) throw new Error('subs는 배열이어야 합니다.');
if (!data.users || typeof data.users !== 'object') throw new Error('users 필드가 필요합니다.');
if (!data.filters || typeof data.filters !== 'object') throw new Error('filters 필드가 필요합니다.');
const invalidSub = data.subs.find(item =>
!item ||
typeof item !== 'object' ||
typeof item.handle !== 'string' ||
(item.mode !== 'blocked' && item.mode !== 'excluded')
);
if (invalidSub) throw new Error('subs 항목은 { handle, mode(blocked|excluded) } 형식이어야 합니다.');
if (!Array.isArray(data.users.blocked) || !Array.isArray(data.users.excluded) || !Array.isArray(data.users.notes)) {
throw new Error('users.blocked/excluded/notes는 배열이어야 합니다.');
}
if (!Array.isArray(data.filters.blockTitle) || !Array.isArray(data.filters.excludeTitle)) {
throw new Error('filters.blockTitle/excludeTitle 형식이 올바르지 않습니다.');
}
}
async function exportData() {
try {
const allUsers = await getAllUser();
const excludedSubs = await getExcludedSubs();
const titleFilters = await getTitleFilters();
const titleExcludeFilters = await getTitleExcludeFilters();
const data = await buildOptimizedExportData(
allUsers,
excludedSubs,
titleFilters,
titleExcludeFilters
);
const jsonStr = JSON.stringify(data);
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(jsonStr);
const a = document.createElement('a');
a.href = dataUri;
a.download = `kone-block-data-${new Date().toISOString().slice(0, 10)}.json`;
a.style.display = 'none';
a.setAttribute('data-kone-download', 'true');
document.body.appendChild(a);
a.click();
setTimeout(() => { document.body.removeChild(a); }, 100);
} catch (error) {
console.error('내보내기 실패:', error);
alert('내보내기 중 오류가 발생했습니다.');
}
}
async function importData() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.style.display = 'none';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
validateNewImportSchema(data);
const titleFilters = normalizeStringArray(data.filters.blockTitle, { lowerCase: false });
const titleExcludeFilters = normalizeStringArray(data.filters.excludeTitle, { lowerCase: false });
const converted = buildUserRepoRecordsFromTransferData({
subs: data.subs,
users: data.users
});
await UserRepo.clearAll();
await UserRepo.bulkPut(converted.records);
await saveExcludedSubs(converted.excludedSubs);
await saveTitleFilters(titleFilters);
await saveTitleExcludeFilters(titleExcludeFilters);
await loadTitleFilters();
await refreshUserCache(); // <-- 메모리 캐시 갱신 추가
await updateAll();
syncChannel.postMessage('update_all');
await showManageUI();
if (document.body.contains(input)) document.body.removeChild(input);
} catch (error) {
console.error('가져오기 실패:', error);
alert('가져오기 중 오류가 발생했습니다.\n오류: ' + error.message);
if (document.body.contains(input)) document.body.removeChild(input);
}
};
document.body.appendChild(input);
input.click();
}
async function refreshUserCache() {
try {
const allUsers = await getAllUser();
State.userCache = new Map(allUsers.filter(u => u && u.handle).map(u => {
const handle = u.handle.toString();
const uid = `${u.type || 'user'}:${handle.toLowerCase()}`;
return [uid, u];
}));
rebuildHiddenSetsFromCache();
buildUsernameIndex();
} catch (e) {
console.error('[KoneBlock] refreshUserCache failed:', e);
}
}
async function applyVisibilityPhase() {
if (State.isEnabled && State.isHidden) {
await clearStaleHiddenRows();
await hide();
} else {
// 필터가 꺼져있거나, 숨김 해제 모드인 경우
await show();
}
await handleSubListVisibility();
}
async function refreshUIPhase() {
await displayNote();
await displayUserProfileNote();
await addSubPageButtons();
await addSubListButtons();
setupClickMenus();
showButton();
createFloatingButton();
await handleSubPageVisibility(); // ← 추가
}
async function refreshAfterNoteChange() {
await refreshUserCache();
await displayNote();
await showButton();
}
function shouldRunLoadData() {
const p = location.pathname;
if (p === '/s/all') return true;
// 서브 목록 페이지 (/s/{sub})
if (/^\/s\/[^\/]+$/.test(p)) return true;
// 서브 게시글 상세 (/s/{sub}/{post})
if (/^\/s\/[^\/]+\/[^\/]+/.test(p)) return true;
return false;
}
async function performUpdateAll() {
// 1. 가시성 처리 및 UI 갱신 우선 적용 (이미 로드된 캐시 활용)
await applyVisibilityPhase();
await refreshUIPhase();
// 2. 데이터 로딩 (비대기 실행 - 가시성 처리를 방해하지 않음)
if (shouldRunLoadData()) {
loadData();
}
}
async function runScheduledUpdate() {
if (State.isUpdateRunning) {
State.pendingUpdateReason = 'rerun';
return;
}
State.isUpdateRunning = true;
try {
if (State.observer) State.observer.disconnect();
await updateAll();
} finally {
if (State.observer) State.observer.observe(document.body, { childList: true, subtree: true });
State.isUpdateRunning = false;
if (State.pendingUpdateReason) {
State.pendingUpdateReason = null;
scheduleUpdate('pending-rerun', 120);
}
}
}
function scheduleUpdate(reason = 'mutation', delay = 400) {
State.pendingUpdateReason = reason;
if (State.updateDebounceTimer) clearTimeout(State.updateDebounceTimer);
State.updateDebounceTimer = setTimeout(async () => {
State.updateDebounceTimer = null;
const runReason = State.pendingUpdateReason;
State.pendingUpdateReason = null;
if (!runReason) return;
await runScheduledUpdate();
}, delay);
}
// ====== 메인 업데이트 함수 ======
async function updateAll() {
window._koneBlockScriptRunning = true;
try {
await performUpdateAll();
} finally {
window._koneBlockScriptRunning = false;
}
}
window.updateAll = updateAll;
// ====== 플로팅 버튼 생성 (모바일용) ======
function createFloatingButton() {
if (document.querySelector('.kone-floating-btn')) return;
const floatingBtn = document.createElement('div');
floatingBtn.className = 'kone-floating-btn';
floatingBtn.innerHTML = '⚙️';
floatingBtn.style.cssText = `
position:fixed;bottom:20px;right:20px;
width:40px;height:40px;border-radius:50%;
background:#555;color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:20px;cursor:pointer;
box-shadow:0 2px 8px rgba(0,0,0,0.3);
z-index:999998;transition:all 0.3s ease;
`;
floatingBtn.addEventListener('click', (e) => {
e.stopPropagation();
const existingModal = document.getElementById('kone-manage-modal');
if (existingModal) {
closeManageUI();
} else {
showManageUI();
}
});
floatingBtn.addEventListener('mouseenter', () => {
floatingBtn.style.transform = 'scale(1.1)';
floatingBtn.style.background = '#666';
});
floatingBtn.addEventListener('mouseleave', () => {
floatingBtn.style.transform = 'scale(1)';
floatingBtn.style.background = '#555';
});
document.body.appendChild(floatingBtn);
}
// ====== 키보드 단축키 ======
document.addEventListener('keydown', (e) => {
if (e.key === 'F2') {
e.preventDefault();
const existingModal = document.getElementById('kone-manage-modal');
if (existingModal) {
closeManageUI();
} else {
showManageUI();
}
}
if (e.key === 'Escape') {
const existingModal = document.getElementById('kone-manage-modal');
if (existingModal) {
e.preventDefault();
closeManageUI();
}
}
});
// ====== 초기화 및 옵저버 ======
(async () => {
try {
await refreshUserCache();
await loadTitleFilters();
await GistSync.autoSync();
await updateAll();
} catch (e) {
console.error('[KoneBlock] Initialization failed:', e);
// 최소한 UI는 띄워보도록 시도
try {
await updateAll();
} catch (e2) {}
}
let lastUrl = location.href;
let lastPathname = location.pathname;
State.observer = new MutationObserver((mutations) => {
try {
const currentUrl = location.href;
const currentPathname = location.pathname;
if (currentUrl !== lastUrl) {
_subPageBlurDismissed = false;
if (currentPathname === '/s/all' || lastPathname === '/s/all') {
scheduleUpdate('url-change', 800);
}
lastUrl = currentUrl;
lastPathname = currentPathname;
return;
}
const onlyMenuChange = mutations.every(mutation =>
Array.from(mutation.addedNodes).concat(Array.from(mutation.removedNodes))
.every(node =>
node.nodeType === 1 &&
node.classList &&
(node.classList.contains('kone-click-menu') ||
node.classList.contains('kone-handle-tag') ||
node.classList.contains('note-span-wrapper') ||
node.classList.contains('my-inserted-block') ||
node.classList.contains('dlsite-link-appended') ||
node.classList.contains('kone-blur-overlay') ||
node.classList.contains('kone-excluded-warning') ||
node.classList.contains('kone-blur-overlay-banner') ||
node.id === 'kone-manage-modal')
)
);
if (onlyMenuChange) return;
scheduleUpdate('mutation', 400);
} catch (e) {
console.error('[KoneBlock] Observer error:', e);
}
});
State.observer.observe(document.body, { childList: true, subtree: true });
})();
})();