tsoft-cli 3.12.2 → 3.12.4
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/commands/theme-dev.js +2 -16
- package/src/commands/theme-init.js +12 -29
- package/src/commands/theme-section.js +3 -37
- package/src/commands/theme.js +12 -47
- package/src/theme-download.js +105 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to T-Soft CLI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [3.12.4] - 2026-08-11
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- `theme pull` no longer fails on Windows with `EBUSY: resource busy or locked`. Putting a finished download in place moved the existing theme directory aside first, and Windows refuses to rename a directory that a terminal is sitting in or that has a file open in an editor — so the command failed for anyone whose editor had the theme open, which is the normal case. The theme directory now stays where it is: fresh files are copied in and files no longer present in the archive are removed afterwards, so nothing has to be renamed. macOS and Linux were unaffected because they allow renaming a directory that is in use.
|
|
12
|
+
|
|
13
|
+
## [3.12.3] - 2026-08-11
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- A failed download no longer destroys the theme you already had on disk. `theme pull`, `theme create` and `theme init` deleted the target directory *before* fetching anything, so a download that arrived truncated or died mid-transfer left nothing behind but a `theme.zip` that could not be opened — the files you had been working on were already gone. Downloads now land in a temporary directory next to the target and are only put in place once the archive has been received and read successfully; if anything goes wrong the existing files stay exactly as they were.
|
|
17
|
+
- A download cut off mid-transfer is now reported instead of hanging. Only the file being written was watched for errors, not the incoming data, so a dropped connection could leave the command waiting indefinitely or crash it outside its own error handling.
|
|
18
|
+
- `theme section` no longer risks the theme it is updating: a corrupt archive is detected before any file in the theme is touched.
|
|
19
|
+
|
|
8
20
|
## [3.12.2] - 2026-08-11
|
|
9
21
|
|
|
10
22
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
|
-
import {createWriteStream} from 'fs';
|
|
4
3
|
import path from 'path';
|
|
5
|
-
import AdmZip from 'adm-zip';
|
|
6
4
|
import chokidar from 'chokidar';
|
|
7
5
|
import {createApiClient} from '../api-client.js';
|
|
8
6
|
import open from 'open';
|
|
@@ -12,6 +10,7 @@ import {brandedConfirm, brandedSelect, showWarning, showError, showInfo} from '.
|
|
|
12
10
|
import {themeSectionMenuCommand} from './theme-section.js';
|
|
13
11
|
import {fetchAllThemes} from '../theme-list.js';
|
|
14
12
|
import { t } from '../i18n.js';
|
|
13
|
+
import {extractThemeArchive} from '../theme-download.js';
|
|
15
14
|
|
|
16
15
|
function resolveSectionFolder(relativePath) {
|
|
17
16
|
if (!relativePath) {
|
|
@@ -61,20 +60,7 @@ async function pullAndSync(themeUuid, themePath) {
|
|
|
61
60
|
console.log(chalk.yellow(t('dev.sync_pulling')));
|
|
62
61
|
const response = await apiClient.download(`/theme/${themeUuid}/download`, {});
|
|
63
62
|
|
|
64
|
-
await
|
|
65
|
-
const zipPath = path.join(themePath, 'theme.zip');
|
|
66
|
-
const writer = createWriteStream(zipPath);
|
|
67
|
-
|
|
68
|
-
response.data.pipe(writer);
|
|
69
|
-
|
|
70
|
-
await new Promise((resolve, reject) => {
|
|
71
|
-
writer.on('finish', resolve);
|
|
72
|
-
writer.on('error', reject);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const zip = new AdmZip(zipPath);
|
|
76
|
-
zip.extractAllTo(themePath, true);
|
|
77
|
-
await fs.unlink(zipPath);
|
|
63
|
+
await extractThemeArchive(response.data, themePath);
|
|
78
64
|
|
|
79
65
|
console.log(chalk.green(t('dev.sync_updated')));
|
|
80
66
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
|
-
import {createWriteStream} from 'fs';
|
|
4
3
|
import path from 'path';
|
|
5
|
-
import AdmZip from 'adm-zip';
|
|
6
4
|
import {createApiClient} from '../api-client.js';
|
|
7
5
|
import {slugify, getActiveStore, getWorkspaceRoot} from '../storage.js';
|
|
8
6
|
import {brandedConfirm, brandedSelect, showWarning, showInfo, showError} from '../ui/prompt-wrapper.js';
|
|
@@ -11,6 +9,7 @@ import {normalizeStoreDomain} from '../storage.js';
|
|
|
11
9
|
import {isJsonMode, jsonOut} from '../output-mode.js';
|
|
12
10
|
import {mapError} from '../errors/error-mapper.js';
|
|
13
11
|
import { t } from '../i18n.js';
|
|
12
|
+
import {extractThemeArchive} from '../theme-download.js';
|
|
14
13
|
|
|
15
14
|
export async function themeInitCommand(fromSlug) {
|
|
16
15
|
try {
|
|
@@ -83,21 +82,15 @@ export async function themeInitCommand(fromSlug) {
|
|
|
83
82
|
|
|
84
83
|
// Check if directory exists
|
|
85
84
|
const exists = await fs.access(themePath).then(() => true).catch(() => false);
|
|
86
|
-
if (exists) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (!overwrite) {
|
|
97
|
-
showWarning(t('init.dir_cancelled'));
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
await fs.rm(themePath, { recursive: true, force: true });
|
|
85
|
+
if (exists && !isJsonMode()) {
|
|
86
|
+
const overwrite = await brandedConfirm({
|
|
87
|
+
message: t('init.dir_exists_prompt', { path: themePath }),
|
|
88
|
+
default: false,
|
|
89
|
+
}, t('init.dir_exists_title'));
|
|
90
|
+
|
|
91
|
+
if (!overwrite) {
|
|
92
|
+
showWarning(t('init.dir_cancelled'));
|
|
93
|
+
return;
|
|
101
94
|
}
|
|
102
95
|
}
|
|
103
96
|
|
|
@@ -105,19 +98,9 @@ export async function themeInitCommand(fromSlug) {
|
|
|
105
98
|
if (!isJsonMode()) {
|
|
106
99
|
console.log(chalk.yellow(t('init.downloading')));
|
|
107
100
|
}
|
|
108
|
-
await fs.mkdir(themePath, { recursive: true });
|
|
109
101
|
const downloadResponse = await apiClient.download(`/theme/${forkedData.uuid}/download`, {});
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
downloadResponse.data.pipe(writer);
|
|
113
|
-
await new Promise((resolve, reject) => {
|
|
114
|
-
writer.on('finish', resolve);
|
|
115
|
-
writer.on('error', reject);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const zip = new AdmZip(zipPath);
|
|
119
|
-
zip.extractAllTo(themePath, true);
|
|
120
|
-
await fs.unlink(zipPath);
|
|
102
|
+
|
|
103
|
+
await extractThemeArchive(downloadResponse.data, themePath);
|
|
121
104
|
|
|
122
105
|
// JSON mode success output
|
|
123
106
|
if (isJsonMode()) {
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import {checkbox} from '@inquirer/prompts';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
|
-
import {createWriteStream} from 'fs';
|
|
5
4
|
import path from 'path';
|
|
6
|
-
import AdmZip from 'adm-zip';
|
|
7
5
|
import {createApiClient} from '../api-client.js';
|
|
8
6
|
import {getActiveTheme, getThemeUuidBySlug, normalizeStoreDomain, getActiveStore, getWorkspaceRoot} from '../storage.js';
|
|
9
7
|
import {brandedConfirm, brandedSelect, brandedSearch, brandedInput, showWarning, showSuccess, showInfo} from '../ui/prompt-wrapper.js';
|
|
10
8
|
import { t } from '../i18n.js';
|
|
9
|
+
import {mergeThemeArchive} from '../theme-download.js';
|
|
11
10
|
|
|
12
11
|
const SECTION_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
13
12
|
|
|
@@ -294,31 +293,13 @@ export async function themeSectionAddCommand() {
|
|
|
294
293
|
const themePath = path.join(getWorkspaceRoot(), storeSlug, activeTheme);
|
|
295
294
|
|
|
296
295
|
try {
|
|
297
|
-
// Tema klasörü henüz lokalde yoksa (theme dev/pull yapılmamışsa)
|
|
298
|
-
// createWriteStream ENOENT verir — önce dizini garantiye al.
|
|
299
|
-
await fs.mkdir(themePath, { recursive: true });
|
|
300
|
-
|
|
301
296
|
// Temayı indir
|
|
302
297
|
const downloadResponse = await apiClient.download(`/theme/${themeInfo.uuid}/download`, {});
|
|
303
298
|
|
|
304
|
-
const zipPath = path.join(themePath, 'theme-update.zip');
|
|
305
|
-
const writer = createWriteStream(zipPath);
|
|
306
|
-
|
|
307
|
-
downloadResponse.data.pipe(writer);
|
|
308
|
-
|
|
309
|
-
await new Promise((resolve, reject) => {
|
|
310
|
-
writer.on('finish', resolve);
|
|
311
|
-
writer.on('error', reject);
|
|
312
|
-
});
|
|
313
|
-
|
|
314
299
|
console.log(chalk.green(t('section.add.files_downloaded')));
|
|
315
300
|
console.log(chalk.yellow(t('section.add.files_updating')));
|
|
316
301
|
|
|
317
|
-
|
|
318
|
-
const zip = new AdmZip(zipPath);
|
|
319
|
-
zip.extractAllTo(themePath, true);
|
|
320
|
-
|
|
321
|
-
await fs.unlink(zipPath);
|
|
302
|
+
await mergeThemeArchive(downloadResponse.data, themePath);
|
|
322
303
|
|
|
323
304
|
console.log(chalk.green(t('section.add.files_updated') + '\n'));
|
|
324
305
|
console.log(chalk.bold.green(t('section.add.complete')));
|
|
@@ -337,24 +318,9 @@ export async function themeSectionAddCommand() {
|
|
|
337
318
|
}
|
|
338
319
|
|
|
339
320
|
async function downloadThemeFiles(apiClient, themeUuid, themePath) {
|
|
340
|
-
await fs.mkdir(themePath, { recursive: true });
|
|
341
|
-
|
|
342
321
|
const downloadResponse = await apiClient.download(`/theme/${themeUuid}/download`, {});
|
|
343
322
|
|
|
344
|
-
|
|
345
|
-
const writer = createWriteStream(zipPath);
|
|
346
|
-
|
|
347
|
-
downloadResponse.data.pipe(writer);
|
|
348
|
-
|
|
349
|
-
await new Promise((resolve, reject) => {
|
|
350
|
-
writer.on('finish', resolve);
|
|
351
|
-
writer.on('error', reject);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
const zip = new AdmZip(zipPath);
|
|
355
|
-
zip.extractAllTo(themePath, true);
|
|
356
|
-
|
|
357
|
-
await fs.unlink(zipPath);
|
|
323
|
+
await mergeThemeArchive(downloadResponse.data, themePath);
|
|
358
324
|
}
|
|
359
325
|
|
|
360
326
|
export async function themeSectionCreateCommand() {
|
package/src/commands/theme.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import {input} from '@inquirer/prompts';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
|
-
import {createWriteStream} from 'fs';
|
|
5
4
|
import path from 'path';
|
|
6
|
-
import AdmZip from 'adm-zip';
|
|
7
5
|
import {createApiClient} from '../api-client.js';
|
|
8
6
|
import {normalizeStoreDomain, slugify, setActiveTheme, getActiveTheme, getActiveStore, getWorkspaceRoot, getWorkspaceContext} from '../storage.js';
|
|
9
7
|
import {brandedConfirm, brandedSelect, brandedSearch, showWarning} from '../ui/prompt-wrapper.js';
|
|
@@ -12,6 +10,7 @@ import {themeLabels, decorateThemeName} from '../ui/theme-choice.js';
|
|
|
12
10
|
import { isJsonMode, jsonOut } from '../output-mode.js';
|
|
13
11
|
import { mapError } from '../errors/error-mapper.js';
|
|
14
12
|
import { t } from '../i18n.js';
|
|
13
|
+
import {extractThemeArchive} from '../theme-download.js';
|
|
15
14
|
|
|
16
15
|
const THEME_SEARCH_THRESHOLD = 7;
|
|
17
16
|
|
|
@@ -289,37 +288,20 @@ export async function themeCreateCommand(themeName) {
|
|
|
289
288
|
|
|
290
289
|
const response = await apiClient.download('/theme/default/download', downloadParams);
|
|
291
290
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const zipPath = path.join(themePath, 'theme.zip');
|
|
295
|
-
const writer = createWriteStream(zipPath);
|
|
296
|
-
|
|
297
|
-
response.data.pipe(writer);
|
|
298
|
-
|
|
299
|
-
writer.on('finish', async () => {
|
|
300
|
-
console.log(chalk.green(t('theme.files_downloaded')));
|
|
301
|
-
console.log(chalk.yellow(t('theme.extracting')));
|
|
302
|
-
|
|
303
|
-
const zip = new AdmZip(zipPath);
|
|
304
|
-
zip.extractAllTo(themePath, true);
|
|
305
|
-
|
|
306
|
-
await fs.unlink(zipPath);
|
|
291
|
+
console.log(chalk.green(t('theme.files_downloaded')));
|
|
292
|
+
console.log(chalk.yellow(t('theme.extracting')));
|
|
307
293
|
|
|
308
|
-
|
|
294
|
+
await extractThemeArchive(response.data, themePath);
|
|
309
295
|
|
|
310
|
-
|
|
311
|
-
console.log(chalk.green(t('theme.active_set') + '\n'));
|
|
296
|
+
console.log(chalk.green(t('theme.extracted', { path: `${storeSlug}/${themeSlug}/` })));
|
|
312
297
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
console.log(chalk.cyan(t('theme.dev_start_tip')));
|
|
316
|
-
console.log(chalk.white(' tsoft theme dev\n'));
|
|
317
|
-
});
|
|
298
|
+
await setActiveTheme(themeSlug);
|
|
299
|
+
console.log(chalk.green(t('theme.active_set') + '\n'));
|
|
318
300
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
301
|
+
console.log(chalk.bold.green(t('theme.create_success')));
|
|
302
|
+
console.log(chalk.gray(' ' + t('theme.create_location', { path: `${storeSlug}/${themeSlug}/` }) + '\n'));
|
|
303
|
+
console.log(chalk.cyan(t('theme.dev_start_tip')));
|
|
304
|
+
console.log(chalk.white(' tsoft theme dev\n'));
|
|
323
305
|
|
|
324
306
|
} catch (error) {
|
|
325
307
|
console.error(chalk.red('\n❌ Hata: ') + error.message);
|
|
@@ -385,33 +367,16 @@ export async function themePullCommand() {
|
|
|
385
367
|
showWarning(t('theme.cancelled'));
|
|
386
368
|
return;
|
|
387
369
|
}
|
|
388
|
-
|
|
389
|
-
await fs.rm(themePath, {recursive: true, force: true});
|
|
390
370
|
}
|
|
391
371
|
|
|
392
372
|
console.log(chalk.yellow(t('theme.downloading_files')));
|
|
393
373
|
|
|
394
374
|
const downloadResponse = await apiClient.download(`/theme/${themeUuid}/download`, {});
|
|
395
375
|
|
|
396
|
-
await fs.mkdir(themePath, {recursive: true});
|
|
397
|
-
|
|
398
|
-
const zipPath = path.join(themePath, 'theme.zip');
|
|
399
|
-
const writer = createWriteStream(zipPath);
|
|
400
|
-
|
|
401
|
-
downloadResponse.data.pipe(writer);
|
|
402
|
-
|
|
403
|
-
await new Promise((resolve, reject) => {
|
|
404
|
-
writer.on('finish', resolve);
|
|
405
|
-
writer.on('error', reject);
|
|
406
|
-
});
|
|
407
|
-
|
|
408
376
|
console.log(chalk.green(t('theme.files_downloaded')));
|
|
409
377
|
console.log(chalk.yellow(t('theme.extracting')));
|
|
410
378
|
|
|
411
|
-
|
|
412
|
-
zip.extractAllTo(themePath, true);
|
|
413
|
-
|
|
414
|
-
await fs.unlink(zipPath);
|
|
379
|
+
await extractThemeArchive(downloadResponse.data, themePath);
|
|
415
380
|
|
|
416
381
|
console.log(chalk.green(t('theme.extracted', { path: `${storeSlug}/${themeSlug}/` })));
|
|
417
382
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import { createWriteStream } from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { pipeline } from 'stream/promises';
|
|
5
|
+
import AdmZip from 'adm-zip';
|
|
6
|
+
|
|
7
|
+
async function stageArchive(stream, stagingPath) {
|
|
8
|
+
const zipPath = path.join(stagingPath, 'theme.zip');
|
|
9
|
+
|
|
10
|
+
await pipeline(stream, createWriteStream(zipPath));
|
|
11
|
+
|
|
12
|
+
const { size } = await fs.stat(zipPath);
|
|
13
|
+
|
|
14
|
+
if (size === 0) {
|
|
15
|
+
throw new Error('EMPTY_ARCHIVE');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const zip = new AdmZip(zipPath);
|
|
19
|
+
zip.extractAllTo(stagingPath, true);
|
|
20
|
+
|
|
21
|
+
await fs.rm(zipPath, { force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function withStaging(themePath, apply) {
|
|
25
|
+
const parent = path.dirname(themePath);
|
|
26
|
+
await fs.mkdir(parent, { recursive: true });
|
|
27
|
+
|
|
28
|
+
const staging = await fs.mkdtemp(path.join(parent, `.${path.basename(themePath)}-`));
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return await apply(staging);
|
|
32
|
+
} finally {
|
|
33
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function copyInto(sourceDir, targetDir) {
|
|
38
|
+
for (const entry of await fs.readdir(sourceDir)) {
|
|
39
|
+
await fs.cp(path.join(sourceDir, entry), path.join(targetDir, entry), {
|
|
40
|
+
recursive: true,
|
|
41
|
+
force: true,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Arşivde bulunmayan girdileri hedeften siler.
|
|
48
|
+
*
|
|
49
|
+
* Dizini yeniden adlandırmak Windows'ta içine girilmişse veya içindeki bir
|
|
50
|
+
* dosya açıksa EBUSY verir; bu yüzden hedef dizin yerinde bırakılır ve yalnızca
|
|
51
|
+
* fazlalık girdiler kaldırılır. Kalıcı bir kilit tek bir dosyayı geride
|
|
52
|
+
* bırakabilir, ama temanın geri kalanı yine güncellenmiş olur.
|
|
53
|
+
*/
|
|
54
|
+
async function removeStaleEntries(themePath, stagingPath) {
|
|
55
|
+
const fresh = new Set(await fs.readdir(stagingPath));
|
|
56
|
+
|
|
57
|
+
for (const entry of await fs.readdir(themePath)) {
|
|
58
|
+
if (!fresh.has(entry)) {
|
|
59
|
+
await fs.rm(path.join(themePath, entry), { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Tema arşivini indirir, doğrular ve ancak ondan sonra hedefe yerleştirir.
|
|
66
|
+
*
|
|
67
|
+
* Arşiv hedefin yanındaki geçici dizine açılır; indirme yarıda koparsa veya
|
|
68
|
+
* arşiv bozuksa hedef dizine dokunulmaz. Hedef dizinin kendisi taşınmaz,
|
|
69
|
+
* yalnızca içeriği değiştirilir — dizin bir editörde veya kabukta açıkken de
|
|
70
|
+
* çalışır.
|
|
71
|
+
*
|
|
72
|
+
* @param {import('stream').Readable} stream - Arşiv veri akışı
|
|
73
|
+
* @param {string} themePath - Temanın açılacağı hedef dizin
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
*/
|
|
76
|
+
export async function extractThemeArchive(stream, themePath) {
|
|
77
|
+
await withStaging(themePath, async (staging) => {
|
|
78
|
+
await stageArchive(stream, staging);
|
|
79
|
+
|
|
80
|
+
await fs.mkdir(themePath, { recursive: true });
|
|
81
|
+
|
|
82
|
+
await copyInto(staging, themePath);
|
|
83
|
+
await removeStaleEntries(themePath, staging);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tema arşivini indirir, doğrular ve ancak ondan sonra hedefin üzerine açar.
|
|
89
|
+
*
|
|
90
|
+
* Hedefteki dosyalar silinmez; arşivdekiler üzerine yazılır. Bozuk veya yarım
|
|
91
|
+
* inen bir arşiv hedefe hiç dokunmaz.
|
|
92
|
+
*
|
|
93
|
+
* @param {import('stream').Readable} stream - Arşiv veri akışı
|
|
94
|
+
* @param {string} themePath - Arşivin açılacağı dizin
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
export async function mergeThemeArchive(stream, themePath) {
|
|
98
|
+
await withStaging(themePath, async (staging) => {
|
|
99
|
+
await stageArchive(stream, staging);
|
|
100
|
+
|
|
101
|
+
await fs.mkdir(themePath, { recursive: true });
|
|
102
|
+
|
|
103
|
+
await copyInto(staging, themePath);
|
|
104
|
+
});
|
|
105
|
+
}
|