Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/hooks/useCourseData.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export function useCourseData(courses: CourseBase[]) {
setLastRequestTime(fetchedAt);
saveDataToStorage('lastRequestTime', fetchedAt.toString());
} catch (error) {
logger.error('강의 데이터 새로고침 실패:', error);
logger.course.error('강의 데이터 새로고침 실패:', error);
clearLastRequestTime();
setIsError(true);
} finally {
Expand Down Expand Up @@ -204,7 +204,7 @@ export function useCourseData(courses: CourseBase[]) {
return merged;
});
} catch (error) {
logger.warn('강의 추가 데이터 fetch 실패:', error);
logger.course.warn('강의 추가 데이터 fetch 실패:', error);
} finally {
setPendingCourseIds((prev) => {
const next = new Set(prev);
Expand Down
12 changes: 6 additions & 6 deletions src/lib/calendarUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ export const getOAuthToken = async (interactive = false): Promise<string | null>
}
// 비대화형 호출에서 토큰 없음은 미로그인 상태이므로 에러 로그 생략
if (interactive) {
logger.error('OAuth 토큰 획득 실패:', response?.error);
logger.calendar.error('OAuth 토큰 획득 실패:', response?.error);
}
} catch (e) {
if (interactive) {
logger.error('OAuth 메시지 전송 실패:', e);
logger.calendar.error('OAuth 메시지 전송 실패:', e);
}
}
return null;
Expand All @@ -49,7 +49,7 @@ export const removeCachedAuthToken = async (token: string): Promise<void> => {
try {
await chrome.runtime.sendMessage({ action: 'removeCachedAuthToken', token });
} catch (e) {
logger.error('토큰 제거 메시지 전송 실패:', e);
logger.calendar.error('토큰 제거 메시지 전송 실패:', e);
}
};

Expand All @@ -75,12 +75,12 @@ export async function addCalendarEvent(
}
if (!response.ok) {
const errorBody = await response.json();
logger.error('이벤트 추가 실패:', response.status, errorBody);
logger.calendar.error('이벤트 추가 실패:', response.status, errorBody);
return { ok: false, tokenExpired: false };
}
return { ok: true, tokenExpired: false };
} catch (error) {
logger.error('캘린더 이벤트 추가 오류:', error);
logger.calendar.error('캘린더 이벤트 추가 오류:', error);
return { ok: false, tokenExpired: false };
}
}
Expand Down Expand Up @@ -190,7 +190,7 @@ export async function getCalendarEvents(

return { events: allEvents, tokenExpired: false };
} catch (error) {
logger.error('캘린더 이벤트 가져오기 실패:', error);
logger.calendar.error('캘린더 이벤트 가져오기 실패:', error);
return { events: [], tokenExpired: false };
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/courseStatus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ async function fetchAndInject(courseId: string, course: CourseBase, isTracked: b
setFetchCache(courseId);
showStatusBar('success');
} catch (error) {
logger.error('강의 데이터 로드 오류:', error);
logger.course.error('강의 데이터 로드 오류:', error);
showStatusBar('error');
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/fetchCourseData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { logger } from './logger';

function settled<T>(result: PromiseSettledResult<T>, fallback: T): T {
if (result.status === 'fulfilled') return result.value;
logger.warn(result.reason?.message ?? result.reason);
logger.course.warn(result.reason?.message ?? result.reason);
return fallback;
}

Expand Down
111 changes: 87 additions & 24 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
const STORAGE_KEY = 'dotbugi_logs';
const MAX_LOGS = 500;
import { createZip, type ZipFile } from './zip';

const OLD_STORAGE_KEY = 'dotbugi_logs';
const STORAGE_PREFIX = 'dotbugi_logs_';
const MAX_LOGS_PER_CATEGORY = 200;

type LogLevel = 'info' | 'warn' | 'error';
type LogCategory = 'player' | 'course' | 'calendar' | 'storage' | 'general';

const LOG_CATEGORIES: LogCategory[] = ['player', 'course', 'calendar', 'storage', 'general'];

interface LogEntry {
timestamp: string;
Expand Down Expand Up @@ -31,40 +37,82 @@ function formatEntry(level: LogLevel, args: unknown[]): LogEntry {
};
}

async function persist(entry: LogEntry) {
async function persist(category: LogCategory, entry: LogEntry) {
try {
const result = await chrome.storage.local.get(STORAGE_KEY);
const logs: LogEntry[] = result[STORAGE_KEY] ?? [];
const key = STORAGE_PREFIX + category;
const result = await chrome.storage.local.get(key);
const logs: LogEntry[] = result[key] ?? [];
logs.push(entry);
if (logs.length > MAX_LOGS) logs.splice(0, logs.length - MAX_LOGS);
await chrome.storage.local.set({ [STORAGE_KEY]: logs });
if (logs.length > MAX_LOGS_PER_CATEGORY) logs.splice(0, logs.length - MAX_LOGS_PER_CATEGORY);
await chrome.storage.local.set({ [key]: logs });
} catch {
// storage 접근 불가 시 무시 (e.g. 테스트 환경)
}
}

function log(level: LogLevel, ...args: unknown[]) {
function log(category: LogCategory, level: LogLevel, ...args: unknown[]) {
const entry = formatEntry(level, args);
persist(entry);
persist(category, entry);
}

interface CategoryLogger {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}

function createCategoryLogger(category: LogCategory): CategoryLogger {
return {
info: (...args: unknown[]) => log(category, 'info', ...args),
warn: (...args: unknown[]) => log(category, 'warn', ...args),
error: (...args: unknown[]) => log(category, 'error', ...args),
};
}

export const logger = {
info: (...args: unknown[]) => log('info', ...args),
warn: (...args: unknown[]) => log('warn', ...args),
error: (...args: unknown[]) => log('error', ...args),
export const logger: CategoryLogger & Record<LogCategory, CategoryLogger> = {
// 기본 (general) — 기존 호출과 호환
...createCategoryLogger('general'),
// 카테고리별
player: createCategoryLogger('player'),
course: createCategoryLogger('course'),
calendar: createCategoryLogger('calendar'),
storage: createCategoryLogger('storage'),
general: createCategoryLogger('general'),
};

export async function getLogs(): Promise<LogEntry[]> {
// 기존 단일 키 → 카테고리별 마이그레이션 (1회성)
(async () => {
try {
const result = await chrome.storage.local.get(STORAGE_KEY);
return result[STORAGE_KEY] ?? [];
const result = await chrome.storage.local.get(OLD_STORAGE_KEY);
const oldLogs: LogEntry[] | undefined = result[OLD_STORAGE_KEY];
if (!oldLogs || oldLogs.length === 0) return;
const key = STORAGE_PREFIX + 'general';
await chrome.storage.local.set({ [key]: oldLogs.slice(-MAX_LOGS_PER_CATEGORY) });
await chrome.storage.local.remove(OLD_STORAGE_KEY);
} catch {
return [];
// 무시
}
})();

export async function getLogs(): Promise<Record<LogCategory, LogEntry[]>> {
try {
const keys = LOG_CATEGORIES.map((c) => STORAGE_PREFIX + c);
const result = await chrome.storage.local.get(keys);
const logs = {} as Record<LogCategory, LogEntry[]>;
for (const category of LOG_CATEGORIES) {
logs[category] = result[STORAGE_PREFIX + category] ?? [];
}
return logs;
} catch {
const empty = {} as Record<LogCategory, LogEntry[]>;
for (const category of LOG_CATEGORIES) empty[category] = [];
return empty;
}
}

export async function clearLogs(): Promise<void> {
await chrome.storage.local.remove(STORAGE_KEY);
const keys = LOG_CATEGORIES.map((c) => STORAGE_PREFIX + c);
await chrome.storage.local.remove(keys);
}

/** 디버깅용 스토리지 스냅샷 (민감 정보 없음, 트래킹 강의만 포함) */
Expand Down Expand Up @@ -94,16 +142,31 @@ async function getStorageDump(): Promise<string> {
}

export async function downloadLogs(): Promise<void> {
const [logs, storageDump] = await Promise.all([getLogs(), getStorageDump()]);
if (logs.length === 0 && storageDump === '(스토리지 접근 불가)') return;
const [allLogs, storageDump] = await Promise.all([getLogs(), getStorageDump()]);

const encoder = new TextEncoder();
const files: ZipFile[] = [];

for (const category of LOG_CATEGORIES) {
const logs = allLogs[category];
if (logs.length === 0) continue;
const text = logs
.map((l) => `[${l.timestamp}] [${l.level.toUpperCase()}] ${l.message}`)
.join('\n');
files.push({ name: `${category}.txt`, data: encoder.encode(text) });
}

if (storageDump !== '(스토리지 접근 불가)') {
files.push({ name: 'storage-snapshot.json', data: encoder.encode(storageDump) });
}

if (files.length === 0) return;

const logText = logs.map((l) => `[${l.timestamp}] [${l.level.toUpperCase()}] ${l.message}`).join('\n');
const text = [logText, '', '=== Storage Snapshot ===', storageDump].join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const blob = createZip(files);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dotbugi-logs-${new Date().toISOString().slice(0, 10)}.txt`;
a.download = `dotbugi-logs-${new Date().toISOString().slice(0, 10)}.zip`;
a.click();
URL.revokeObjectURL(url);
}
2 changes: 1 addition & 1 deletion src/lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function loadAndTransform<T, R>(
try {
parsedData = JSON.parse(data);
} catch (error) {
logger.error(`JSON 파싱 에러 (${storageKey}):`, error);
logger.storage.error(`JSON 파싱 에러 (${storageKey}):`, error);
return;
}
} else {
Expand Down
98 changes: 98 additions & 0 deletions src/lib/zip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* Minimal ZIP (STORE, no compression) builder.
* No external dependencies — uses standard Web APIs only.
*/

const CRC_TABLE = /* @__PURE__ */ (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c;
}
return table;
})();

function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (let i = 0; i < data.length; i++) crc = CRC_TABLE[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}

function writeU16(view: DataView, offset: number, value: number) {
view.setUint16(offset, value, true);
}

function writeU32(view: DataView, offset: number, value: number) {
view.setUint32(offset, value, true);
}

export interface ZipFile {
name: string;
data: Uint8Array;
}

export function createZip(files: ZipFile[]): Blob {
const encoder = new TextEncoder();
const entries: { name: Uint8Array; data: Uint8Array; crc: number; offset: number }[] = [];

// Build local file headers + data
const localParts: Uint8Array[] = [];
let offset = 0;

for (const file of files) {
const name = encoder.encode(file.name);
const crc = crc32(file.data);

// Local file header: 30 bytes + name
const header = new ArrayBuffer(30);
const hv = new DataView(header);
writeU32(hv, 0, 0x04034b50); // signature
writeU16(hv, 4, 20); // version needed
writeU16(hv, 8, 0); // compression: STORE
writeU32(hv, 14, crc);
writeU32(hv, 18, file.data.length); // compressed size
writeU32(hv, 22, file.data.length); // uncompressed size
writeU16(hv, 26, name.length);

localParts.push(new Uint8Array(header), name, file.data);
entries.push({ name, data: file.data, crc, offset });
offset += 30 + name.length + file.data.length;
}

// Build central directory
const centralParts: Uint8Array[] = [];
let centralSize = 0;

for (const entry of entries) {
const cd = new ArrayBuffer(46);
const cv = new DataView(cd);
writeU32(cv, 0, 0x02014b50); // signature
writeU16(cv, 4, 20); // version made by
writeU16(cv, 6, 20); // version needed
writeU16(cv, 10, 0); // compression: STORE
writeU32(cv, 16, entry.crc);
writeU32(cv, 20, entry.data.length);
writeU32(cv, 24, entry.data.length);
writeU16(cv, 28, entry.name.length);
writeU32(cv, 42, entry.offset);

centralParts.push(new Uint8Array(cd), entry.name);
centralSize += 46 + entry.name.length;
}

// End of central directory record: 22 bytes
const eocd = new ArrayBuffer(22);
const ev = new DataView(eocd);
writeU32(ev, 0, 0x06054b50);
writeU16(ev, 4, 0); // disk number
writeU16(ev, 6, 0); // disk with CD
writeU16(ev, 8, entries.length);
writeU16(ev, 10, entries.length);
writeU32(ev, 12, centralSize);
writeU32(ev, 16, offset); // offset of CD

return new Blob([...localParts, ...centralParts, new Uint8Array(eocd)], {
type: 'application/zip',
});
}
12 changes: 9 additions & 3 deletions src/popover/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,12 @@ export default function Dashboard() {
setCalendarToken(null);
showTokenExpiredNotif();
} else {
getOAuthToken(false).then((token) => {
if (token) setCalendarToken(token);
// 이전에 로그인한 적이 있는 사용자만 자동 토큰 가져오기
chrome.storage.local.get('calendarLoggedIn', (result) => {
if (!result.calendarLoggedIn) return;
getOAuthToken(false).then((token) => {
if (token) setCalendarToken(token);
});
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -240,6 +244,7 @@ export default function Dashboard() {
const token = await getOAuthToken(true);
if (token) {
setCalendarToken(token);
chrome.storage.local.set({ calendarLoggedIn: true });
}
};

Expand All @@ -248,10 +253,11 @@ export default function Dashboard() {
try {
await removeCachedAuthToken(calendarToken);
} catch (e) {
logger.warn('removeCachedAuthToken failed:', e);
logger.calendar.warn('removeCachedAuthToken failed:', e);
}
}
setCalendarToken(null);
chrome.storage.local.remove('calendarLoggedIn');
};

const handleCalendarSync = async () => {
Expand Down
Loading
Loading