All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 36s
650 lines
21 KiB
TypeScript
650 lines
21 KiB
TypeScript
// MCP Server para PrinterCentral
|
||
// Permite a agentes de IA gestionar impresoras y templates
|
||
|
||
import { getAllTemplates, getTemplateById, createTemplate, updateTemplate, resolveVariables } from './templates'
|
||
import type { Operation } from './templates'
|
||
import { getAllPrinters, getSelectedPrinter, getPrinterById } from './printers'
|
||
import { buildFromOperations } from './eposBuilder'
|
||
import { buildSoapEnvelope, sendToPrinter, parsePrinterResponse } from './printer'
|
||
|
||
// Guías de impresión por modelo
|
||
export const PRINTING_GUIDES: Record<string, {
|
||
model: string
|
||
type: string
|
||
columns: number
|
||
columnsDoubleWidth: number
|
||
supportedChars: string
|
||
charsetDetails?: {
|
||
supported: string[]
|
||
notSupported: string[]
|
||
}
|
||
rules: string[]
|
||
tips?: string[]
|
||
operations: string[]
|
||
example: string
|
||
}> = {
|
||
'TM-U220': {
|
||
model: 'Epson TM-U220',
|
||
type: 'Matricial (impact)',
|
||
columns: 40,
|
||
columnsDoubleWidth: 20,
|
||
supportedChars: 'Latin-1 extendido + Box Drawing + Bloques. NO emojis ni flechas Unicode.',
|
||
charsetDetails: {
|
||
supported: [
|
||
'ASCII completo: A-Z a-z 0-9 = - _ * + | / \\ ( ) [ ] < > " \' ` @ # % & ^ ~',
|
||
'Box Drawing Doble: ╔ ╗ ╚ ╝ ║ ═',
|
||
'Box Drawing Simple: ┌ ┐ └ ┘ │ ─ ├ ┤ ┬ ┴ ┼',
|
||
'Bloques y Sombras: █ ▓ ▒ ░ ▀ ▄',
|
||
'Latinos extendidos: ñ Ñ á é í ó ú Á É Í Ó Ú ¿ ¡ ç',
|
||
'Monedas: $ € £ ¥',
|
||
'Matemáticos: ± × ÷ ≤ ≥ ∞'
|
||
],
|
||
notSupported: [
|
||
'Flechas Unicode: → ← ↑ ↓ ↔ ⇒ ⇐',
|
||
'Estrellas/Círculos: ★ ☆ ● ○ ◆ ◇',
|
||
'Emojis (todos): 😀 ❤ ✓ ✗ ☀ ☁ ✔ ✘',
|
||
'Específicos: ₿ (Bitcoin) ≠ (Distinto)'
|
||
]
|
||
},
|
||
rules: [
|
||
'Máximo 40 caracteres por línea (20 con width:2)',
|
||
'Siempre usar feed entre elementos para legibilidad',
|
||
'feed:1 entre items, feed:2 entre secciones',
|
||
'feed:4 antes del cut final',
|
||
'Los headers con width:2 deben ser max 20 chars',
|
||
'Docs largos pueden dar EX_TIMEOUT pero se imprimen igual'
|
||
],
|
||
tips: [
|
||
'TABLAS: Usar box drawing ┌─┬─┐ │ ├─┼─┤ └─┴─┘ para tablas legibles',
|
||
'BARRAS PROGRESO: Combinar █ y ░ ej: [█████░░░░░]',
|
||
'CAJAS: Box doble ╔═╗ ║ ╚═╝ para destacar contenido importante',
|
||
'BULLETS: Usar * - + > o [*] [X] en lugar de ✓ ✗',
|
||
'ASCII ART: Funciona perfecto con caracteres básicos'
|
||
],
|
||
operations: [
|
||
'{ op: "text", value: "texto" } - Imprime texto',
|
||
'{ op: "feed", lines: N } - Avanza N líneas',
|
||
'{ op: "cut" } - Corta el papel',
|
||
'{ op: "align", align: "left|center|right" } - Alineación',
|
||
'{ op: "style", bold: true, underline: true, width: 1-2, height: 1-2 } - Estilos'
|
||
],
|
||
example: `[
|
||
{ "op": "align", "align": "center" },
|
||
{ "op": "style", "bold": true, "width": 2, "height": 2 },
|
||
{ "op": "text", "value": "TITULO" },
|
||
{ "op": "style", "bold": false, "width": 1, "height": 1 },
|
||
{ "op": "feed", "lines": 2 },
|
||
{ "op": "text", "value": "╔══════════════════════════════════════╗" },
|
||
{ "op": "text", "value": "║ Contenido importante aquí ║" },
|
||
{ "op": "text", "value": "╚══════════════════════════════════════╝" },
|
||
{ "op": "feed", "lines": 1 },
|
||
{ "op": "text", "value": "┌────────┬────────┬────────┐" },
|
||
{ "op": "text", "value": "│ COL 1 │ COL 2 │ COL 3 │" },
|
||
{ "op": "text", "value": "├────────┼────────┼────────┤" },
|
||
{ "op": "text", "value": "│ dato │ dato │ dato │" },
|
||
{ "op": "text", "value": "└────────┴────────┴────────┘" },
|
||
{ "op": "feed", "lines": 1 },
|
||
{ "op": "text", "value": "Progreso: [██████████░░░░░░░░░░] 50%" },
|
||
{ "op": "feed", "lines": 4 },
|
||
{ "op": "cut" }
|
||
]`
|
||
},
|
||
'TM-T20II': {
|
||
model: 'Epson TM-T20II',
|
||
type: 'Térmica',
|
||
columns: 48,
|
||
columnsDoubleWidth: 24,
|
||
supportedChars: 'ASCII extendido, algunos caracteres Unicode soportados',
|
||
rules: [
|
||
'Máximo 48 caracteres por línea (24 con width:2)',
|
||
'Soporta más caracteres que matriciales',
|
||
'Impresión rápida y silenciosa',
|
||
'Usar feed para espaciado',
|
||
'Soporta códigos QR y de barras',
|
||
'Ideal para recibos y tickets'
|
||
],
|
||
operations: [
|
||
'{ op: "text", value: "texto" } - Imprime texto',
|
||
'{ op: "feed", lines: N } - Avanza N líneas',
|
||
'{ op: "cut" } - Corta el papel',
|
||
'{ op: "align", align: "left|center|right" } - Alineación',
|
||
'{ op: "style", bold: true, underline: true, width: 1-2, height: 1-2 } - Estilos',
|
||
'{ op: "qrcode", data: "texto", size: 4 } - Código QR',
|
||
'{ op: "barcode", data: "123456", type: "ean13" } - Código de barras'
|
||
],
|
||
example: `[
|
||
{ "op": "align", "align": "center" },
|
||
{ "op": "style", "bold": true, "width": 2, "height": 2 },
|
||
{ "op": "text", "value": "RECIBO" },
|
||
{ "op": "style", "bold": false, "width": 1, "height": 1 },
|
||
{ "op": "feed", "lines": 1 },
|
||
{ "op": "text", "value": "================================================" },
|
||
{ "op": "feed", "lines": 1 },
|
||
{ "op": "align", "align": "left" },
|
||
{ "op": "text", "value": "Contenido aquí" },
|
||
{ "op": "feed", "lines": 2 },
|
||
{ "op": "qrcode", "data": "https://example.com", "size": 4 },
|
||
{ "op": "feed", "lines": 4 },
|
||
{ "op": "cut" }
|
||
]`
|
||
},
|
||
'TM-T88': {
|
||
model: 'Epson TM-T88 (series)',
|
||
type: 'Térmica',
|
||
columns: 48,
|
||
columnsDoubleWidth: 24,
|
||
supportedChars: 'ASCII extendido, algunos caracteres Unicode soportados',
|
||
rules: [
|
||
'Máximo 48 caracteres por línea (24 con width:2)',
|
||
'Soporta más caracteres que matriciales',
|
||
'Impresión más rápida y silenciosa',
|
||
'Usar feed para espaciado',
|
||
'Soporta imágenes y códigos QR'
|
||
],
|
||
operations: [
|
||
'{ op: "text", value: "texto" } - Imprime texto',
|
||
'{ op: "feed", lines: N } - Avanza N líneas',
|
||
'{ op: "cut" } - Corta el papel',
|
||
'{ op: "align", align: "left|center|right" } - Alineación',
|
||
'{ op: "style", bold: true, underline: true, width: 1-2, height: 1-2 } - Estilos',
|
||
'{ op: "qrcode", data: "texto", size: 4 } - Código QR',
|
||
'{ op: "barcode", data: "123456", type: "ean13" } - Código de barras'
|
||
],
|
||
example: `[
|
||
{ "op": "align", "align": "center" },
|
||
{ "op": "style", "bold": true, "width": 2, "height": 2 },
|
||
{ "op": "text", "value": "RECIBO" },
|
||
{ "op": "style", "bold": false, "width": 1, "height": 1 },
|
||
{ "op": "feed", "lines": 1 },
|
||
{ "op": "qrcode", "data": "https://example.com", "size": 4 },
|
||
{ "op": "feed", "lines": 2 },
|
||
{ "op": "cut" }
|
||
]`
|
||
}
|
||
}
|
||
|
||
// Obtener guía por modelo (búsqueda flexible)
|
||
export function getPrintingGuide(model: string): typeof PRINTING_GUIDES[string] | null {
|
||
// Búsqueda exacta
|
||
if (PRINTING_GUIDES[model]) return PRINTING_GUIDES[model]
|
||
|
||
// Búsqueda parcial (case-insensitive)
|
||
const modelLower = model.toLowerCase()
|
||
for (const [key, guide] of Object.entries(PRINTING_GUIDES)) {
|
||
if (key.toLowerCase().includes(modelLower) || modelLower.includes(key.toLowerCase())) {
|
||
return guide
|
||
}
|
||
}
|
||
|
||
// Default a TM-U220 si no se encuentra
|
||
return PRINTING_GUIDES['TM-U220']
|
||
}
|
||
|
||
// Definición de las tools MCP
|
||
export const MCP_TOOLS = [
|
||
{
|
||
name: 'printercentral_list_templates',
|
||
description: 'Lista todos los templates de impresión disponibles. Retorna id, nombre, descripción (con instrucciones de uso) y variables requeridas para cada template.',
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {},
|
||
required: [] as string[]
|
||
}
|
||
},
|
||
{
|
||
name: 'printercentral_list_printers',
|
||
description: 'Lista todas las impresoras configuradas. Retorna id, nombre, host e indica cuál es la impresora por defecto.',
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {},
|
||
required: [] as string[]
|
||
}
|
||
},
|
||
{
|
||
name: 'printercentral_print_template',
|
||
description: 'Imprime un template guardado con variables resueltas. Usa list_templates primero para ver los templates disponibles y sus variables.',
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {
|
||
templateId: {
|
||
type: 'string',
|
||
description: 'ID del template a imprimir'
|
||
},
|
||
variables: {
|
||
type: 'object',
|
||
description: 'Objeto con las variables a resolver. Las claves son los nombres de las variables definidas en el template.',
|
||
additionalProperties: { type: 'string' }
|
||
},
|
||
printerId: {
|
||
type: 'string',
|
||
description: 'ID de la impresora a usar. Si no se especifica, usa la impresora por defecto.'
|
||
}
|
||
},
|
||
required: ['templateId']
|
||
}
|
||
},
|
||
{
|
||
name: 'printercentral_print_raw',
|
||
description: `Imprime operaciones ePOS directamente. Útil para impresiones personalizadas sin crear un template.
|
||
|
||
FORMATO DE OPERACIONES (para impresora TM-U220, max 40 caracteres por línea):
|
||
- { op: "text", value: "texto" } - Imprime texto
|
||
- { op: "feed", lines: N } - Avanza N líneas (usar entre elementos para legibilidad)
|
||
- { op: "cut" } - Corta el papel
|
||
- { op: "align", align: "left|center|right" } - Alinea el texto
|
||
- { op: "style", bold: true/false, underline: true/false, width: 1-2, height: 1-2 } - Estilo de texto
|
||
|
||
EJEMPLO:
|
||
[
|
||
{ "op": "align", "align": "center" },
|
||
{ "op": "style", "bold": true, "width": 2, "height": 2 },
|
||
{ "op": "text", "value": "TITULO" },
|
||
{ "op": "style", "bold": false, "width": 1, "height": 1 },
|
||
{ "op": "feed", "lines": 2 },
|
||
{ "op": "text", "value": "Contenido aquí" },
|
||
{ "op": "feed", "lines": 4 },
|
||
{ "op": "cut" }
|
||
]
|
||
|
||
REGLAS IMPORTANTES:
|
||
- Max 40 caracteres por línea (20 con width:2)
|
||
- Usar solo caracteres ASCII (evitar unicode especial)
|
||
- Siempre terminar con feed y cut`,
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {
|
||
operations: {
|
||
type: 'array',
|
||
description: 'Array de operaciones ePOS a ejecutar',
|
||
items: { type: 'object' }
|
||
},
|
||
variables: {
|
||
type: 'object',
|
||
description: 'Variables a resolver en el texto. Usa sintaxis {{nombreVariable}} en los valores de text.',
|
||
additionalProperties: { type: 'string' }
|
||
},
|
||
printerId: {
|
||
type: 'string',
|
||
description: 'ID de la impresora a usar. Si no se especifica, usa la impresora por defecto.'
|
||
}
|
||
},
|
||
required: ['operations']
|
||
}
|
||
},
|
||
{
|
||
name: 'printercentral_create_template',
|
||
description: 'Crea un nuevo template de impresión. El template queda guardado para uso futuro.',
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {
|
||
name: {
|
||
type: 'string',
|
||
description: 'Nombre del template'
|
||
},
|
||
description: {
|
||
type: 'string',
|
||
description: 'Descripción del template. IMPORTANTE: Incluir instrucciones de uso y explicación de cada variable para que otros agentes sepan cómo usarlo.'
|
||
},
|
||
operations: {
|
||
type: 'array',
|
||
description: 'Array de operaciones ePOS. Usa {{variable}} para definir variables.',
|
||
items: { type: 'object' }
|
||
}
|
||
},
|
||
required: ['name', 'operations']
|
||
}
|
||
},
|
||
{
|
||
name: 'printercentral_update_template',
|
||
description: 'Actualiza un template existente. Solo se actualizan los campos proporcionados.',
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {
|
||
templateId: {
|
||
type: 'string',
|
||
description: 'ID del template a actualizar'
|
||
},
|
||
name: {
|
||
type: 'string',
|
||
description: 'Nuevo nombre del template'
|
||
},
|
||
description: {
|
||
type: 'string',
|
||
description: 'Nueva descripción del template'
|
||
},
|
||
operations: {
|
||
type: 'array',
|
||
description: 'Nuevas operaciones del template',
|
||
items: { type: 'object' }
|
||
}
|
||
},
|
||
required: ['templateId']
|
||
}
|
||
},
|
||
{
|
||
name: 'printercentral_get_printing_guide',
|
||
description: 'Obtiene la guía de formato para un modelo de impresora específico. Incluye límites de caracteres, reglas de formato, operaciones disponibles y ejemplos.',
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {
|
||
model: {
|
||
type: 'string',
|
||
description: 'Modelo de impresora (ej: "TM-U220", "TM-T88"). Si no se especifica, retorna todas las guías disponibles.'
|
||
}
|
||
},
|
||
required: [] as string[]
|
||
}
|
||
},
|
||
{
|
||
name: 'printercentral_list_models',
|
||
description: 'Lista todos los modelos de impresora soportados con sus características básicas.',
|
||
inputSchema: {
|
||
type: 'object' as const,
|
||
properties: {},
|
||
required: [] as string[]
|
||
}
|
||
}
|
||
]
|
||
|
||
// Handlers para cada tool
|
||
export async function handleToolCall(toolName: string, args: Record<string, any>): Promise<{ content: Array<{ type: string; text: string }> }> {
|
||
switch (toolName) {
|
||
case 'printercentral_list_templates': {
|
||
const templates = await getAllTemplates()
|
||
const result = templates.map(t => ({
|
||
id: t.id,
|
||
name: t.name,
|
||
description: t.description,
|
||
variables: t.variables,
|
||
createdAt: t.createdAt,
|
||
updatedAt: t.updatedAt
|
||
}))
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify(result, null, 2)
|
||
}]
|
||
}
|
||
}
|
||
|
||
case 'printercentral_list_printers': {
|
||
const printers = await getAllPrinters()
|
||
const selected = await getSelectedPrinter()
|
||
const result = printers.map(p => ({
|
||
id: p.id,
|
||
name: p.name,
|
||
host: p.host,
|
||
deviceId: p.deviceId,
|
||
model: p.model || 'No especificado',
|
||
isDefault: p.isDefault,
|
||
isSelected: selected?.id === p.id
|
||
}))
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify(result, null, 2)
|
||
}]
|
||
}
|
||
}
|
||
|
||
case 'printercentral_print_template': {
|
||
const { templateId, variables = {}, printerId } = args
|
||
|
||
// Obtener template
|
||
const template = await getTemplateById(templateId)
|
||
if (!template) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: `Template no encontrado: ${templateId}` })
|
||
}]
|
||
}
|
||
}
|
||
|
||
// Resolver variables
|
||
const resolvedOps = resolveVariables(template.operations, variables)
|
||
|
||
// Construir XML
|
||
const inner = buildFromOperations(resolvedOps)
|
||
const soap = buildSoapEnvelope(inner)
|
||
|
||
// Obtener impresora
|
||
const printer = printerId
|
||
? await getPrinterById(printerId)
|
||
: await getSelectedPrinter()
|
||
|
||
if (!printer) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: 'No hay impresora configurada' })
|
||
}]
|
||
}
|
||
}
|
||
|
||
// Enviar a impresora
|
||
try {
|
||
const result = await sendToPrinter(soap, printer.host, printer.deviceId, printer.timeout)
|
||
const { success, code } = parsePrinterResponse(result.data)
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
ok: success,
|
||
templateName: template.name,
|
||
printerUsed: { id: printer.id, name: printer.name },
|
||
code
|
||
})
|
||
}]
|
||
}
|
||
} catch (err: any) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: err.message })
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
|
||
case 'printercentral_print_raw': {
|
||
const { operations, variables = {}, printerId } = args
|
||
|
||
if (!operations || !Array.isArray(operations) || operations.length === 0) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: 'operations es requerido y debe ser un array no vacío' })
|
||
}]
|
||
}
|
||
}
|
||
|
||
// Resolver variables
|
||
const resolvedOps = resolveVariables(operations as Operation[], variables)
|
||
|
||
// Construir XML
|
||
const inner = buildFromOperations(resolvedOps)
|
||
const soap = buildSoapEnvelope(inner)
|
||
|
||
// Obtener impresora
|
||
const printer = printerId
|
||
? await getPrinterById(printerId)
|
||
: await getSelectedPrinter()
|
||
|
||
if (!printer) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: 'No hay impresora configurada' })
|
||
}]
|
||
}
|
||
}
|
||
|
||
// Enviar a impresora
|
||
try {
|
||
const result = await sendToPrinter(soap, printer.host, printer.deviceId, printer.timeout)
|
||
const { success, code } = parsePrinterResponse(result.data)
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
ok: success,
|
||
printerUsed: { id: printer.id, name: printer.name },
|
||
code
|
||
})
|
||
}]
|
||
}
|
||
} catch (err: any) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: err.message })
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
|
||
case 'printercentral_create_template': {
|
||
const { name, description, operations } = args
|
||
|
||
if (!name || !operations) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: 'name y operations son requeridos' })
|
||
}]
|
||
}
|
||
}
|
||
|
||
try {
|
||
const template = await createTemplate({
|
||
name,
|
||
description: description || '',
|
||
operations
|
||
})
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
ok: true,
|
||
template: {
|
||
id: template.id,
|
||
name: template.name,
|
||
variables: template.variables
|
||
}
|
||
})
|
||
}]
|
||
}
|
||
} catch (err: any) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: err.message })
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
|
||
case 'printercentral_update_template': {
|
||
const { templateId, name, description, operations } = args
|
||
|
||
if (!templateId) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: 'templateId es requerido' })
|
||
}]
|
||
}
|
||
}
|
||
|
||
const updateData: any = {}
|
||
if (name !== undefined) updateData.name = name
|
||
if (description !== undefined) updateData.description = description
|
||
if (operations !== undefined) updateData.operations = operations
|
||
|
||
try {
|
||
const template = await updateTemplate(templateId, updateData)
|
||
|
||
if (!template) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: `Template no encontrado: ${templateId}` })
|
||
}]
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({
|
||
ok: true,
|
||
template: {
|
||
id: template.id,
|
||
name: template.name,
|
||
variables: template.variables
|
||
}
|
||
})
|
||
}]
|
||
}
|
||
} catch (err: any) {
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: err.message })
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
|
||
case 'printercentral_get_printing_guide': {
|
||
const { model } = args
|
||
|
||
if (model) {
|
||
const guide = getPrintingGuide(model)
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify(guide, null, 2)
|
||
}]
|
||
}
|
||
} else {
|
||
// Retornar todas las guías
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify(PRINTING_GUIDES, null, 2)
|
||
}]
|
||
}
|
||
}
|
||
}
|
||
|
||
case 'printercentral_list_models': {
|
||
const models = Object.entries(PRINTING_GUIDES).map(([key, guide]) => ({
|
||
id: key,
|
||
model: guide.model,
|
||
type: guide.type,
|
||
columns: guide.columns,
|
||
columnsDoubleWidth: guide.columnsDoubleWidth
|
||
}))
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify(models, null, 2)
|
||
}]
|
||
}
|
||
}
|
||
|
||
default:
|
||
return {
|
||
content: [{
|
||
type: 'text',
|
||
text: JSON.stringify({ ok: false, error: `Tool desconocida: ${toolName}` })
|
||
}]
|
||
}
|
||
}
|
||
}
|