@duyxyz/saca 1.0.3 → 1.0.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.
Files changed (7) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +38 -38
  3. package/bin/cli.js +315 -315
  4. package/lib/adb.js +156 -156
  5. package/lib/state.js +144 -144
  6. package/lib/ui.js +205 -205
  7. package/package.json +50 -50
package/lib/ui.js CHANGED
@@ -1,205 +1,205 @@
1
- import chalk from 'chalk';
2
- import { state, summaryStats, getCurrentItem } from './state.js';
3
-
4
- export function stripAnsi(text) {
5
- return String(text).replace(/\x1B\[[0-9;]*m/g, '');
6
- }
7
-
8
- export function pad(text, width) {
9
- const plainLength = stripAnsi(text).length;
10
- if (plainLength >= width) return text;
11
- return text + ' '.repeat(width - plainLength);
12
- }
13
-
14
- export function truncate(text, width) {
15
- if (width <= 0) return '';
16
- const plain = stripAnsi(text);
17
- if (plain.length <= width) return pad(text, width);
18
- if (width === 1) return '…';
19
- return plain.slice(0, width - 1) + '…';
20
- }
21
-
22
- export function line(width, left = '', right = '') {
23
- const rightText = right ? ` ${right}` : '';
24
- const available = Math.max(0, width - stripAnsi(rightText).length);
25
- return truncate(left, available) + rightText;
26
- }
27
-
28
- export function fullWidthRule(width, color = chalk.dim) {
29
- return color('─'.repeat(Math.max(0, width)));
30
- }
31
-
32
- export function getTerminalSize() {
33
- return {
34
- width: process.stdout.columns || 100,
35
- height: process.stdout.rows || 32,
36
- };
37
- }
38
-
39
- export function getListHeight() {
40
- const { height } = getTerminalSize();
41
- return Math.max(1, height - 10);
42
- }
43
-
44
- export function renderLoading(width, height) {
45
- const rows = [];
46
- rows.push(chalk.white.bold('SACA'));
47
- rows.push(chalk.dim('Full-screen ADB debloat session'));
48
- rows.push('');
49
- rows.push(chalk.cyan(state.message));
50
- while (rows.length < height - 2) rows.push('');
51
- rows.push(fullWidthRule(width));
52
- rows.push(chalk.dim('Waiting for ADB response...'));
53
- return rows;
54
- }
55
-
56
- export function renderError(width, height) {
57
- const rows = [];
58
- rows.push(chalk.red.bold('Connection Error'));
59
- rows.push('');
60
- for (const part of state.error.split('\n')) rows.push(chalk.white(part));
61
- rows.push('');
62
- rows.push(chalk.dim('Press Ctrl+Q or Ctrl+C to exit.'));
63
- while (rows.length < height) rows.push('');
64
- return rows.slice(0, height);
65
- }
66
-
67
- export function renderHeader(width) {
68
- const device = state.device;
69
- const deviceName = device ? [device.brand, device.model].filter(Boolean).join(' ').trim() : 'No device';
70
- const right = state.query ? chalk.yellow(`filter: ${state.query}`) : chalk.dim('filter: all');
71
- return [
72
- line(width, chalk.white.bold('SACA'), chalk.dim('FULL SCREEN')),
73
- line(width, chalk.dim(deviceName || 'Unknown device'), right),
74
- fullWidthRule(width),
75
- ];
76
- }
77
-
78
- export function renderFooter(width, left = '', right = '') {
79
- return [line(width, chalk.dim(left), chalk.dim(right))];
80
- }
81
-
82
- export function renderList(width, height) {
83
- const rows = [];
84
- const { systemCount, userCount } = summaryStats();
85
- const selectedCount = state.selected.size;
86
- const current = getCurrentItem();
87
- const systemVisible = state.filteredByGroup.System.slice(state.scrollByGroup.System, state.scrollByGroup.System + Math.max(4, height - 6));
88
- const userVisible = state.filteredByGroup.User.slice(state.scrollByGroup.User, state.scrollByGroup.User + Math.max(4, height - 6));
89
-
90
- rows.push(line(width, chalk.white(`Packages ${chalk.dim(`(${state.items.length})`)}`), chalk.dim(`selected ${selectedCount}`)));
91
- rows.push(line(width, chalk.dim(`system ${state.filteredByGroup.System.length}/${systemCount} | user ${state.filteredByGroup.User.length}/${userCount}`), chalk.dim(`focus ${state.activePane.toLowerCase()}`)));
92
- rows.push(fullWidthRule(width));
93
-
94
- const leftWidth = Math.max(20, Math.floor((width - 3) / 2));
95
- const rightWidth = Math.max(20, width - leftWidth - 3);
96
- const maxPaneHeight = Math.max(4, height - 5);
97
-
98
- const renderPane = (group, visible, paneWidth) => {
99
- const paneRows = [];
100
- const isActivePane = state.activePane === group;
101
- const title = isActivePane ? chalk.cyan.bold(`${group} Apps`) : chalk.white(`${group} Apps`);
102
- const count = chalk.dim(`(${state.filteredByGroup[group].length})`);
103
- paneRows.push(line(paneWidth, title, count));
104
- paneRows.push(chalk.dim('─'.repeat(Math.max(0, paneWidth))));
105
-
106
- if (visible.length === 0) {
107
- paneRows.push(chalk.yellow('No matches'));
108
- paneRows.push(chalk.dim('Adjust filter'));
109
- } else {
110
- for (let index = 0; index < visible.length; index++) {
111
- const absoluteIndex = state.scrollByGroup[group] + index;
112
- const item = visible[index];
113
- const isActive = isActivePane && absoluteIndex === state.cursorByGroup[group];
114
- const isSelected = state.selected.has(item.pkg);
115
- const marker = isActive ? chalk.cyan('❯') : ' ';
116
- const bullet = isSelected ? chalk.green('●') : chalk.dim('○');
117
- const pkgColor = isSelected ? chalk.green : (isActive ? chalk.white : chalk.dim);
118
- const rowText = `${marker} ${bullet} ${item.pkg}`;
119
- const formattedRow = line(paneWidth, isActive ? chalk.bold(rowText) : rowText);
120
- paneRows.push(pkgColor(formattedRow));
121
- }
122
- }
123
- return paneRows.slice(0, maxPaneHeight);
124
- };
125
-
126
- const leftPane = renderPane('System', systemVisible, leftWidth);
127
- const rightPane = renderPane('User', userVisible, rightWidth);
128
- const paneHeight = Math.max(leftPane.length, rightPane.length);
129
-
130
- for (let i = 0; i < paneHeight; i++) {
131
- rows.push(`${pad(leftPane[i] || '', leftWidth)} ${chalk.dim('│')} ${pad(rightPane[i] || '', rightWidth)}`);
132
- }
133
-
134
- const info = current ? `${current.pkg} | ${current.group} app` : 'No package selected';
135
- while (rows.length < height - 2) rows.push('');
136
- rows.push(fullWidthRule(width));
137
- rows.push(...renderFooter(width, info, 'up/down move | left/right switch | space toggle | enter review | esc clear | ctrl+q quit'));
138
- return rows.slice(0, height);
139
- }
140
-
141
- export function renderConfirm(width, height) {
142
- const rows = [];
143
- const pending = state.pending;
144
- rows.push(chalk.white.bold('Review Selection'));
145
- rows.push(chalk.dim(`Ready to uninstall ${pending.length} package(s) for user 0.`));
146
- rows.push(fullWidthRule(width));
147
- for (const pkg of pending.slice(0, Math.max(3, height - 6))) rows.push(chalk.red(`- ${pkg}`));
148
- if (pending.length > Math.max(3, height - 6)) rows.push(chalk.dim(`... and ${pending.length - Math.max(3, height - 6)} more`));
149
- while (rows.length < height - 2) rows.push('');
150
- rows.push(fullWidthRule(width));
151
- rows.push(...renderFooter(width, 'Press Enter to confirm', 'Enter confirm | Esc back | ctrl+q quit'));
152
- return rows.slice(0, height);
153
- }
154
-
155
- export function renderRunning(width, height) {
156
- const rows = [];
157
- rows.push(chalk.white.bold('Uninstalling Packages'));
158
- rows.push(chalk.dim(`Processing ${state.progressIndex}/${state.pending.length}`));
159
- rows.push(fullWidthRule(width));
160
- const barWidth = Math.max(10, width - 10);
161
- const ratio = state.pending.length === 0 ? 0 : state.progressIndex / state.pending.length;
162
- const filled = Math.round(barWidth * ratio);
163
- const bar = chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(Math.max(0, barWidth - filled)));
164
- rows.push(bar);
165
- rows.push('');
166
- rows.push(line(width, chalk.white(state.currentPackage || 'Preparing...'), chalk.dim(`${state.successCount} ok / ${state.failCount} failed`)));
167
- while (rows.length < height - 2) rows.push('');
168
- rows.push(fullWidthRule(width));
169
- rows.push(...renderFooter(width, 'Working...', 'Do not disconnect the device'));
170
- return rows.slice(0, height);
171
- }
172
-
173
- export function renderSummary(width, height) {
174
- const rows = [];
175
- rows.push(chalk.white.bold('Summary'));
176
- rows.push(chalk.green(`Success: ${state.successCount}`));
177
- rows.push(state.failCount > 0 ? chalk.red(`Failed: ${state.failCount}`) : chalk.dim('Failed: 0'));
178
- rows.push(fullWidthRule(width));
179
- rows.push(chalk.dim('Press Enter or Esc to return to the list. Press Ctrl+Q or Ctrl+C to exit.'));
180
- while (rows.length < height) rows.push('');
181
- return rows.slice(0, height);
182
- }
183
-
184
- let lastFrame = '';
185
-
186
- export function render() {
187
- if (!state.running) return;
188
- const { width, height } = getTerminalSize();
189
- const bodyHeight = Math.max(6, height - 3);
190
- let rows;
191
- if (state.screen === 'loading') rows = renderLoading(width, height);
192
- else if (state.screen === 'error') rows = renderError(width, height);
193
- else if (state.screen === 'list') rows = [...renderHeader(width), ...renderList(width, bodyHeight)];
194
- else if (state.screen === 'confirm') rows = [...renderHeader(width), ...renderConfirm(width, bodyHeight)];
195
- else if (state.screen === 'running') rows = [...renderHeader(width), ...renderRunning(width, bodyHeight)];
196
- else rows = [...renderHeader(width), ...renderSummary(width, bodyHeight)];
197
-
198
- while (rows.length < height) rows.push('');
199
- const frame = rows.slice(0, height).map((row) => truncate(row, width)).join('\n');
200
-
201
- if (frame !== lastFrame) {
202
- process.stdout.write('\x1b[H' + frame + '\x1b[J');
203
- lastFrame = frame;
204
- }
205
- }
1
+ import chalk from 'chalk';
2
+ import { state, summaryStats, getCurrentItem } from './state.js';
3
+
4
+ export function stripAnsi(text) {
5
+ return String(text).replace(/\x1B\[[0-9;]*m/g, '');
6
+ }
7
+
8
+ export function pad(text, width) {
9
+ const plainLength = stripAnsi(text).length;
10
+ if (plainLength >= width) return text;
11
+ return text + ' '.repeat(width - plainLength);
12
+ }
13
+
14
+ export function truncate(text, width) {
15
+ if (width <= 0) return '';
16
+ const plain = stripAnsi(text);
17
+ if (plain.length <= width) return pad(text, width);
18
+ if (width === 1) return '…';
19
+ return plain.slice(0, width - 1) + '…';
20
+ }
21
+
22
+ export function line(width, left = '', right = '') {
23
+ const rightText = right ? ` ${right}` : '';
24
+ const available = Math.max(0, width - stripAnsi(rightText).length);
25
+ return truncate(left, available) + rightText;
26
+ }
27
+
28
+ export function fullWidthRule(width, color = chalk.dim) {
29
+ return color('─'.repeat(Math.max(0, width)));
30
+ }
31
+
32
+ export function getTerminalSize() {
33
+ return {
34
+ width: process.stdout.columns || 100,
35
+ height: process.stdout.rows || 32,
36
+ };
37
+ }
38
+
39
+ export function getListHeight() {
40
+ const { height } = getTerminalSize();
41
+ return Math.max(1, height - 10);
42
+ }
43
+
44
+ export function renderLoading(width, height) {
45
+ const rows = [];
46
+ rows.push(chalk.white.bold('SACA'));
47
+ rows.push(chalk.dim('Full-screen ADB debloat session'));
48
+ rows.push('');
49
+ rows.push(chalk.cyan(state.message));
50
+ while (rows.length < height - 2) rows.push('');
51
+ rows.push(fullWidthRule(width));
52
+ rows.push(chalk.dim('Waiting for ADB response...'));
53
+ return rows;
54
+ }
55
+
56
+ export function renderError(width, height) {
57
+ const rows = [];
58
+ rows.push(chalk.red.bold('Connection Error'));
59
+ rows.push('');
60
+ for (const part of state.error.split('\n')) rows.push(chalk.white(part));
61
+ rows.push('');
62
+ rows.push(chalk.dim('Press Ctrl+Q or Ctrl+C to exit.'));
63
+ while (rows.length < height) rows.push('');
64
+ return rows.slice(0, height);
65
+ }
66
+
67
+ export function renderHeader(width) {
68
+ const device = state.device;
69
+ const deviceName = device ? [device.brand, device.model].filter(Boolean).join(' ').trim() : 'No device';
70
+ const right = state.query ? chalk.yellow(`filter: ${state.query}`) : chalk.dim('filter: all');
71
+ return [
72
+ line(width, chalk.white.bold('SACA'), chalk.dim('FULL SCREEN')),
73
+ line(width, chalk.dim(deviceName || 'Unknown device'), right),
74
+ fullWidthRule(width),
75
+ ];
76
+ }
77
+
78
+ export function renderFooter(width, left = '', right = '') {
79
+ return [line(width, chalk.dim(left), chalk.dim(right))];
80
+ }
81
+
82
+ export function renderList(width, height) {
83
+ const rows = [];
84
+ const { systemCount, userCount } = summaryStats();
85
+ const selectedCount = state.selected.size;
86
+ const current = getCurrentItem();
87
+ const systemVisible = state.filteredByGroup.System.slice(state.scrollByGroup.System, state.scrollByGroup.System + Math.max(4, height - 6));
88
+ const userVisible = state.filteredByGroup.User.slice(state.scrollByGroup.User, state.scrollByGroup.User + Math.max(4, height - 6));
89
+
90
+ rows.push(line(width, chalk.white(`Packages ${chalk.dim(`(${state.items.length})`)}`), chalk.dim(`selected ${selectedCount}`)));
91
+ rows.push(line(width, chalk.dim(`system ${state.filteredByGroup.System.length}/${systemCount} | user ${state.filteredByGroup.User.length}/${userCount}`), chalk.dim(`focus ${state.activePane.toLowerCase()}`)));
92
+ rows.push(fullWidthRule(width));
93
+
94
+ const leftWidth = Math.max(20, Math.floor((width - 3) / 2));
95
+ const rightWidth = Math.max(20, width - leftWidth - 3);
96
+ const maxPaneHeight = Math.max(4, height - 5);
97
+
98
+ const renderPane = (group, visible, paneWidth) => {
99
+ const paneRows = [];
100
+ const isActivePane = state.activePane === group;
101
+ const title = isActivePane ? chalk.cyan.bold(`${group} Apps`) : chalk.white(`${group} Apps`);
102
+ const count = chalk.dim(`(${state.filteredByGroup[group].length})`);
103
+ paneRows.push(line(paneWidth, title, count));
104
+ paneRows.push(chalk.dim('─'.repeat(Math.max(0, paneWidth))));
105
+
106
+ if (visible.length === 0) {
107
+ paneRows.push(chalk.yellow('No matches'));
108
+ paneRows.push(chalk.dim('Adjust filter'));
109
+ } else {
110
+ for (let index = 0; index < visible.length; index++) {
111
+ const absoluteIndex = state.scrollByGroup[group] + index;
112
+ const item = visible[index];
113
+ const isActive = isActivePane && absoluteIndex === state.cursorByGroup[group];
114
+ const isSelected = state.selected.has(item.pkg);
115
+ const marker = isActive ? chalk.cyan('❯') : ' ';
116
+ const bullet = isSelected ? chalk.green('●') : chalk.dim('○');
117
+ const pkgColor = isSelected ? chalk.green : (isActive ? chalk.white : chalk.dim);
118
+ const rowText = `${marker} ${bullet} ${item.pkg}`;
119
+ const formattedRow = line(paneWidth, isActive ? chalk.bold(rowText) : rowText);
120
+ paneRows.push(pkgColor(formattedRow));
121
+ }
122
+ }
123
+ return paneRows.slice(0, maxPaneHeight);
124
+ };
125
+
126
+ const leftPane = renderPane('System', systemVisible, leftWidth);
127
+ const rightPane = renderPane('User', userVisible, rightWidth);
128
+ const paneHeight = Math.max(leftPane.length, rightPane.length);
129
+
130
+ for (let i = 0; i < paneHeight; i++) {
131
+ rows.push(`${pad(leftPane[i] || '', leftWidth)} ${chalk.dim('│')} ${pad(rightPane[i] || '', rightWidth)}`);
132
+ }
133
+
134
+ const info = current ? `${current.pkg} | ${current.group} app` : 'No package selected';
135
+ while (rows.length < height - 2) rows.push('');
136
+ rows.push(fullWidthRule(width));
137
+ rows.push(...renderFooter(width, info, 'up/down move | left/right switch | space toggle | enter review | esc clear | ctrl+q quit'));
138
+ return rows.slice(0, height);
139
+ }
140
+
141
+ export function renderConfirm(width, height) {
142
+ const rows = [];
143
+ const pending = state.pending;
144
+ rows.push(chalk.white.bold('Review Selection'));
145
+ rows.push(chalk.dim(`Ready to uninstall ${pending.length} package(s) for user 0.`));
146
+ rows.push(fullWidthRule(width));
147
+ for (const pkg of pending.slice(0, Math.max(3, height - 6))) rows.push(chalk.red(`- ${pkg}`));
148
+ if (pending.length > Math.max(3, height - 6)) rows.push(chalk.dim(`... and ${pending.length - Math.max(3, height - 6)} more`));
149
+ while (rows.length < height - 2) rows.push('');
150
+ rows.push(fullWidthRule(width));
151
+ rows.push(...renderFooter(width, 'Press Enter to confirm', 'Enter confirm | Esc back | ctrl+q quit'));
152
+ return rows.slice(0, height);
153
+ }
154
+
155
+ export function renderRunning(width, height) {
156
+ const rows = [];
157
+ rows.push(chalk.white.bold('Uninstalling Packages'));
158
+ rows.push(chalk.dim(`Processing ${state.progressIndex}/${state.pending.length}`));
159
+ rows.push(fullWidthRule(width));
160
+ const barWidth = Math.max(10, width - 10);
161
+ const ratio = state.pending.length === 0 ? 0 : state.progressIndex / state.pending.length;
162
+ const filled = Math.round(barWidth * ratio);
163
+ const bar = chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(Math.max(0, barWidth - filled)));
164
+ rows.push(bar);
165
+ rows.push('');
166
+ rows.push(line(width, chalk.white(state.currentPackage || 'Preparing...'), chalk.dim(`${state.successCount} ok / ${state.failCount} failed`)));
167
+ while (rows.length < height - 2) rows.push('');
168
+ rows.push(fullWidthRule(width));
169
+ rows.push(...renderFooter(width, 'Working...', 'Do not disconnect the device'));
170
+ return rows.slice(0, height);
171
+ }
172
+
173
+ export function renderSummary(width, height) {
174
+ const rows = [];
175
+ rows.push(chalk.white.bold('Summary'));
176
+ rows.push(chalk.green(`Success: ${state.successCount}`));
177
+ rows.push(state.failCount > 0 ? chalk.red(`Failed: ${state.failCount}`) : chalk.dim('Failed: 0'));
178
+ rows.push(fullWidthRule(width));
179
+ rows.push(chalk.dim('Press Enter or Esc to return to the list. Press Ctrl+Q or Ctrl+C to exit.'));
180
+ while (rows.length < height) rows.push('');
181
+ return rows.slice(0, height);
182
+ }
183
+
184
+ let lastFrame = '';
185
+
186
+ export function render() {
187
+ if (!state.running) return;
188
+ const { width, height } = getTerminalSize();
189
+ const bodyHeight = Math.max(6, height - 3);
190
+ let rows;
191
+ if (state.screen === 'loading') rows = renderLoading(width, height);
192
+ else if (state.screen === 'error') rows = renderError(width, height);
193
+ else if (state.screen === 'list') rows = [...renderHeader(width), ...renderList(width, bodyHeight)];
194
+ else if (state.screen === 'confirm') rows = [...renderHeader(width), ...renderConfirm(width, bodyHeight)];
195
+ else if (state.screen === 'running') rows = [...renderHeader(width), ...renderRunning(width, bodyHeight)];
196
+ else rows = [...renderHeader(width), ...renderSummary(width, bodyHeight)];
197
+
198
+ while (rows.length < height) rows.push('');
199
+ const frame = rows.slice(0, height).map((row) => truncate(row, width)).join('\n');
200
+
201
+ if (frame !== lastFrame) {
202
+ process.stdout.write('\x1b[H' + frame + '\x1b[J');
203
+ lastFrame = frame;
204
+ }
205
+ }
package/package.json CHANGED
@@ -1,50 +1,50 @@
1
- {
2
- "name": "@duyxyz/saca",
3
- "version": "1.0.3",
4
- "description": "Interactive CLI tool to list and uninstall Android apps via ADB",
5
- "type": "module",
6
- "bin": {
7
- "saca": "bin/cli.js"
8
- },
9
- "files": [
10
- "bin/",
11
- "lib/",
12
- "README.md",
13
- "LICENSE",
14
- "adb.exe",
15
- "AdbWinApi.dll",
16
- "AdbWinUsbApi.dll"
17
- ],
18
- "scripts": {
19
- "start": "node bin/cli.js"
20
- },
21
- "keywords": [
22
- "adb",
23
- "android",
24
- "uninstall",
25
- "cli",
26
- "debloat"
27
- ],
28
- "author": "",
29
- "license": "MIT",
30
- "dependencies": {
31
- "@inquirer/checkbox": "^4.0.2",
32
- "@inquirer/confirm": "^5.1.6",
33
- "blessed": "^0.1.81",
34
- "boxen": "^8.0.1",
35
- "chalk": "^5.4.1",
36
- "gradient-string": "^3.0.0",
37
- "ora": "^8.2.0"
38
- },
39
- "engines": {
40
- "node": ">=18.0.0"
41
- },
42
- "repository": {
43
- "type": "git",
44
- "url": "git+https://github.com/duyxyz/saca.git"
45
- },
46
- "homepage": "https://github.com/duyxyz/saca#readme",
47
- "bugs": {
48
- "url": "https://github.com/duyxyz/saca/issues"
49
- }
50
- }
1
+ {
2
+ "name": "@duyxyz/saca",
3
+ "version": "1.0.4",
4
+ "description": "Interactive CLI tool to list and uninstall Android apps via ADB",
5
+ "type": "module",
6
+ "bin": {
7
+ "saca": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "README.md",
13
+ "LICENSE",
14
+ "adb.exe",
15
+ "AdbWinApi.dll",
16
+ "AdbWinUsbApi.dll"
17
+ ],
18
+ "scripts": {
19
+ "start": "node bin/cli.js"
20
+ },
21
+ "keywords": [
22
+ "adb",
23
+ "android",
24
+ "uninstall",
25
+ "cli",
26
+ "debloat"
27
+ ],
28
+ "author": "",
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@inquirer/checkbox": "^4.0.2",
32
+ "@inquirer/confirm": "^5.1.6",
33
+ "blessed": "^0.1.81",
34
+ "boxen": "^8.0.1",
35
+ "chalk": "^5.4.1",
36
+ "gradient-string": "^3.0.0",
37
+ "ora": "^8.2.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=18.0.0"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/duyxyz/saca.git"
45
+ },
46
+ "homepage": "https://github.com/duyxyz/saca#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/duyxyz/saca/issues"
49
+ }
50
+ }