hsync-cli 1.0.0
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/dist/bin.js +51850 -0
- package/dist/hsync +0 -0
- package/package.json +30 -0
- package/scripts/build-js.ts +32 -0
- package/src/commands/auth.ts +139 -0
- package/src/commands/bucket.ts +110 -0
- package/src/commands/sync.ts +213 -0
- package/src/commands/transfer.ts +220 -0
- package/src/index.ts +126 -0
- package/src/types.ts +7 -0
- package/src/utils/client.ts +24 -0
- package/src/utils/config.ts +25 -0
- package/src/utils/prompts.ts +51 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import crypto from 'crypto';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { loadConfig } from '../utils/config';
|
|
7
|
+
import { OverwritePrompter } from '../utils/prompts';
|
|
8
|
+
import { RemoteClient } from '../../../sync-engine/src/RemoteClient';
|
|
9
|
+
|
|
10
|
+
interface LocalFile {
|
|
11
|
+
relativePath: string;
|
|
12
|
+
absolutePath: string;
|
|
13
|
+
hash: string;
|
|
14
|
+
size: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function hashFile(absolutePath: string): Promise<string> {
|
|
18
|
+
const content = await fs.readFile(absolutePath);
|
|
19
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function scanLocalPath(targetPath: string, rootDir: string): Promise<LocalFile[]> {
|
|
23
|
+
const stat = await fs.stat(targetPath);
|
|
24
|
+
if (stat.isFile()) {
|
|
25
|
+
const hash = await hashFile(targetPath);
|
|
26
|
+
return [{
|
|
27
|
+
relativePath: path.basename(targetPath),
|
|
28
|
+
absolutePath: targetPath,
|
|
29
|
+
hash: hash,
|
|
30
|
+
size: stat.size,
|
|
31
|
+
}];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const results: LocalFile[] = [];
|
|
35
|
+
async function recurse(currentPath: string) {
|
|
36
|
+
const entries = await fs.readdir(currentPath, { withFileTypes: true });
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
39
|
+
if (entry.isDirectory()) {
|
|
40
|
+
await recurse(fullPath);
|
|
41
|
+
} else if (entry.isFile()) {
|
|
42
|
+
const fileStat = await fs.stat(fullPath);
|
|
43
|
+
const hash = await hashFile(fullPath);
|
|
44
|
+
results.push({
|
|
45
|
+
relativePath: path.relative(rootDir, fullPath),
|
|
46
|
+
absolutePath: fullPath,
|
|
47
|
+
hash: hash,
|
|
48
|
+
size: fileStat.size,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await recurse(targetPath);
|
|
55
|
+
return results;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseRemoteTarget(target: string): { slug: string; remotePath: string } {
|
|
59
|
+
const parts = target.split(':');
|
|
60
|
+
const slug = parts[0];
|
|
61
|
+
const remotePath = (parts.slice(1).join(':') || '').replace(/^\/+|\/+$/g, '');
|
|
62
|
+
return { slug: slug, remotePath: remotePath };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function push(localPath: string, remoteTarget: string, options: { yes?: boolean }): Promise<void> {
|
|
66
|
+
const forceYes = options.yes || false;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const config = await loadConfig();
|
|
70
|
+
const serverUrl = config.serverUrl;
|
|
71
|
+
const apiKey = config.apiKey;
|
|
72
|
+
|
|
73
|
+
if (!serverUrl || !apiKey) {
|
|
74
|
+
console.error(chalk.red('Error: CLI not authenticated. Please run: hsync config set-server <url> && hsync login'));
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const remote = parseRemoteTarget(remoteTarget);
|
|
79
|
+
const resolvedLocalPath = path.resolve(localPath);
|
|
80
|
+
|
|
81
|
+
// Verify local path exists
|
|
82
|
+
try {
|
|
83
|
+
await fs.access(resolvedLocalPath);
|
|
84
|
+
} catch {
|
|
85
|
+
console.error(chalk.red(`Error: Local path "${localPath}" does not exist.`));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const spinner = ora('Scanning local files...').start();
|
|
90
|
+
const localFiles = await scanLocalPath(resolvedLocalPath, resolvedLocalPath);
|
|
91
|
+
spinner.text = 'Fetching remote file tree...';
|
|
92
|
+
|
|
93
|
+
const remoteClient = new RemoteClient(serverUrl, apiKey);
|
|
94
|
+
const remoteFiles = await remoteClient.getTree(remote.slug, remote.remotePath);
|
|
95
|
+
spinner.stop();
|
|
96
|
+
|
|
97
|
+
console.log(chalk.cyan(`Found ${localFiles.length} local files, ${remoteFiles.length} remote files.`));
|
|
98
|
+
|
|
99
|
+
const prompter = new OverwritePrompter(forceYes);
|
|
100
|
+
let pushCount = 0;
|
|
101
|
+
let skipCount = 0;
|
|
102
|
+
|
|
103
|
+
for (const localFile of localFiles) {
|
|
104
|
+
const remoteFile = remoteFiles.find((f) => f.path === localFile.relativePath);
|
|
105
|
+
const destinationPath = remote.remotePath
|
|
106
|
+
? `${remote.remotePath}/${localFile.relativePath}`
|
|
107
|
+
: localFile.relativePath;
|
|
108
|
+
|
|
109
|
+
let shouldUpload = false;
|
|
110
|
+
|
|
111
|
+
if (!remoteFile) {
|
|
112
|
+
// New file
|
|
113
|
+
shouldUpload = true;
|
|
114
|
+
} else if (remoteFile.hash !== localFile.hash) {
|
|
115
|
+
// Conflict - content differs
|
|
116
|
+
const confirm = await prompter.shouldOverwrite(localFile.relativePath);
|
|
117
|
+
if (confirm) {
|
|
118
|
+
shouldUpload = true;
|
|
119
|
+
} else {
|
|
120
|
+
skipCount++;
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
// Identical hashes
|
|
124
|
+
skipCount++;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (shouldUpload) {
|
|
128
|
+
const fileSpinner = ora(`Pushing ${localFile.relativePath}...`).start();
|
|
129
|
+
try {
|
|
130
|
+
await remoteClient.uploadFile(remote.slug, destinationPath, localFile.absolutePath);
|
|
131
|
+
fileSpinner.succeed(chalk.green(`Pushed ${localFile.relativePath}`));
|
|
132
|
+
pushCount++;
|
|
133
|
+
} catch (uploadErr: any) {
|
|
134
|
+
fileSpinner.fail(chalk.red(`Failed to push ${localFile.relativePath}: ${uploadErr.message}`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.log(chalk.green(`\n✔ Push complete. Pushed: ${pushCount}, Skipped: ${skipCount}`));
|
|
140
|
+
} catch (err: any) {
|
|
141
|
+
console.error(chalk.red(`Error: Push failed: ${err.message}`));
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function pull(remoteTarget: string, localPath: string, options: { yes?: boolean }): Promise<void> {
|
|
147
|
+
const forceYes = options.yes || false;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const config = await loadConfig();
|
|
151
|
+
const serverUrl = config.serverUrl;
|
|
152
|
+
const apiKey = config.apiKey;
|
|
153
|
+
|
|
154
|
+
if (!serverUrl || !apiKey) {
|
|
155
|
+
console.error(chalk.red('Error: CLI not authenticated. Please run: hsync config set-server <url> && hsync login'));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const remote = parseRemoteTarget(remoteTarget);
|
|
160
|
+
const resolvedLocalPath = path.resolve(localPath);
|
|
161
|
+
|
|
162
|
+
const spinner = ora('Fetching remote file tree...').start();
|
|
163
|
+
const remoteClient = new RemoteClient(serverUrl, apiKey);
|
|
164
|
+
const remoteFiles = await remoteClient.getTree(remote.slug, remote.remotePath);
|
|
165
|
+
spinner.text = 'Scanning local files...';
|
|
166
|
+
|
|
167
|
+
// Verify local path exists or create it
|
|
168
|
+
await fs.mkdir(resolvedLocalPath, { recursive: true });
|
|
169
|
+
const localFiles = await scanLocalPath(resolvedLocalPath, resolvedLocalPath);
|
|
170
|
+
spinner.stop();
|
|
171
|
+
|
|
172
|
+
console.log(chalk.cyan(`Found ${remoteFiles.length} remote files, ${localFiles.length} local files.`));
|
|
173
|
+
|
|
174
|
+
const prompter = new OverwritePrompter(forceYes);
|
|
175
|
+
let pullCount = 0;
|
|
176
|
+
let skipCount = 0;
|
|
177
|
+
|
|
178
|
+
for (const remoteFile of remoteFiles) {
|
|
179
|
+
const localFile = localFiles.find((f) => f.relativePath === remoteFile.path);
|
|
180
|
+
const localDestPath = path.join(resolvedLocalPath, remoteFile.path);
|
|
181
|
+
const sourcePath = remote.remotePath
|
|
182
|
+
? `${remote.remotePath}/${remoteFile.path}`
|
|
183
|
+
: remoteFile.path;
|
|
184
|
+
|
|
185
|
+
let shouldDownload = false;
|
|
186
|
+
|
|
187
|
+
if (!localFile) {
|
|
188
|
+
// New remote file
|
|
189
|
+
shouldDownload = true;
|
|
190
|
+
} else if (localFile.hash !== remoteFile.hash) {
|
|
191
|
+
// Conflict - content differs
|
|
192
|
+
const confirm = await prompter.shouldOverwrite(remoteFile.path);
|
|
193
|
+
if (confirm) {
|
|
194
|
+
shouldDownload = true;
|
|
195
|
+
} else {
|
|
196
|
+
skipCount++;
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
// Identical hashes
|
|
200
|
+
skipCount++;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (shouldDownload) {
|
|
204
|
+
const fileSpinner = ora(`Pulling ${remoteFile.path}...`).start();
|
|
205
|
+
try {
|
|
206
|
+
await remoteClient.downloadFile(remote.slug, sourcePath, localDestPath);
|
|
207
|
+
fileSpinner.succeed(chalk.green(`Pulled ${remoteFile.path}`));
|
|
208
|
+
pullCount++;
|
|
209
|
+
} catch (downloadErr: any) {
|
|
210
|
+
fileSpinner.fail(chalk.red(`Failed to pull ${remoteFile.path}: ${downloadErr.message}`));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log(chalk.green(`\n✔ Pull complete. Pulled: ${pullCount}, Skipped: ${skipCount}`));
|
|
216
|
+
} catch (err: any) {
|
|
217
|
+
console.error(chalk.red(`Error: Pull failed: ${err.message}`));
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { setServer, login, showMe, logout } from './commands/auth';
|
|
4
|
+
import { listBuckets, createBucket, deleteBucket } from './commands/bucket';
|
|
5
|
+
import { push, pull } from './commands/transfer';
|
|
6
|
+
import { addSyncJob, listSyncJobs, removeSyncJob, startSyncScheduler } from './commands/sync';
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('hsync')
|
|
12
|
+
.description('hsync CLI - Premium cross-device file synchronization client')
|
|
13
|
+
.version('1.0.0');
|
|
14
|
+
|
|
15
|
+
// === Auth Commands ===
|
|
16
|
+
const configCmd = program.command('config').description('Configure CLI settings');
|
|
17
|
+
configCmd
|
|
18
|
+
.command('set-server <url>')
|
|
19
|
+
.description('Set target API server URL')
|
|
20
|
+
.action(async (url) => {
|
|
21
|
+
await setServer(url);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.command('login')
|
|
26
|
+
.description('Authenticate and register local API Key')
|
|
27
|
+
.option('-k, --api-key <key>', 'Skip interactive prompts and login directly using a pre-generated API Key')
|
|
28
|
+
.action(async (options) => {
|
|
29
|
+
await login(options);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command('me')
|
|
34
|
+
.description('Show profile details of currently authenticated account')
|
|
35
|
+
.action(async () => {
|
|
36
|
+
await showMe();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
program
|
|
40
|
+
.command('logout')
|
|
41
|
+
.description('De-authenticate and remove local API Key')
|
|
42
|
+
.action(async () => {
|
|
43
|
+
await logout();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// === Bucket Commands ===
|
|
47
|
+
const bucketCmd = program.command('bucket').description('Manage remote storage buckets');
|
|
48
|
+
|
|
49
|
+
bucketCmd
|
|
50
|
+
.command('list')
|
|
51
|
+
.description('List all storage buckets registered on your account')
|
|
52
|
+
.action(async () => {
|
|
53
|
+
await listBuckets();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
bucketCmd
|
|
57
|
+
.command('create <name> <slug>')
|
|
58
|
+
.description('Create a new storage bucket')
|
|
59
|
+
.requiredOption('-p, --provider <local|s3>', 'Storage provider type ("local" or "s3")')
|
|
60
|
+
.option('-r, --rootPath <path>', 'Physical folder path on host (required for local provider)')
|
|
61
|
+
.option('-b, --bucket <name>', 'S3 bucket name (required for s3 provider)')
|
|
62
|
+
.action(async (name, slug, options) => {
|
|
63
|
+
await createBucket(name, slug, options);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
bucketCmd
|
|
67
|
+
.command('delete <slug>')
|
|
68
|
+
.description('Permanently delete a storage bucket')
|
|
69
|
+
.action(async (slug) => {
|
|
70
|
+
await deleteBucket(slug);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// === Transfer Commands ===
|
|
74
|
+
program
|
|
75
|
+
.command('push <localPath> <remoteTarget>')
|
|
76
|
+
.description('Upload folder or file to a remote bucket')
|
|
77
|
+
.option('-y, --yes', 'Automatically overwrite conflicts without interactive prompts')
|
|
78
|
+
.action(async (localPath, remoteTarget, options) => {
|
|
79
|
+
await push(localPath, remoteTarget, options);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
program
|
|
83
|
+
.command('pull <remoteTarget> <localPath>')
|
|
84
|
+
.description('Download folder or file from a remote bucket')
|
|
85
|
+
.option('-y, --yes', 'Automatically overwrite conflicts without interactive prompts')
|
|
86
|
+
.action(async (remoteTarget, localPath, options) => {
|
|
87
|
+
await pull(remoteTarget, localPath, options);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// === Sync Scheduler Commands ===
|
|
91
|
+
const syncCmd = program.command('sync').description('Manage background synchronization schedules');
|
|
92
|
+
|
|
93
|
+
syncCmd
|
|
94
|
+
.command('add <localPath> <remoteTarget>')
|
|
95
|
+
.description('Register a new persistent sync job profile')
|
|
96
|
+
.requiredOption('-n, --name <jobName>', 'Unique identifier label for the sync job')
|
|
97
|
+
.requiredOption('-d, --direction <push|pull|bidirectional>', 'Sync transfer direction mapping')
|
|
98
|
+
.requiredOption('-s, --strategy <local-wins|server-wins|newer-wins>', 'Conflict resolution strategy rules')
|
|
99
|
+
.option('-i, --interval <seconds>', 'Synchronization scheduling delay frequency in seconds', '10')
|
|
100
|
+
.option('--no-conflict-copy', 'Disable keeping backup copies (.conflict-YYYY-MM-DD) on conflict resolutions')
|
|
101
|
+
.action(async (localPath, remoteTarget, options) => {
|
|
102
|
+
await addSyncJob(localPath, remoteTarget, options);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
syncCmd
|
|
106
|
+
.command('list')
|
|
107
|
+
.description('List all registered local sync profiles')
|
|
108
|
+
.action(async () => {
|
|
109
|
+
await listSyncJobs();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
syncCmd
|
|
113
|
+
.command('remove <name>')
|
|
114
|
+
.description('Remove a registered local sync job')
|
|
115
|
+
.action(async (name) => {
|
|
116
|
+
await removeSyncJob(name);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
syncCmd
|
|
120
|
+
.command('start [name]')
|
|
121
|
+
.description('Start interval synchronization scheduler service')
|
|
122
|
+
.action(async (name) => {
|
|
123
|
+
await startSyncScheduler(name);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
program.parse(process.argv);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import type { AxiosInstance } from 'axios';
|
|
3
|
+
import { loadConfig } from './config';
|
|
4
|
+
|
|
5
|
+
export async function getHttpClient(): Promise<AxiosInstance> {
|
|
6
|
+
const config = await loadConfig();
|
|
7
|
+
const serverUrl = config.serverUrl;
|
|
8
|
+
const apiKey = config.apiKey;
|
|
9
|
+
|
|
10
|
+
if (!serverUrl) {
|
|
11
|
+
throw new Error('Server URL is not set. Please run: hsync config set-server <url>');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const headers: Record<string, string> = {};
|
|
15
|
+
if (apiKey) {
|
|
16
|
+
headers['X-API-Key'] = apiKey;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return axios.create({
|
|
20
|
+
baseURL: serverUrl,
|
|
21
|
+
headers: headers,
|
|
22
|
+
validateStatus: (status) => status >= 200 && status < 300,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import type { CliConfig } from '../types';
|
|
5
|
+
|
|
6
|
+
const configDir = path.join(os.homedir(), '.hsync');
|
|
7
|
+
const configPath = path.join(configDir, 'config.json');
|
|
8
|
+
|
|
9
|
+
export async function loadConfig(): Promise<CliConfig> {
|
|
10
|
+
try {
|
|
11
|
+
const raw = await fs.readFile(configPath, 'utf-8');
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (!parsed.syncJobs) {
|
|
14
|
+
parsed.syncJobs = [];
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
} catch (err) {
|
|
18
|
+
return { syncJobs: [] };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function saveConfig(config: CliConfig): Promise<void> {
|
|
23
|
+
await fs.mkdir(configDir, { recursive: true });
|
|
24
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
|
|
3
|
+
export class OverwritePrompter {
|
|
4
|
+
private overrideDecision: 'overwrite-all' | 'skip-all' | null = null;
|
|
5
|
+
private forceYes: boolean;
|
|
6
|
+
|
|
7
|
+
constructor(forceYes: boolean = false) {
|
|
8
|
+
this.forceYes = forceYes;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async shouldOverwrite(filePath: string): Promise<boolean> {
|
|
12
|
+
if (this.forceYes || this.overrideDecision === 'overwrite-all') {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if (this.overrideDecision === 'skip-all') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const answer = await inquirer.prompt([
|
|
20
|
+
{
|
|
21
|
+
type: 'list',
|
|
22
|
+
name: 'decision',
|
|
23
|
+
message: `Conflict detected on "${filePath}". Local and remote content differ. How do you want to proceed?`,
|
|
24
|
+
choices: [
|
|
25
|
+
{ name: 'Overwrite', value: 'overwrite' },
|
|
26
|
+
{ name: 'Skip', value: 'skip' },
|
|
27
|
+
{ name: 'Overwrite All', value: 'overwrite-all' },
|
|
28
|
+
{ name: 'Skip All', value: 'skip-all' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const choice = answer.decision;
|
|
34
|
+
if (choice === 'overwrite') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (choice === 'skip') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (choice === 'overwrite-all') {
|
|
41
|
+
this.overrideDecision = 'overwrite-all';
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (choice === 'skip-all') {
|
|
45
|
+
this.overrideDecision = 'skip-all';
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|