@fuzionx/framework 0.1.29 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/index.js +230 -114
- package/cli/templates/{app/fuzionx → make/app}/controllers/HomeController.js +1 -0
- package/cli/templates/{app/tester → make/app}/views/default/errors/404.html +0 -4
- package/cli/templates/{app/fuzionx/views/default/errors/404.html → make/app/views/default/errors/500.html} +1 -2
- package/cli/templates/make/app/views/default/pages/home.html +11 -0
- package/index.js +3 -0
- package/lib/core/Application.js +31 -6
- package/lib/core/Context.js +30 -1
- package/lib/helpers/I18nHelper.js +10 -6
- package/lib/middleware/apiAuth.js +79 -0
- package/lib/middleware/auth.js +42 -0
- package/lib/middleware/bodyParser.js +19 -0
- package/lib/middleware/cors.js +47 -0
- package/lib/middleware/csrf.js +32 -0
- package/lib/middleware/index.js +8 -277
- package/lib/middleware/session.js +27 -0
- package/lib/middleware/theme.js +20 -0
- package/lib/schedule/Job.js +4 -0
- package/lib/schedule/Queue.js +20 -8
- package/lib/schedule/Scheduler.js +84 -75
- package/lib/utilities/ArrUtil.js +112 -0
- package/lib/utilities/DateUtil.js +98 -0
- package/lib/utilities/FunctionUtil.js +119 -0
- package/lib/utilities/NumUtil.js +75 -0
- package/lib/utilities/ObjectUtil.js +170 -0
- package/lib/utilities/PaginationUtil.js +81 -0
- package/lib/utilities/StrUtil.js +105 -0
- package/lib/utilities/index.js +18 -0
- package/package.json +2 -2
- package/cli/templates/app/.env.example.tpl +0 -14
- package/cli/templates/app/.gitignore.tpl +0 -4
- package/cli/templates/app/app.js.tpl +0 -6
- package/cli/templates/app/database/models/User.js +0 -9
- package/cli/templates/app/fuzionx/views/default/errors/500.html +0 -14
- package/cli/templates/app/fuzionx/views/default/pages/home.html +0 -188
- package/cli/templates/app/fuzionx.yaml.tpl +0 -202
- package/cli/templates/app/locales/en.json +0 -52
- package/cli/templates/app/locales/ko.json +0 -52
- package/cli/templates/app/package.json.tpl +0 -16
- package/cli/templates/app/shared/events/userEvents.js +0 -10
- package/cli/templates/app/shared/jobs/CleanupJob.js +0 -18
- package/cli/templates/app/shared/jobs/EmailTask.js +0 -17
- package/cli/templates/app/shared/jobs/VideoPreviewTask.js +0 -47
- package/cli/templates/app/shared/workers/heavy.js +0 -18
- package/cli/templates/app/tester/controllers/FileController.js +0 -288
- package/cli/templates/app/tester/controllers/HomeController.js +0 -36
- package/cli/templates/app/tester/controllers/UserController.js +0 -43
- package/cli/templates/app/tester/middleware/RequestLogger.js +0 -13
- package/cli/templates/app/tester/routes/api.js +0 -397
- package/cli/templates/app/tester/routes/web.js +0 -8
- package/cli/templates/app/tester/services/UserService.js +0 -52
- package/cli/templates/app/tester/views/default/errors/500.html +0 -14
- package/cli/templates/app/tester/views/default/layouts/main.html +0 -82
- package/cli/templates/app/tester/views/default/pages/home.html +0 -56
- package/cli/templates/app/tester/views/default/pages/i18n.html +0 -104
- package/cli/templates/app/tester/views/default/pages/upload.html +0 -149
- package/cli/templates/app/tester/views/default/pages/websocket.html +0 -239
- package/cli/templates/app/tester/views/default/partials/footer.html +0 -8
- package/cli/templates/app/tester/views/default/partials/header.html +0 -20
- package/cli/templates/app/tester/ws/ChatHandler.js +0 -98
- /package/cli/templates/{app/fuzionx/routes/api.js.tpl → make/app/routes/api.js} +0 -0
- /package/cli/templates/{app/fuzionx/routes/web.js.tpl → make/app/routes/web.js} +0 -0
- /package/cli/templates/{app/fuzionx → make/app}/views/default/layouts/main.html +0 -0
|
@@ -1,38 +1,47 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scheduler —
|
|
2
|
+
* Scheduler — 정기 실행 작업 관리 (cron 스케줄)
|
|
3
|
+
*
|
|
4
|
+
* AutoLoader가 shared/jobs/ 에서 Job 클래스를 자동 스캔·등록.
|
|
5
|
+
* handle()은 메인 스레드에서 실행되므로 this.db, this.service() 등
|
|
6
|
+
* 앱 컨텍스트에 접근 가능합니다.
|
|
7
|
+
*
|
|
8
|
+
* CPU-intensive 작업은 handle() 내에서 this.worker.run()으로 위임.
|
|
3
9
|
*
|
|
4
10
|
* @see docs/framework/10-scheduler-queue.md
|
|
5
|
-
* @see
|
|
11
|
+
* @see lib/schedule/WorkerPool.js
|
|
6
12
|
*/
|
|
7
13
|
export default class Scheduler {
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('../core/Application.js').default} app
|
|
16
|
+
*/
|
|
8
17
|
constructor(app) {
|
|
9
18
|
this.app = app;
|
|
19
|
+
/** @type {Array<{JobClass: typeof import('./Job.js').default}>} */
|
|
10
20
|
this._jobs = [];
|
|
21
|
+
/** @type {Array<NodeJS.Timeout>} */
|
|
11
22
|
this._timers = [];
|
|
12
|
-
this._running = false;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
* Job 등록
|
|
26
|
+
* Job 클래스 등록 (AutoLoader에서 호출)
|
|
17
27
|
* @param {typeof import('./Job.js').default} JobClass
|
|
18
28
|
*/
|
|
19
29
|
register(JobClass) {
|
|
20
|
-
|
|
30
|
+
if (!JobClass.schedule) return;
|
|
31
|
+
if (JobClass.enabled === false) return;
|
|
32
|
+
this._jobs.push({ JobClass });
|
|
21
33
|
}
|
|
22
34
|
|
|
23
35
|
/**
|
|
24
|
-
* 모든
|
|
36
|
+
* 등록된 모든 Job 시작
|
|
25
37
|
*/
|
|
26
38
|
start() {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const interval = this._parseCron(JobClass.schedule);
|
|
35
|
-
if (!interval) continue;
|
|
39
|
+
for (const { JobClass } of this._jobs) {
|
|
40
|
+
const interval = this._parseSchedule(JobClass.schedule);
|
|
41
|
+
if (!interval) {
|
|
42
|
+
this.app?.logger?.warn?.(`[Scheduler] Invalid schedule: ${JobClass.name} → '${JobClass.schedule}'`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
36
45
|
|
|
37
46
|
const timeout = JobClass.timeout || 30000;
|
|
38
47
|
|
|
@@ -46,7 +55,11 @@ export default class Scheduler {
|
|
|
46
55
|
),
|
|
47
56
|
]);
|
|
48
57
|
} catch (err) {
|
|
49
|
-
|
|
58
|
+
if (typeof job.onError === 'function') {
|
|
59
|
+
try { await job.onError(err); } catch {}
|
|
60
|
+
} else {
|
|
61
|
+
this.app?.logger?.error?.(`[Scheduler] ${JobClass.name} failed: ${err.message}`);
|
|
62
|
+
}
|
|
50
63
|
}
|
|
51
64
|
};
|
|
52
65
|
|
|
@@ -62,12 +75,18 @@ export default class Scheduler {
|
|
|
62
75
|
} else {
|
|
63
76
|
const timer = setInterval(runJob, interval);
|
|
64
77
|
this._timers.push(timer);
|
|
78
|
+
// 즉시 첫 실행
|
|
79
|
+
runJob();
|
|
65
80
|
}
|
|
81
|
+
|
|
82
|
+
this.app?.logger?.info?.(
|
|
83
|
+
`[Scheduler] ${JobClass.name} registered (${JobClass.schedule}, interval=${interval}ms)`
|
|
84
|
+
);
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
/**
|
|
70
|
-
* 모든
|
|
89
|
+
* 모든 타이머 정지
|
|
71
90
|
*/
|
|
72
91
|
stop() {
|
|
73
92
|
for (const timer of this._timers) {
|
|
@@ -75,87 +94,77 @@ export default class Scheduler {
|
|
|
75
94
|
clearTimeout(timer);
|
|
76
95
|
}
|
|
77
96
|
this._timers = [];
|
|
78
|
-
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Schedule 파싱 ──
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 간편 스케줄 → ms 변환
|
|
103
|
+
*
|
|
104
|
+
* 지원 포맷:
|
|
105
|
+
* 'every:5s' → 5000ms
|
|
106
|
+
* 'every:5m' → 300000ms
|
|
107
|
+
* 'every:1h' → 3600000ms
|
|
108
|
+
* 'daily:HH:MM' → 24h (첫 실행은 _calcInitialDelay)
|
|
109
|
+
* 'weekly:DAY:HH:MM' → 7d (첫 실행은 _calcInitialDelay)
|
|
110
|
+
*/
|
|
111
|
+
_parseSchedule(schedule) {
|
|
112
|
+
if (!schedule) return null;
|
|
113
|
+
|
|
114
|
+
// every:Ns/Nm/Nh
|
|
115
|
+
const everyMatch = schedule.match(/^every:(\d+)(s|m|h)$/i);
|
|
116
|
+
if (everyMatch) {
|
|
117
|
+
const val = parseInt(everyMatch[1], 10);
|
|
118
|
+
const unit = everyMatch[2].toLowerCase();
|
|
119
|
+
if (unit === 's') return val * 1000;
|
|
120
|
+
if (unit === 'm') return val * 60000;
|
|
121
|
+
if (unit === 'h') return val * 3600000;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// daily:HH:MM → 24시간 간격
|
|
125
|
+
if (/^daily:\d{2}:\d{2}$/.test(schedule)) {
|
|
126
|
+
return 24 * 60 * 60 * 1000;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// weekly:DAY:HH:MM → 7일 간격
|
|
130
|
+
if (/^weekly:\w+:\d{2}:\d{2}$/.test(schedule)) {
|
|
131
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
79
135
|
}
|
|
80
136
|
|
|
81
137
|
/**
|
|
82
|
-
* daily/weekly 스케줄의 첫
|
|
83
|
-
* @param {string} schedule
|
|
84
|
-
* @returns {number} 0이면 즉시 setInterval, >0이면 setTimeout 필요
|
|
85
|
-
* @private
|
|
138
|
+
* daily/weekly 스케줄의 첫 실행 지연시간 계산
|
|
86
139
|
*/
|
|
87
140
|
_calcInitialDelay(schedule) {
|
|
88
141
|
const now = new Date();
|
|
89
142
|
|
|
90
|
-
//
|
|
143
|
+
// daily:HH:MM
|
|
91
144
|
const dailyMatch = schedule.match(/^daily:(\d{2}):(\d{2})$/);
|
|
92
145
|
if (dailyMatch) {
|
|
93
|
-
const [, h, m] = dailyMatch;
|
|
94
146
|
const target = new Date(now);
|
|
95
|
-
target.setHours(
|
|
147
|
+
target.setHours(parseInt(dailyMatch[1], 10), parseInt(dailyMatch[2], 10), 0, 0);
|
|
96
148
|
if (target <= now) target.setDate(target.getDate() + 1);
|
|
97
149
|
return target - now;
|
|
98
150
|
}
|
|
99
151
|
|
|
100
|
-
//
|
|
152
|
+
// weekly:DAY:HH:MM
|
|
101
153
|
const weeklyMatch = schedule.match(/^weekly:(\w+):(\d{2}):(\d{2})$/);
|
|
102
154
|
if (weeklyMatch) {
|
|
103
155
|
const days = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
104
|
-
const
|
|
105
|
-
|
|
156
|
+
const targetDay = days[weeklyMatch[1].toLowerCase()];
|
|
157
|
+
if (targetDay === undefined) return 0;
|
|
158
|
+
|
|
106
159
|
const target = new Date(now);
|
|
107
|
-
target.setHours(
|
|
160
|
+
target.setHours(parseInt(weeklyMatch[2], 10), parseInt(weeklyMatch[3], 10), 0, 0);
|
|
161
|
+
|
|
108
162
|
let diff = targetDay - now.getDay();
|
|
109
163
|
if (diff < 0 || (diff === 0 && target <= now)) diff += 7;
|
|
110
164
|
target.setDate(target.getDate() + diff);
|
|
111
165
|
return target - now;
|
|
112
166
|
}
|
|
113
167
|
|
|
114
|
-
return 0; // every
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* 간단 cron 파서 — 간격(ms) 또는 다음 실행까지 지연(ms) 반환
|
|
119
|
-
* 지원:
|
|
120
|
-
* 'every:30s' / 'every:5m' / 'every:1h'
|
|
121
|
-
* 'daily:02:00' — 매일 HH:MM
|
|
122
|
-
* 'weekly:mon:09:00' — 매주 요일 HH:MM
|
|
123
|
-
* '* * * * *' — 매분 (60초)
|
|
124
|
-
* 5-field cron — 분 단위 간격 근사
|
|
125
|
-
* @private
|
|
126
|
-
*/
|
|
127
|
-
_parseCron(schedule) {
|
|
128
|
-
if (!schedule) return null;
|
|
129
|
-
|
|
130
|
-
// 'every:30s' → 30000ms
|
|
131
|
-
const everyMatch = schedule.match(/^every:(\d+)([smh])$/);
|
|
132
|
-
if (everyMatch) {
|
|
133
|
-
const [, num, unit] = everyMatch;
|
|
134
|
-
const multiplier = { s: 1000, m: 60000, h: 3600000 };
|
|
135
|
-
return Number(num) * multiplier[unit];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// 'daily:HH:MM' → 24h interval
|
|
139
|
-
const dailyMatch = schedule.match(/^daily:(\d{2}):(\d{2})$/);
|
|
140
|
-
if (dailyMatch) {
|
|
141
|
-
return 86400000;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// 'weekly:day:HH:MM' → 7일 간격
|
|
145
|
-
const weeklyMatch = schedule.match(/^weekly:\w+:\d{2}:\d{2}$/);
|
|
146
|
-
if (weeklyMatch) {
|
|
147
|
-
return 604800000;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// 5-field cron: '*/5 * * * *' → 분 단위 추출
|
|
151
|
-
const cronMatch = schedule.match(/^\*\/(\d+)\s/);
|
|
152
|
-
if (cronMatch) {
|
|
153
|
-
return Number(cronMatch[1]) * 60000;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// '* * * * *' → 60000ms (매분)
|
|
157
|
-
if (schedule.match(/^[\d*\/,-]+(\s[\d*\/,-]+){4}$/)) return 60000;
|
|
158
|
-
|
|
159
|
-
return null;
|
|
168
|
+
return 0; // every:* → 즉시 시작
|
|
160
169
|
}
|
|
161
170
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ArrUtil — 배열 순수 유틸리티
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 배열을 n개씩 분할
|
|
7
|
+
* @param {Array} arr
|
|
8
|
+
* @param {number} size
|
|
9
|
+
* @returns {Array<Array>}
|
|
10
|
+
*
|
|
11
|
+
* @example chunk([1,2,3,4,5], 2) → [[1,2], [3,4], [5]]
|
|
12
|
+
*/
|
|
13
|
+
export function chunk(arr, size) {
|
|
14
|
+
const result = [];
|
|
15
|
+
for (let i = 0; i < arr.length; i += size) {
|
|
16
|
+
result.push(arr.slice(i, i + size));
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 중복 제거
|
|
23
|
+
* @param {Array} arr
|
|
24
|
+
* @param {Function} [keyFn] - 비교 키 추출 함수
|
|
25
|
+
* @returns {Array}
|
|
26
|
+
*/
|
|
27
|
+
export function unique(arr, keyFn) {
|
|
28
|
+
if (!keyFn) return [...new Set(arr)];
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
return arr.filter(item => {
|
|
31
|
+
const key = keyFn(item);
|
|
32
|
+
if (seen.has(key)) return false;
|
|
33
|
+
seen.add(key);
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 키 기준 그룹핑
|
|
40
|
+
* @param {Array<object>} arr
|
|
41
|
+
* @param {string|Function} key
|
|
42
|
+
* @returns {object}
|
|
43
|
+
*
|
|
44
|
+
* @example groupBy([{a:1,b:'x'},{a:2,b:'x'},{a:3,b:'y'}], 'b') → { x: [...], y: [...] }
|
|
45
|
+
*/
|
|
46
|
+
export function groupBy(arr, key) {
|
|
47
|
+
const fn = typeof key === 'function' ? key : (item) => item[key];
|
|
48
|
+
return arr.reduce((acc, item) => {
|
|
49
|
+
const k = fn(item);
|
|
50
|
+
(acc[k] = acc[k] || []).push(item);
|
|
51
|
+
return acc;
|
|
52
|
+
}, {});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 특정 키 값만 추출
|
|
57
|
+
* @param {Array<object>} arr
|
|
58
|
+
* @param {string} key
|
|
59
|
+
* @returns {Array}
|
|
60
|
+
*
|
|
61
|
+
* @example pluck([{id:1,name:'a'},{id:2,name:'b'}], 'name') → ['a', 'b']
|
|
62
|
+
*/
|
|
63
|
+
export function pluck(arr, key) {
|
|
64
|
+
return arr.map(item => item[key]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 키맵 생성 (배열 → 객체)
|
|
69
|
+
* @param {Array<object>} arr
|
|
70
|
+
* @param {string} key
|
|
71
|
+
* @returns {object}
|
|
72
|
+
*
|
|
73
|
+
* @example keyBy([{id:1,name:'a'},{id:2,name:'b'}], 'id') → { 1: {id:1,...}, 2: {id:2,...} }
|
|
74
|
+
*/
|
|
75
|
+
export function keyBy(arr, key) {
|
|
76
|
+
return arr.reduce((acc, item) => {
|
|
77
|
+
acc[item[key]] = item;
|
|
78
|
+
return acc;
|
|
79
|
+
}, {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 배열 셔플 (Fisher-Yates — JS 폴백용, 프로덕션은 WASM FuzionXUtil.shuffle 사용)
|
|
84
|
+
* @param {Array} arr
|
|
85
|
+
* @returns {Array} 새 배열 (원본 미변경)
|
|
86
|
+
*/
|
|
87
|
+
export function shuffle(arr) {
|
|
88
|
+
const result = [...arr];
|
|
89
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
90
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
91
|
+
[result[i], result[j]] = [result[j], result[i]];
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 배열 flatten (1 depth)
|
|
98
|
+
* @param {Array} arr
|
|
99
|
+
* @returns {Array}
|
|
100
|
+
*/
|
|
101
|
+
export function flatten(arr) {
|
|
102
|
+
return arr.flat();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 배열이 비어있는지 확인
|
|
107
|
+
* @param {*} arr
|
|
108
|
+
* @returns {boolean}
|
|
109
|
+
*/
|
|
110
|
+
export function isEmpty(arr) {
|
|
111
|
+
return !Array.isArray(arr) || arr.length === 0;
|
|
112
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DateUtil — 날짜 순수 유틸리티
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ISO → 'YYYY-MM-DD HH:mm:ss' 포맷
|
|
7
|
+
* @param {Date|string|number} date
|
|
8
|
+
* @param {string} [fmt='YYYY-MM-DD HH:mm:ss']
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function format(date, fmt = 'YYYY-MM-DD HH:mm:ss') {
|
|
12
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
13
|
+
if (isNaN(d.getTime())) return '';
|
|
14
|
+
|
|
15
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
16
|
+
const tokens = {
|
|
17
|
+
YYYY: d.getFullYear(),
|
|
18
|
+
MM: pad(d.getMonth() + 1),
|
|
19
|
+
DD: pad(d.getDate()),
|
|
20
|
+
HH: pad(d.getHours()),
|
|
21
|
+
mm: pad(d.getMinutes()),
|
|
22
|
+
ss: pad(d.getSeconds()),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
let result = fmt;
|
|
26
|
+
for (const [token, value] of Object.entries(tokens)) {
|
|
27
|
+
result = result.replace(token, value);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 상대 시간 (n분 전, n시간 전, n일 전)
|
|
34
|
+
* @param {Date|string|number} date
|
|
35
|
+
* @param {string} [locale='ko']
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
export function ago(date, locale = 'ko') {
|
|
39
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const diffMs = now - d.getTime();
|
|
42
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
43
|
+
|
|
44
|
+
const units = locale === 'ko'
|
|
45
|
+
? { just: '방금 전', sec: '초 전', min: '분 전', hour: '시간 전', day: '일 전', month: '개월 전', year: '년 전' }
|
|
46
|
+
: { just: 'just now', sec: 's ago', min: 'm ago', hour: 'h ago', day: 'd ago', month: 'mo ago', year: 'y ago' };
|
|
47
|
+
|
|
48
|
+
if (diffSec < 10) return units.just;
|
|
49
|
+
if (diffSec < 60) return `${diffSec}${units.sec}`;
|
|
50
|
+
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}${units.min}`;
|
|
51
|
+
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}${units.hour}`;
|
|
52
|
+
if (diffSec < 2592000) return `${Math.floor(diffSec / 86400)}${units.day}`;
|
|
53
|
+
if (diffSec < 31536000) return `${Math.floor(diffSec / 2592000)}${units.month}`;
|
|
54
|
+
return `${Math.floor(diffSec / 31536000)}${units.year}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 오늘인지 확인
|
|
59
|
+
* @param {Date|string|number} date
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function isToday(date) {
|
|
63
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
64
|
+
const now = new Date();
|
|
65
|
+
return d.getFullYear() === now.getFullYear()
|
|
66
|
+
&& d.getMonth() === now.getMonth()
|
|
67
|
+
&& d.getDate() === now.getDate();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 두 날짜 간 일수 차이
|
|
72
|
+
* @param {Date|string|number} a
|
|
73
|
+
* @param {Date|string|number} b
|
|
74
|
+
* @returns {number}
|
|
75
|
+
*/
|
|
76
|
+
export function diffDays(a, b) {
|
|
77
|
+
const da = a instanceof Date ? a : new Date(a);
|
|
78
|
+
const db = b instanceof Date ? b : new Date(b);
|
|
79
|
+
return Math.round((da.getTime() - db.getTime()) / 86400000);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 날짜 범위 배열 생성
|
|
84
|
+
* @param {Date|string} start
|
|
85
|
+
* @param {Date|string} end
|
|
86
|
+
* @returns {Date[]}
|
|
87
|
+
*/
|
|
88
|
+
export function range(start, end) {
|
|
89
|
+
const s = new Date(start);
|
|
90
|
+
const e = new Date(end);
|
|
91
|
+
const dates = [];
|
|
92
|
+
const d = new Date(s);
|
|
93
|
+
while (d <= e) {
|
|
94
|
+
dates.push(new Date(d));
|
|
95
|
+
d.setDate(d.getDate() + 1);
|
|
96
|
+
}
|
|
97
|
+
return dates;
|
|
98
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FunctionUtil — 함수 순수 유틸리티
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Debounce — 마지막 호출 후 delay ms 뒤 실행
|
|
7
|
+
* @param {Function} fn
|
|
8
|
+
* @param {number} delay - ms
|
|
9
|
+
* @returns {Function}
|
|
10
|
+
*/
|
|
11
|
+
export function debounce(fn, delay = 300) {
|
|
12
|
+
let timer;
|
|
13
|
+
const debounced = (...args) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
timer = setTimeout(() => fn(...args), delay);
|
|
16
|
+
};
|
|
17
|
+
debounced.cancel = () => clearTimeout(timer);
|
|
18
|
+
return debounced;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Throttle — delay ms 간격으로 최대 1회 실행
|
|
23
|
+
* @param {Function} fn
|
|
24
|
+
* @param {number} delay - ms
|
|
25
|
+
* @returns {Function}
|
|
26
|
+
*/
|
|
27
|
+
export function throttle(fn, delay = 300) {
|
|
28
|
+
let last = 0;
|
|
29
|
+
let timer;
|
|
30
|
+
return (...args) => {
|
|
31
|
+
const now = Date.now();
|
|
32
|
+
const remaining = delay - (now - last);
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
if (remaining <= 0) {
|
|
35
|
+
last = now;
|
|
36
|
+
fn(...args);
|
|
37
|
+
} else {
|
|
38
|
+
timer = setTimeout(() => {
|
|
39
|
+
last = Date.now();
|
|
40
|
+
fn(...args);
|
|
41
|
+
}, remaining);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 한 번만 실행
|
|
48
|
+
* @param {Function} fn
|
|
49
|
+
* @returns {Function}
|
|
50
|
+
*/
|
|
51
|
+
export function once(fn) {
|
|
52
|
+
let called = false;
|
|
53
|
+
let result;
|
|
54
|
+
return (...args) => {
|
|
55
|
+
if (called) return result;
|
|
56
|
+
called = true;
|
|
57
|
+
result = fn(...args);
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Promise 타임아웃 래퍼
|
|
64
|
+
* @param {Promise} promise
|
|
65
|
+
* @param {number} ms
|
|
66
|
+
* @param {string} [message='Timeout']
|
|
67
|
+
* @returns {Promise}
|
|
68
|
+
*/
|
|
69
|
+
export function timeout(promise, ms, message = 'Timeout') {
|
|
70
|
+
return Promise.race([
|
|
71
|
+
promise,
|
|
72
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(message)), ms)),
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 딜레이 (async sleep)
|
|
78
|
+
* @param {number} ms
|
|
79
|
+
* @returns {Promise<void>}
|
|
80
|
+
*/
|
|
81
|
+
export function sleep(ms) {
|
|
82
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 함수 파이프라인 (왼→오) — 순차 실행
|
|
87
|
+
* @param {...Function} fns
|
|
88
|
+
* @returns {Function}
|
|
89
|
+
*
|
|
90
|
+
* @example pipe(addOne, double, square)(2) → square(double(addOne(2)))
|
|
91
|
+
*/
|
|
92
|
+
export function pipe(...fns) {
|
|
93
|
+
return (x) => fns.reduce((v, fn) => fn(v), x);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 메모이제이션 — 결과 캐싱
|
|
98
|
+
* @param {Function} fn
|
|
99
|
+
* @param {Function} [keyFn] - 캐시 키 생성 함수 (기본: 첫 인자)
|
|
100
|
+
* @returns {Function}
|
|
101
|
+
*/
|
|
102
|
+
export function memoize(fn, keyFn) {
|
|
103
|
+
const cache = new Map();
|
|
104
|
+
const memoized = (...args) => {
|
|
105
|
+
const key = keyFn ? keyFn(...args) : args[0];
|
|
106
|
+
if (cache.has(key)) return cache.get(key);
|
|
107
|
+
const result = fn(...args);
|
|
108
|
+
cache.set(key, result);
|
|
109
|
+
return result;
|
|
110
|
+
};
|
|
111
|
+
memoized.cache = cache;
|
|
112
|
+
memoized.clear = () => cache.clear();
|
|
113
|
+
return memoized;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* noop — 아무것도 하지 않는 함수
|
|
118
|
+
*/
|
|
119
|
+
export function noop() {}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NumUtil — 숫자 순수 유틸리티
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 범위 제한 (clamp)
|
|
7
|
+
* @param {number} n
|
|
8
|
+
* @param {number} min
|
|
9
|
+
* @param {number} max
|
|
10
|
+
* @returns {number}
|
|
11
|
+
*/
|
|
12
|
+
export function clamp(n, min, max) {
|
|
13
|
+
return Math.min(Math.max(n, min), max);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 범위 내 랜덤 정수
|
|
18
|
+
* @param {number} min
|
|
19
|
+
* @param {number} max
|
|
20
|
+
* @returns {number}
|
|
21
|
+
*/
|
|
22
|
+
export function random(min = 0, max = 100) {
|
|
23
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 숫자 포맷 (로케일)
|
|
28
|
+
* @param {number} n
|
|
29
|
+
* @param {string} [locale='ko-KR']
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*
|
|
32
|
+
* @example format(1234567) → '1,234,567'
|
|
33
|
+
*/
|
|
34
|
+
export function format(n, locale = 'ko-KR') {
|
|
35
|
+
return new Intl.NumberFormat(locale).format(n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 바이트 → 읽기 쉬운 단위 변환
|
|
40
|
+
* @param {number} bytes
|
|
41
|
+
* @param {number} [decimals=2]
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*
|
|
44
|
+
* @example fileSize(1536) → '1.50 KB'
|
|
45
|
+
*/
|
|
46
|
+
export function fileSize(bytes, decimals = 2) {
|
|
47
|
+
if (bytes === 0) return '0 B';
|
|
48
|
+
const k = 1024;
|
|
49
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
50
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
51
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${units[i]}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 퍼센트 계산
|
|
56
|
+
* @param {number} part
|
|
57
|
+
* @param {number} total
|
|
58
|
+
* @param {number} [decimals=1]
|
|
59
|
+
* @returns {number}
|
|
60
|
+
*/
|
|
61
|
+
export function percent(part, total, decimals = 1) {
|
|
62
|
+
if (total === 0) return 0;
|
|
63
|
+
return parseFloat(((part / total) * 100).toFixed(decimals));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 안전한 정수 파싱
|
|
68
|
+
* @param {*} value
|
|
69
|
+
* @param {number} [fallback=0]
|
|
70
|
+
* @returns {number}
|
|
71
|
+
*/
|
|
72
|
+
export function toInt(value, fallback = 0) {
|
|
73
|
+
const n = parseInt(value, 10);
|
|
74
|
+
return isNaN(n) ? fallback : n;
|
|
75
|
+
}
|