@fazetitans/fscopy 1.1.3 → 1.2.1

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.
@@ -0,0 +1,265 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Stats, LogEntry } from '../types.js';
4
+
5
+ /**
6
+ * Parse a size string like "10MB" or "1GB" into bytes.
7
+ * Supports: B, KB, MB, GB (case insensitive)
8
+ * Returns 0 for invalid or "0" input.
9
+ */
10
+ export function parseSize(sizeStr: string | undefined): number {
11
+ if (!sizeStr || sizeStr === '0') return 0;
12
+
13
+ const sizeRegex = /^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i;
14
+ const match = sizeRegex.exec(sizeStr.trim());
15
+ if (!match) return 0;
16
+
17
+ const value = Number.parseFloat(match[1]);
18
+ const unit = (match[2] || 'B').toUpperCase();
19
+
20
+ const multipliers: Record<string, number> = {
21
+ B: 1,
22
+ KB: 1024,
23
+ MB: 1024 * 1024,
24
+ GB: 1024 * 1024 * 1024,
25
+ };
26
+
27
+ return Math.floor(value * (multipliers[unit] || 1));
28
+ }
29
+
30
+ export interface OutputOptions {
31
+ quiet: boolean;
32
+ json: boolean;
33
+ logFile?: string;
34
+ maxLogSize?: number; // Max log file size in bytes (0 = unlimited)
35
+ maxLogFiles?: number; // Max number of rotated log files to keep
36
+ }
37
+
38
+ /**
39
+ * Unified output manager for console and file logging.
40
+ * Handles quiet mode (--quiet) and JSON mode (--json).
41
+ */
42
+ export class Output {
43
+ private readonly options: OutputOptions;
44
+ private readonly entries: LogEntry[] = [];
45
+ private readonly startTime: Date;
46
+
47
+ constructor(options: Partial<OutputOptions> = {}) {
48
+ this.options = {
49
+ quiet: options.quiet ?? false,
50
+ json: options.json ?? false,
51
+ logFile: options.logFile,
52
+ maxLogSize: options.maxLogSize ?? 0,
53
+ maxLogFiles: options.maxLogFiles ?? 5,
54
+ };
55
+ this.startTime = new Date();
56
+ }
57
+
58
+ // ==========================================================================
59
+ // Initialization
60
+ // ==========================================================================
61
+
62
+ init(): void {
63
+ if (this.options.logFile) {
64
+ this.rotateLogIfNeeded();
65
+ const header = `# fscopy transfer log\n# Started: ${this.startTime.toISOString()}\n\n`;
66
+ fs.writeFileSync(this.options.logFile, header);
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Rotate log file if it exceeds maxLogSize.
72
+ * Creates numbered backups: log.1.ext, log.2.ext, etc.
73
+ */
74
+ private rotateLogIfNeeded(): void {
75
+ const logFile = this.options.logFile;
76
+ const maxSize = this.options.maxLogSize ?? 0;
77
+ const maxFiles = this.options.maxLogFiles ?? 5;
78
+
79
+ if (!logFile || maxSize <= 0) return;
80
+ if (!fs.existsSync(logFile)) return;
81
+
82
+ const stats = fs.statSync(logFile);
83
+ if (stats.size < maxSize) return;
84
+
85
+ const dir = path.dirname(logFile);
86
+ const ext = path.extname(logFile);
87
+ const base = path.basename(logFile, ext);
88
+
89
+ // Delete oldest backup if at max
90
+ const oldestPath = path.join(dir, `${base}.${maxFiles}${ext}`);
91
+ if (fs.existsSync(oldestPath)) {
92
+ fs.unlinkSync(oldestPath);
93
+ }
94
+
95
+ // Shift existing backups: .4 -> .5, .3 -> .4, etc.
96
+ for (let i = maxFiles - 1; i >= 1; i--) {
97
+ const from = path.join(dir, `${base}.${i}${ext}`);
98
+ const to = path.join(dir, `${base}.${i + 1}${ext}`);
99
+ if (fs.existsSync(from)) {
100
+ fs.renameSync(from, to);
101
+ }
102
+ }
103
+
104
+ // Rename current to .1
105
+ const backupPath = path.join(dir, `${base}.1${ext}`);
106
+ fs.renameSync(logFile, backupPath);
107
+ }
108
+
109
+ // ==========================================================================
110
+ // Console Output (respects quiet mode, skipped in JSON mode)
111
+ // ==========================================================================
112
+
113
+ /** Print info message to console */
114
+ print(message: string): void {
115
+ if (!this.options.json) {
116
+ console.log(message);
117
+ }
118
+ }
119
+
120
+ /** Print message (skipped in quiet mode) */
121
+ private printIfNotQuiet(message: string): void {
122
+ if (!this.options.quiet && !this.options.json) {
123
+ console.log(message);
124
+ }
125
+ }
126
+
127
+ /** Print info message (skipped in quiet mode) */
128
+ info(message: string): void {
129
+ this.printIfNotQuiet(message);
130
+ }
131
+
132
+ /** Print success message (skipped in quiet mode) */
133
+ success(message: string): void {
134
+ this.printIfNotQuiet(message);
135
+ }
136
+
137
+ /** Print warning message (always shown, except in JSON mode) */
138
+ warn(message: string): void {
139
+ if (!this.options.json) {
140
+ console.warn(message);
141
+ }
142
+ }
143
+
144
+ /** Print error message (always shown, except in JSON mode) */
145
+ error(message: string): void {
146
+ if (!this.options.json) {
147
+ console.error(message);
148
+ }
149
+ }
150
+
151
+ /** Print a blank line */
152
+ blank(): void {
153
+ if (!this.options.quiet && !this.options.json) {
154
+ console.log('');
155
+ }
156
+ }
157
+
158
+ /** Print a separator line */
159
+ separator(char: string = '=', length: number = 60): void {
160
+ if (!this.options.quiet && !this.options.json) {
161
+ console.log(char.repeat(length));
162
+ }
163
+ }
164
+
165
+ /** Print a header with separators */
166
+ header(title: string): void {
167
+ if (!this.options.quiet && !this.options.json) {
168
+ console.log('='.repeat(60));
169
+ console.log(title);
170
+ console.log('='.repeat(60));
171
+ }
172
+ }
173
+
174
+ // ==========================================================================
175
+ // File Logging (always writes if logFile is set)
176
+ // ==========================================================================
177
+
178
+ /** Log entry to file only */
179
+ log(level: string, message: string, data: Record<string, unknown> = {}): void {
180
+ const entry: LogEntry = {
181
+ timestamp: new Date().toISOString(),
182
+ level,
183
+ message,
184
+ ...data,
185
+ };
186
+ this.entries.push(entry);
187
+
188
+ if (this.options.logFile) {
189
+ const line =
190
+ `[${entry.timestamp}] [${level}] ${message}` +
191
+ (Object.keys(data).length > 0 ? ` ${JSON.stringify(data)}` : '') +
192
+ '\n';
193
+ fs.appendFileSync(this.options.logFile, line);
194
+ }
195
+ }
196
+
197
+ /** Log info to file */
198
+ logInfo(message: string, data?: Record<string, unknown>): void {
199
+ this.log('INFO', message, data);
200
+ }
201
+
202
+ /** Log error to file */
203
+ logError(message: string, data?: Record<string, unknown>): void {
204
+ this.log('ERROR', message, data);
205
+ }
206
+
207
+ /** Log success to file */
208
+ logSuccess(message: string, data?: Record<string, unknown>): void {
209
+ this.log('SUCCESS', message, data);
210
+ }
211
+
212
+ /** Write summary to log file */
213
+ logSummary(stats: Stats, duration: string): void {
214
+ if (this.options.logFile) {
215
+ let summary = `\n# Summary\n# Collections: ${stats.collectionsProcessed}\n`;
216
+ if (stats.documentsDeleted > 0) {
217
+ summary += `# Deleted: ${stats.documentsDeleted}\n`;
218
+ }
219
+ summary += `# Transferred: ${stats.documentsTransferred}\n`;
220
+ if (stats.conflicts > 0) {
221
+ summary += `# Conflicts: ${stats.conflicts}\n`;
222
+ }
223
+ summary += `# Errors: ${stats.errors}\n# Duration: ${duration}s\n`;
224
+ fs.appendFileSync(this.options.logFile, summary);
225
+ }
226
+ }
227
+
228
+ // ==========================================================================
229
+ // JSON Output
230
+ // ==========================================================================
231
+
232
+ /** Print JSON output (only in JSON mode) */
233
+ json(data: unknown): void {
234
+ if (this.options.json) {
235
+ console.log(JSON.stringify(data, null, 2));
236
+ }
237
+ }
238
+
239
+ // ==========================================================================
240
+ // Helpers
241
+ // ==========================================================================
242
+
243
+ get isQuiet(): boolean {
244
+ return this.options.quiet;
245
+ }
246
+
247
+ get isJson(): boolean {
248
+ return this.options.json;
249
+ }
250
+
251
+ get logFile(): string | undefined {
252
+ return this.options.logFile;
253
+ }
254
+ }
255
+
256
+ // Default singleton instance (can be replaced)
257
+ let defaultOutput: Output = new Output();
258
+
259
+ export function setDefaultOutput(output: Output): void {
260
+ defaultOutput = output;
261
+ }
262
+
263
+ export function getDefaultOutput(): Output {
264
+ return defaultOutput;
265
+ }
@@ -0,0 +1,102 @@
1
+ import cliProgress from 'cli-progress';
2
+ import type { Stats } from '../types.js';
3
+
4
+ export interface ProgressBarOptions {
5
+ format?: string;
6
+ barCompleteChar?: string;
7
+ barIncompleteChar?: string;
8
+ hideCursor?: boolean;
9
+ }
10
+
11
+ const DEFAULT_OPTIONS: ProgressBarOptions = {
12
+ format: '📦 Progress |{bar}| {percentage}% | {value}/{total} docs | {speed} docs/s | ETA: {eta}s',
13
+ barCompleteChar: '█',
14
+ barIncompleteChar: '░',
15
+ hideCursor: true,
16
+ };
17
+
18
+ /**
19
+ * Wrapper around cli-progress that handles speed calculation and cleanup.
20
+ * Eliminates the need for type hacks to store the speed interval.
21
+ */
22
+ export class ProgressBarWrapper {
23
+ private bar: cliProgress.SingleBar | null = null;
24
+ private speedInterval: NodeJS.Timeout | null = null;
25
+ private lastDocsTransferred = 0;
26
+ private lastTime = Date.now();
27
+
28
+ constructor(private readonly options: ProgressBarOptions = {}) {}
29
+
30
+ /**
31
+ * Start the progress bar with the given total and stats reference.
32
+ * The stats object is used to read documentsTransferred for speed calculation.
33
+ */
34
+ start(total: number, stats: Stats): void {
35
+ if (total <= 0) return;
36
+
37
+ const mergedOptions = { ...DEFAULT_OPTIONS, ...this.options };
38
+ this.bar = new cliProgress.SingleBar({
39
+ format: mergedOptions.format,
40
+ barCompleteChar: mergedOptions.barCompleteChar,
41
+ barIncompleteChar: mergedOptions.barIncompleteChar,
42
+ hideCursor: mergedOptions.hideCursor,
43
+ });
44
+
45
+ this.bar.start(total, 0, { speed: '0' });
46
+ this.lastDocsTransferred = 0;
47
+ this.lastTime = Date.now();
48
+
49
+ this.speedInterval = setInterval(() => {
50
+ this.updateSpeed(stats);
51
+ }, 500);
52
+ }
53
+
54
+ /**
55
+ * Increment the progress bar by 1.
56
+ */
57
+ increment(): void {
58
+ if (this.bar) {
59
+ this.bar.increment();
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Update the speed display based on current stats.
65
+ */
66
+ private updateSpeed(stats: Stats): void {
67
+ if (!this.bar) return;
68
+
69
+ const now = Date.now();
70
+ const timeDiff = (now - this.lastTime) / 1000;
71
+ const currentDocs = stats.documentsTransferred;
72
+
73
+ if (timeDiff > 0) {
74
+ const docsDiff = currentDocs - this.lastDocsTransferred;
75
+ const speed = Math.round(docsDiff / timeDiff);
76
+ this.lastDocsTransferred = currentDocs;
77
+ this.lastTime = now;
78
+ this.bar.update({ speed: String(speed) });
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Stop the progress bar and clean up the speed interval.
84
+ */
85
+ stop(): void {
86
+ if (this.speedInterval) {
87
+ clearInterval(this.speedInterval);
88
+ this.speedInterval = null;
89
+ }
90
+ if (this.bar) {
91
+ this.bar.stop();
92
+ this.bar = null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Check if the progress bar is active.
98
+ */
99
+ get isActive(): boolean {
100
+ return this.bar !== null;
101
+ }
102
+ }
@@ -51,8 +51,10 @@ export class RateLimiter {
51
51
 
52
52
  await this.sleep(waitTime);
53
53
 
54
- // After waiting, we should have enough tokens
55
- this.tokens = 0; // We consumed them all
54
+ // After waiting, calculate remaining tokens:
55
+ // tokens accumulated = waitTime * refillRate (may exceed tokensNeeded due to ceiling)
56
+ const tokensAccumulated = waitTime * this.refillRate;
57
+ this.tokens = Math.min(this.maxTokens, this.tokens + tokensAccumulated - count);
56
58
  this.lastRefill = Date.now();
57
59
  }
58
60
 
@@ -1,5 +1,5 @@
1
1
  import type { Stats } from '../types.js';
2
- import type { Logger } from '../utils/logger.js';
2
+ import type { Output } from '../utils/output.js';
3
3
 
4
4
  export interface WebhookPayload {
5
5
  source: string;
@@ -108,7 +108,7 @@ export function formatDiscordPayload(payload: WebhookPayload): Record<string, un
108
108
  export async function sendWebhook(
109
109
  webhookUrl: string,
110
110
  payload: WebhookPayload,
111
- logger: Logger
111
+ output: Output
112
112
  ): Promise<void> {
113
113
  const webhookType = detectWebhookType(webhookUrl);
114
114
 
@@ -136,11 +136,11 @@ export async function sendWebhook(
136
136
  throw new Error(`HTTP ${response.status}: ${errorText}`);
137
137
  }
138
138
 
139
- logger.info(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
140
- console.log(`📤 Webhook notification sent (${webhookType})`);
139
+ output.logInfo(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
140
+ output.info(`📤 Webhook notification sent (${webhookType})`);
141
141
  } catch (error) {
142
142
  const message = error instanceof Error ? error.message : String(error);
143
- logger.error(`Failed to send webhook: ${message}`, { url: webhookUrl });
144
- console.error(`⚠️ Failed to send webhook: ${message}`);
143
+ output.logError(`Failed to send webhook: ${message}`, { url: webhookUrl });
144
+ output.warn(`⚠️ Failed to send webhook: ${message}`);
145
145
  }
146
146
  }