jerkjs 2.1.7 → 2.3.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +204 -4
  3. package/README_EN.md +1 -1
  4. package/README_PT.md +1 -1
  5. package/docs/ARQUITECTURA_ROUTES.md +84 -38
  6. package/{JERK_FRAMEWORK_DOCUMENTATION.md → docs/JERK_FRAMEWORK_DOCUMENTATION.md} +28 -2
  7. package/docs/JERK_MODELOS_HOWTO.md +566 -0
  8. package/index.js +41 -5
  9. package/jerk-qbuilder/CHANGELOG.md +71 -0
  10. package/jerk-qbuilder/HOWTO.md +325 -0
  11. package/jerk-qbuilder/README.md +52 -0
  12. package/lib/core/server.js +328 -27
  13. package/lib/loader/routeLoader.js +148 -117
  14. package/lib/mvc/GenericAdapter.js +136 -0
  15. package/lib/mvc/MariaDBAdapter.js +315 -0
  16. package/lib/mvc/MemoryAdapter.js +269 -0
  17. package/lib/mvc/ModelControllerExample.js +285 -0
  18. package/lib/mvc/controllerBase.js +77 -0
  19. package/lib/mvc/modelBase.js +383 -0
  20. package/lib/mvc/modelManager.js +284 -0
  21. package/lib/mvc/userModel.js +265 -0
  22. package/lib/mvc/viewEngine.js +32 -1
  23. package/lib/query/MariaDBAdapter.js +78 -0
  24. package/lib/query/consoleAdapter.js +184 -0
  25. package/lib/query/queryBuilder.js +953 -0
  26. package/lib/query/queryBuilderHooks.js +455 -0
  27. package/lib/query/queryBuilderMiddleware.js +332 -0
  28. package/lib/utils/mimeType.js +62 -0
  29. package/package.json +5 -3
  30. package/utils/find_file_path.sh +36 -0
  31. package/BUG_REPORTE_COMPRESION.txt +0 -72
  32. package/standard/CompressionTestController.js +0 -56
  33. package/standard/HealthController.js +0 -16
  34. package/standard/HomeController.js +0 -12
  35. package/standard/ProductController.js +0 -18
  36. package/standard/README.md +0 -47
  37. package/standard/UserController.js +0 -23
  38. package/standard/package.json +0 -22
  39. package/standard/routes.json +0 -65
  40. package/standard/server.js +0 -140
  41. package/standardA/controllers/AuthController.js +0 -82
  42. package/standardA/controllers/HomeController.js +0 -19
  43. package/standardA/controllers/UserController.js +0 -41
  44. package/standardA/server.js +0 -311
  45. package/standardA/views/auth/dashboard.html +0 -51
  46. package/standardA/views/auth/login.html +0 -47
  47. package/standardA/views/index.html +0 -32
  48. package/standardA/views/users/detail.html +0 -28
  49. package/standardA/views/users/list.html +0 -36
  50. /package/{JERK_FRAMEWORK_DIAGRAM.txt → docs/JERK_FRAMEWORK_DIAGRAM.txt} +0 -0
  51. /package/{JERK_FRAMEWORK_DIAGRAM_MERMAID.mmd → docs/JERK_FRAMEWORK_DIAGRAM_MERMAID.mmd} +0 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Modelo de ejemplo para usuarios en el framework JERK
3
+ * Implementación del componente MVC userModel.js
4
+ * Extiende ModelBase para aprovechar todas las funcionalidades
5
+ */
6
+
7
+ const ModelBase = require('./modelBase');
8
+
9
+ class UserModel extends ModelBase {
10
+ /**
11
+ * Constructor del modelo de usuario
12
+ * @param {Object} options - Opciones de configuración del modelo
13
+ */
14
+ constructor(options = {}) {
15
+ super({
16
+ ...options,
17
+ tableName: options.tableName || 'users'
18
+ });
19
+
20
+ // Definir campos del modelo
21
+ this.fields = {
22
+ id: { type: 'integer', primaryKey: true, autoIncrement: true },
23
+ username: { type: 'string', required: true, unique: true },
24
+ email: { type: 'string', required: true, unique: true },
25
+ password: { type: 'string', required: true },
26
+ firstName: { type: 'string' },
27
+ lastName: { type: 'string' },
28
+ createdAt: { type: 'datetime', default: 'CURRENT_TIMESTAMP' },
29
+ updatedAt: { type: 'datetime', default: 'CURRENT_TIMESTAMP' }
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Valida los datos del usuario antes de guardar
35
+ * @param {string} operation - Operación a realizar
36
+ * @param {Object} data - Datos a validar
37
+ * @returns {Object} - Resultado de la validación
38
+ */
39
+ validate(operation, data) {
40
+ const result = { isValid: true, errors: [] };
41
+
42
+ // Validaciones específicas para creación
43
+ if (operation === 'create') {
44
+ if (!data.username) {
45
+ result.isValid = false;
46
+ result.errors.push('Username es requerido');
47
+ }
48
+
49
+ if (!data.email) {
50
+ result.isValid = false;
51
+ result.errors.push('Email es requerido');
52
+ }
53
+
54
+ if (!data.password) {
55
+ result.isValid = false;
56
+ result.errors.push('Password es requerido');
57
+ }
58
+ }
59
+
60
+ // Validaciones para actualización
61
+ if (operation === 'update') {
62
+ // Validar solo los campos que se están actualizando
63
+ if (data.hasOwnProperty('username') && !data.username) {
64
+ result.isValid = false;
65
+ result.errors.push('Username no puede estar vacío si se proporciona');
66
+ }
67
+
68
+ if (data.hasOwnProperty('email') && !data.email) {
69
+ result.isValid = false;
70
+ result.errors.push('Email no puede estar vacío si se proporciona');
71
+ }
72
+
73
+ if (data.hasOwnProperty('password') && !data.password) {
74
+ result.isValid = false;
75
+ result.errors.push('Password no puede estar vacío si se proporciona');
76
+ }
77
+
78
+ // Validar formato de email si se está actualizando
79
+ if (data.email && !this.isValidEmail(data.email)) {
80
+ result.isValid = false;
81
+ result.errors.push('Email no tiene un formato válido');
82
+ }
83
+ }
84
+
85
+ // Validaciones comunes para creación y actualización
86
+ if (operation === 'create' || operation === 'update') {
87
+ // Validar formato de email
88
+ if (data.email && !this.isValidEmail(data.email)) {
89
+ result.isValid = false;
90
+ result.errors.push('Email no tiene un formato válido');
91
+ }
92
+ }
93
+
94
+ // Disparar hook para validación
95
+ if (this.hooks) {
96
+ const validationResult = this.hooks.applyFilters(
97
+ 'user_model_validate',
98
+ result,
99
+ operation,
100
+ data
101
+ );
102
+
103
+ return validationResult;
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Verifica si un email tiene formato válido
111
+ * @param {string} email - Email a validar
112
+ * @returns {boolean} - Verdadero si el email es válido
113
+ */
114
+ isValidEmail(email) {
115
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
116
+ return emailRegex.test(email);
117
+ }
118
+
119
+ /**
120
+ * Busca un usuario por su nombre de usuario
121
+ * @param {string} username - Nombre de usuario a buscar
122
+ * @returns {Promise<Object|null>} - Usuario encontrado o null
123
+ */
124
+ async findByUsername(username) {
125
+ return await this.findOne({ username });
126
+ }
127
+
128
+ /**
129
+ * Busca un usuario por su email
130
+ * @param {string} email - Email a buscar
131
+ * @returns {Promise<Object|null>} - Usuario encontrado o null
132
+ */
133
+ async findByEmail(email) {
134
+ return await this.findOne({ email });
135
+ }
136
+
137
+ /**
138
+ * Crea un nuevo usuario con validación
139
+ * @param {Object} userData - Datos del usuario a crear
140
+ * @returns {Promise<Object>} - Usuario creado
141
+ */
142
+ async createUser(userData) {
143
+ // Validar datos antes de crear
144
+ const validation = this.validate('create', userData);
145
+
146
+ if (!validation.isValid) {
147
+ throw new Error(`Validación fallida: ${validation.errors.join(', ')}`);
148
+ }
149
+
150
+ // Encriptar contraseña antes de guardar
151
+ if (userData.password) {
152
+ userData.password = await this.hashPassword(userData.password);
153
+ }
154
+
155
+ return await this.create(userData);
156
+ }
157
+
158
+ /**
159
+ * Actualiza un usuario con validación
160
+ * @param {Object} conditions - Condiciones para encontrar el usuario
161
+ * @param {Object} userData - Datos del usuario a actualizar
162
+ * @returns {Promise<number>} - Número de filas afectadas
163
+ */
164
+ async updateUser(conditions, userData) {
165
+ // Validar solo los campos que se están actualizando
166
+ const fieldsToUpdate = {};
167
+ for (const [key, value] of Object.entries(userData)) {
168
+ if (value !== undefined && value !== null) {
169
+ fieldsToUpdate[key] = value;
170
+ }
171
+ }
172
+
173
+ // Validar datos antes de actualizar
174
+ const validation = this.validate('update', fieldsToUpdate);
175
+
176
+ if (!validation.isValid) {
177
+ throw new Error(`Validación fallida: ${validation.errors.join(', ')}`);
178
+ }
179
+
180
+ // Encriptar contraseña si se proporciona
181
+ if (fieldsToUpdate.password) {
182
+ fieldsToUpdate.password = await this.hashPassword(fieldsToUpdate.password);
183
+ }
184
+
185
+ return await this.update(conditions, fieldsToUpdate);
186
+ }
187
+
188
+ /**
189
+ * Encripta una contraseña
190
+ * @param {string} password - Contraseña a encriptar
191
+ * @returns {Promise<string>} - Contraseña encriptada
192
+ */
193
+ async hashPassword(password) {
194
+ // Importar bcrypt dinámicamente
195
+ const bcrypt = require('bcrypt');
196
+ const saltRounds = 10;
197
+ return await bcrypt.hash(password, saltRounds);
198
+ }
199
+
200
+ /**
201
+ * Verifica si una contraseña coincide con el hash
202
+ * @param {string} password - Contraseña sin encriptar
203
+ * @param {string} hashedPassword - Contraseña encriptada
204
+ * @returns {Promise<boolean>} - Verdadero si coinciden
205
+ */
206
+ async verifyPassword(password, hashedPassword) {
207
+ // Importar bcrypt dinámicamente
208
+ const bcrypt = require('bcrypt');
209
+ return await bcrypt.compare(password, hashedPassword);
210
+ }
211
+
212
+ /**
213
+ * Obtiene usuarios con información paginada
214
+ * @param {Object} options - Opciones de paginación
215
+ * @param {number} options.page - Página a obtener (por defecto 1)
216
+ * @param {number} options.limit - Límite de resultados por página (por defecto 10)
217
+ * @param {Object} conditions - Condiciones de búsqueda
218
+ * @returns {Promise<Object>} - Resultados con información de paginación
219
+ */
220
+ async getUsersPaginated(options = {}, conditions = {}) {
221
+ const { page = 1, limit = 10 } = options;
222
+ const offset = (page - 1) * limit;
223
+
224
+ // Obtener los usuarios para la página solicitada
225
+ const users = await this.find(conditions, {
226
+ limit,
227
+ offset,
228
+ orderBy: 'createdAt DESC'
229
+ });
230
+
231
+ // Contar el total de usuarios
232
+ const totalCount = await this.count(conditions);
233
+
234
+ // Calcular información de paginación
235
+ const totalPages = Math.ceil(totalCount / limit);
236
+
237
+ return {
238
+ data: users,
239
+ pagination: {
240
+ currentPage: page,
241
+ totalPages,
242
+ totalItems: totalCount,
243
+ itemsPerPage: limit,
244
+ hasNextPage: page < totalPages,
245
+ hasPrevPage: page > 1
246
+ }
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Serializa el modelo de usuario a JSON
252
+ * @returns {Object} - Representación JSON del modelo
253
+ */
254
+ toJSON() {
255
+ const baseJson = super.toJSON();
256
+ return {
257
+ ...baseJson,
258
+ modelName: this.constructor.name,
259
+ tableName: this.tableName,
260
+ fieldCount: Object.keys(this.fields).length
261
+ };
262
+ }
263
+ }
264
+
265
+ module.exports = UserModel;
@@ -215,7 +215,7 @@ class ViewEngine {
215
215
  render(viewName, data = {}, options = {}) {
216
216
  // Obtener la ruta completa de la vista
217
217
  const viewPath = this.getViewPath(viewName);
218
-
218
+
219
219
  // Verificar si la vista existe
220
220
  if (!fs.existsSync(viewPath)) {
221
221
  throw new Error(`Vista no encontrada: ${viewPath}`);
@@ -238,6 +238,37 @@ class ViewEngine {
238
238
  }
239
239
  }
240
240
 
241
+ // Si es una vista de contenido (no un layout), procesarla y usarla como contenido para el layout
242
+ if (options.layout) {
243
+ // Procesar la vista actual con las variables
244
+ const processedViewContent = this.processTemplate(viewContent, data, options);
245
+
246
+ // Cargar el layout
247
+ const layoutPath = this.getViewPath(options.layout);
248
+ if (fs.existsSync(layoutPath)) {
249
+ let layoutContent = fs.readFileSync(layoutPath, 'utf8');
250
+
251
+ // Validar sintaxis del layout si está habilitado
252
+ if (options.validateSyntax !== false) {
253
+ const layoutValidationErrors = ViewEngine.validateTemplate(layoutContent);
254
+ if (layoutValidationErrors.length > 0) {
255
+ this.logger.warn(`Errores de sintaxis en el layout ${options.layout}:`, layoutValidationErrors);
256
+ }
257
+ }
258
+
259
+ // Reemplazar el placeholder {{content}} con el contenido procesado
260
+ layoutContent = layoutContent.replace(/\{\{content\}\}/g, processedViewContent);
261
+
262
+ // Procesar el layout con las variables
263
+ layoutContent = this.processTemplate(layoutContent, data, options);
264
+
265
+ return layoutContent;
266
+ } else {
267
+ // Si no existe el layout, devolver solo la vista procesada
268
+ return this.processTemplate(viewContent, data, options);
269
+ }
270
+ }
271
+
241
272
  // Procesar bloques de inclusión (similar a <?php include ?>)
242
273
  viewContent = this.processIncludes(viewContent, path.dirname(viewPath));
243
274
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Adaptador de MariaDB para el QueryBuilder
3
+ * MariaDBAdapter.js
4
+ *
5
+ * Este adaptador simplemente ejecuta las consultas SQL construidas por el QueryBuilder
6
+ */
7
+
8
+ const mariadb = require('mariadb');
9
+
10
+ class MariaDBAdapter {
11
+ /**
12
+ * Constructor del adaptador MariaDB
13
+ * @param {Object} options - Opciones de configuración
14
+ */
15
+ constructor(options = {}) {
16
+ // Configuración de la conexión a la base de datos
17
+ this.dbConfig = {
18
+ host: options.host || 'localhost',
19
+ user: options.user || 'root',
20
+ password: options.password || '',
21
+ database: options.database || 'otrack_db',
22
+ waitForConnections: options.waitForConnections !== false,
23
+ connectionLimit: options.connectionLimit || 10,
24
+ queueLimit: options.queueLimit || 0,
25
+ acquireTimeout: options.acquireTimeout || 60000,
26
+ timeout: options.timeout || 60000,
27
+ ...options
28
+ };
29
+
30
+ // Crear pool de conexiones
31
+ this.pool = mariadb.createPool(this.dbConfig);
32
+ }
33
+
34
+ /**
35
+ * Método para ejecutar una consulta SQL
36
+ * @param {string} sql - Consulta SQL construida por el QueryBuilder
37
+ * @param {Array} params - Parámetros de la consulta
38
+ * @returns {Promise<Object>} - Resultado de la consulta
39
+ */
40
+ async query(sql, params = []) {
41
+ const connection = await this.pool.getConnection();
42
+ try {
43
+ return await connection.query(sql, params);
44
+ } catch (error) {
45
+ throw error;
46
+ } finally {
47
+ connection.release();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Método para cerrar la conexión
53
+ */
54
+ async close() {
55
+ try {
56
+ await this.pool.end();
57
+ console.log('Conexión a MariaDB cerrada correctamente');
58
+ } catch (error) {
59
+ console.error('Error al cerrar la conexión a MariaDB:', error.message);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Método para verificar si el adaptador está conectado
65
+ * @returns {Promise<boolean>} - Verdadero si está conectado
66
+ */
67
+ async isConnected() {
68
+ try {
69
+ const connection = await this.pool.getConnection();
70
+ connection.release();
71
+ return true;
72
+ } catch (error) {
73
+ return false;
74
+ }
75
+ }
76
+ }
77
+
78
+ module.exports = MariaDBAdapter;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Adaptador de ejemplo para mostrar consultas SQL por consola
3
+ * ConsoleAdapter.js
4
+ */
5
+
6
+ class ConsoleAdapter {
7
+ constructor(options = {}) {
8
+ this.options = options;
9
+ this.queriesExecuted = 0;
10
+ }
11
+
12
+ /**
13
+ * Método para ejecutar una consulta
14
+ * @param {string} sql - Consulta SQL
15
+ * @param {Array} params - Parámetros de la consulta
16
+ * @returns {Promise<Object>} - Resultado simulado
17
+ */
18
+ async query(sql, params = []) {
19
+ this.queriesExecuted++;
20
+
21
+ console.log(`\n--- CONSULTA #${this.queriesExecuted} ---`);
22
+ console.log(`SQL: ${sql}`);
23
+ console.log(`Parámetros:`, params);
24
+ console.log('--- FIN CONSULTA ---\n');
25
+
26
+ // Simular un resultado dependiendo del tipo de consulta
27
+ if (sql.trim().toUpperCase().startsWith('SELECT')) {
28
+ // Para consultas SELECT, devolver un array vacío o con datos simulados
29
+ return [];
30
+ } else if (sql.trim().toUpperCase().startsWith('INSERT')) {
31
+ // Para INSERT, simular un ID insertado
32
+ return { insertId: Math.floor(Math.random() * 10000), affectedRows: 1 };
33
+ } else if (sql.trim().toUpperCase().startsWith('UPDATE') || sql.trim().toUpperCase().startsWith('DELETE')) {
34
+ // Para UPDATE/DELETE, simular filas afectadas
35
+ return { affectedRows: Math.floor(Math.random() * 5) + 1 };
36
+ } else {
37
+ // Para otros tipos de consulta, devolver un objeto vacío
38
+ return {};
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Método para crear un registro
44
+ * @param {string} tableName - Nombre de la tabla
45
+ * @param {Object} data - Datos a insertar
46
+ * @returns {Promise<Object>} - Resultado simulado
47
+ */
48
+ async create(tableName, data) {
49
+ const columns = Object.keys(data).join(', ');
50
+ const placeholders = Object.keys(data).map(() => '?').join(', ');
51
+ const sql = `INSERT INTO \`${tableName}\` (${columns}) VALUES (${placeholders})`;
52
+ const params = Object.values(data);
53
+
54
+ return await this.query(sql, params);
55
+ }
56
+
57
+ /**
58
+ * Método para encontrar registros
59
+ * @param {string} tableName - Nombre de la tabla
60
+ * @param {Object} conditions - Condiciones de búsqueda
61
+ * @param {Object} options - Opciones adicionales
62
+ * @returns {Promise<Array>} - Resultados simulados
63
+ */
64
+ async find(tableName, conditions, options = {}) {
65
+ let sql = `SELECT * FROM \`${tableName}\``;
66
+ const params = [];
67
+
68
+ if (conditions && Object.keys(conditions).length > 0) {
69
+ const conditionsList = [];
70
+ for (const [key, value] of Object.entries(conditions)) {
71
+ if (value !== undefined && value !== null) {
72
+ conditionsList.push(`${key} = ?`);
73
+ params.push(value);
74
+ }
75
+ }
76
+ if (conditionsList.length > 0) {
77
+ sql += ' WHERE ' + conditionsList.join(' AND ');
78
+ }
79
+ }
80
+
81
+ return await this.query(sql, params);
82
+ }
83
+
84
+ /**
85
+ * Método para encontrar un solo registro
86
+ * @param {string} tableName - Nombre de la tabla
87
+ * @param {Object} conditions - Condiciones de búsqueda
88
+ * @param {Object} options - Opciones adicionales
89
+ * @returns {Promise<Object|null>} - Resultado simulado o null
90
+ */
91
+ async findOne(tableName, conditions, options = {}) {
92
+ const results = await this.find(tableName, conditions, { ...options, limit: 1 });
93
+ return results.length > 0 ? results[0] : null;
94
+ }
95
+
96
+ /**
97
+ * Método para actualizar registros
98
+ * @param {string} tableName - Nombre de la tabla
99
+ * @param {Object} conditions - Condiciones para seleccionar registros
100
+ * @param {Object} data - Datos a actualizar
101
+ * @returns {Promise<number>} - Número de filas afectadas simuladas
102
+ */
103
+ async update(tableName, conditions, data) {
104
+ const dataEntries = Object.entries(data);
105
+ if (dataEntries.length === 0) {
106
+ return 0;
107
+ }
108
+
109
+ const setClause = dataEntries.map(([key]) => `${key} = ?`).join(', ');
110
+ const params = dataEntries.map(([, value]) => value);
111
+
112
+ const whereClause = [];
113
+ for (const [key, value] of Object.entries(conditions)) {
114
+ if (value !== undefined && value !== null) {
115
+ whereClause.push(`${key} = ?`);
116
+ params.push(value);
117
+ }
118
+ }
119
+
120
+ let sql = `UPDATE \`${tableName}\` SET ${setClause}`;
121
+ if (whereClause.length > 0) {
122
+ sql += ' WHERE ' + whereClause.join(' AND ');
123
+ }
124
+
125
+ const result = await this.query(sql, params);
126
+ return result.affectedRows || 0;
127
+ }
128
+
129
+ /**
130
+ * Método para eliminar registros
131
+ * @param {string} tableName - Nombre de la tabla
132
+ * @param {Object} conditions - Condiciones para seleccionar registros
133
+ * @returns {Promise<number>} - Número de filas afectadas simuladas
134
+ */
135
+ async delete(tableName, conditions) {
136
+ let sql = `DELETE FROM \`${tableName}\``;
137
+ const params = [];
138
+
139
+ if (conditions && Object.keys(conditions).length > 0) {
140
+ const conditionsList = [];
141
+ for (const [key, value] of Object.entries(conditions)) {
142
+ if (value !== undefined && value !== null) {
143
+ conditionsList.push(`${key} = ?`);
144
+ params.push(value);
145
+ }
146
+ }
147
+ if (conditionsList.length > 0) {
148
+ sql += ' WHERE ' + conditionsList.join(' AND ');
149
+ }
150
+ }
151
+
152
+ const result = await this.query(sql, params);
153
+ return result.affectedRows || 0;
154
+ }
155
+
156
+ /**
157
+ * Método para contar registros
158
+ * @param {string} tableName - Nombre de la tabla
159
+ * @param {Object} conditions - Condiciones de conteo
160
+ * @returns {Promise<number>} - Número simulado de registros
161
+ */
162
+ async count(tableName, conditions) {
163
+ let sql = `SELECT COUNT(*) as total FROM \`${tableName}\``;
164
+ const params = [];
165
+
166
+ if (conditions && Object.keys(conditions).length > 0) {
167
+ const conditionsList = [];
168
+ for (const [key, value] of Object.entries(conditions)) {
169
+ if (value !== undefined && value !== null) {
170
+ conditionsList.push(`${key} = ?`);
171
+ params.push(value);
172
+ }
173
+ }
174
+ if (conditionsList.length > 0) {
175
+ sql += ' WHERE ' + conditionsList.join(' AND ');
176
+ }
177
+ }
178
+
179
+ const result = await this.query(sql, params);
180
+ return result[0] ? result[0].total : 0;
181
+ }
182
+ }
183
+
184
+ module.exports = ConsoleAdapter;