@khanglvm/relay 0.12.2 → 0.13.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/README.md +1 -0
- package/docs/AGENT.md +75 -6
- package/package.json +1 -1
- package/skills/relay/SKILL.md +180 -4
- package/src/cli.js +98 -17
- package/src/mcp-ui/board.js +213 -10
- package/src/server.js +8 -3
- package/src/spec.js +272 -47
- package/src/ui/annotate.css +75 -10
- package/src/ui/annotate.js +90 -12
- package/src/ui/app.js +232 -8
- package/src/ui/blocks.css +164 -4
- package/src/ui/blocks.js +430 -33
- package/src/ui/style.css +90 -0
package/src/mcp-ui/board.js
CHANGED
|
@@ -231,11 +231,11 @@
|
|
|
231
231
|
label: String(rq.label || rq.question || rq.text || ''),
|
|
232
232
|
description: typeof rq.description === 'string' ? rq.description : '',
|
|
233
233
|
required: rq.required === true,
|
|
234
|
-
note: rq.note === undefined ? type === 'single' : rq.note === true,
|
|
234
|
+
note: rq.note === undefined ? (type === 'single' || type === 'rank' || type === 'checklist' || type === 'allocate') : rq.note === true,
|
|
235
235
|
placeholder: typeof rq.placeholder === 'string' ? rq.placeholder : '',
|
|
236
236
|
blocks: normBlocks(rq.blocks, id + '-'),
|
|
237
237
|
};
|
|
238
|
-
if (type === 'single' || type === 'multi') {
|
|
238
|
+
if (type === 'single' || type === 'multi' || type === 'rank' || type === 'checklist' || type === 'allocate') {
|
|
239
239
|
const opts = Array.isArray(rq.options) ? rq.options : [];
|
|
240
240
|
q.options = opts.map((o, j) => {
|
|
241
241
|
if (typeof o === 'string' || typeof o === 'number') return { value: String(o), label: String(o) };
|
|
@@ -249,7 +249,18 @@
|
|
|
249
249
|
}
|
|
250
250
|
return { value: String(o), label: String(o) };
|
|
251
251
|
});
|
|
252
|
-
q.other = type === 'single' ? rq.other !== false : rq.other === true;
|
|
252
|
+
if (type === 'single' || type === 'multi') q.other = type === 'single' ? rq.other !== false : rq.other === true;
|
|
253
|
+
}
|
|
254
|
+
if (type === 'checklist') {
|
|
255
|
+
const rawSt = Array.isArray(rq.statuses) && rq.statuses.length ? rq.statuses
|
|
256
|
+
: [{ value: 'pass', label: 'Pass', tone: 'ok' }, { value: 'fail', label: 'Fail', tone: 'bad' }, { value: 'na', label: 'N/A', tone: 'muted' }];
|
|
257
|
+
q.statuses = rawSt.map((s) => typeof s === 'object' && s
|
|
258
|
+
? { value: String(s.value != null ? s.value : s.label || ''), label: String(s.label != null ? s.label : s.value || ''), tone: s.tone ? String(s.tone) : undefined }
|
|
259
|
+
: { value: String(s), label: String(s) === 'na' ? 'N/A' : String(s) });
|
|
260
|
+
}
|
|
261
|
+
if (type === 'allocate') {
|
|
262
|
+
q.total = Number.isFinite(rq.total) && rq.total > 0 ? Math.floor(rq.total) : 100;
|
|
263
|
+
if (rq.unit !== undefined) q.unit = String(rq.unit);
|
|
253
264
|
}
|
|
254
265
|
if (type === 'scale') {
|
|
255
266
|
q.min = Number.isFinite(rq.min) ? rq.min : 1;
|
|
@@ -260,6 +271,11 @@
|
|
|
260
271
|
if (type === 'color' && Array.isArray(rq.presets)) {
|
|
261
272
|
q.presets = rq.presets.map((c) => String(c)).filter(Boolean);
|
|
262
273
|
}
|
|
274
|
+
if (type === 'color' && Array.isArray(rq.palette)) {
|
|
275
|
+
q.palette = rq.palette.map((p) => typeof p === 'object' && p
|
|
276
|
+
? { value: String(p.value != null ? p.value : p.color || ''), label: String(p.label != null ? p.label : (p.name != null ? p.name : (p.value != null ? p.value : p.color || ''))) }
|
|
277
|
+
: { value: String(p), label: String(p) }).filter((p) => p.value);
|
|
278
|
+
}
|
|
263
279
|
if (rq.default !== undefined) q.default = rq.default;
|
|
264
280
|
spec.questions.push(q);
|
|
265
281
|
});
|
|
@@ -423,6 +439,32 @@
|
|
|
423
439
|
|
|
424
440
|
function seedDefaults() {
|
|
425
441
|
for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
|
|
442
|
+
// Rank questions carry a full, valid permutation so an untouched rank still
|
|
443
|
+
// submits a meaningful order (mirrors the browser board's seedRankOrder).
|
|
444
|
+
for (const q of QS) {
|
|
445
|
+
if (q.type !== 'rank') continue;
|
|
446
|
+
const opts = (q.options || []).map((o) => o.value);
|
|
447
|
+
const prior = Array.isArray(state.answers[q.id]) ? state.answers[q.id] : [];
|
|
448
|
+
const order = prior.filter((v, i) => opts.includes(v) && prior.indexOf(v) === i);
|
|
449
|
+
for (const v of opts) if (!order.includes(v)) order.push(v);
|
|
450
|
+
state.answers[q.id] = order;
|
|
451
|
+
}
|
|
452
|
+
for (const q of QS) if (q.type === 'allocate') state.answers[q.id] = seedAllocate(q, state.answers[q.id]);
|
|
453
|
+
for (const q of QS) if (q.type === 'checklist') state.answers[q.id] = seedChecklist(q, state.answers[q.id]);
|
|
454
|
+
}
|
|
455
|
+
function seedAllocate(q, prior) {
|
|
456
|
+
const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
|
|
457
|
+
const out = {};
|
|
458
|
+
for (const o of q.options || []) out[o.value] = Math.max(0, Number(m[o.value]) || 0);
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
function seedChecklist(q, prior) {
|
|
462
|
+
const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
|
|
463
|
+
const optVals = new Set((q.options || []).map((o) => o.value));
|
|
464
|
+
const stVals = new Set((q.statuses || []).map((s) => s.value));
|
|
465
|
+
const out = {};
|
|
466
|
+
for (const k of Object.keys(m)) if (optVals.has(k) && stVals.has(m[k])) out[k] = m[k];
|
|
467
|
+
return out;
|
|
426
468
|
}
|
|
427
469
|
|
|
428
470
|
function getValue(q) {
|
|
@@ -442,6 +484,17 @@
|
|
|
442
484
|
return v === 'yes' || v === 'no' ? v : undefined;
|
|
443
485
|
case 'scale':
|
|
444
486
|
return typeof v === 'number' ? v : undefined;
|
|
487
|
+
case 'rank':
|
|
488
|
+
return Array.isArray(v) && v.length ? [...v] : undefined;
|
|
489
|
+
case 'checklist': {
|
|
490
|
+
const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
491
|
+
return Object.keys(m).length ? { ...m } : undefined;
|
|
492
|
+
}
|
|
493
|
+
case 'allocate': {
|
|
494
|
+
const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
495
|
+
const sum = Object.values(m).reduce((a, n) => a + (Number(n) || 0), 0);
|
|
496
|
+
return sum > 0 ? { ...m } : undefined;
|
|
497
|
+
}
|
|
445
498
|
default: {
|
|
446
499
|
const t = typeof v === 'string' ? v.trim() : '';
|
|
447
500
|
return t || undefined;
|
|
@@ -595,35 +648,182 @@
|
|
|
595
648
|
input.addEventListener('input', () => { state.answers[q.id] = input.value; clearErr(q.id); });
|
|
596
649
|
return input;
|
|
597
650
|
}
|
|
651
|
+
// Checklist / allocate — see the browser board's controls (no autosave here).
|
|
652
|
+
function controlChecklist(q) {
|
|
653
|
+
const wrap = el('div', { class: 'checklist' });
|
|
654
|
+
const cur = () => (state.answers[q.id] && typeof state.answers[q.id] === 'object' && !Array.isArray(state.answers[q.id])
|
|
655
|
+
? state.answers[q.id] : (state.answers[q.id] = {}));
|
|
656
|
+
for (const o of q.options) {
|
|
657
|
+
const seg = el('div', { class: 'chk-seg' });
|
|
658
|
+
const buttons = [];
|
|
659
|
+
for (const s of q.statuses) {
|
|
660
|
+
const b = el('button', { type: 'button', class: 'chk-status' + (s.tone ? ' tone-' + s.tone : '') }, s.label);
|
|
661
|
+
if (cur()[o.value] === s.value) b.classList.add('sel');
|
|
662
|
+
b.addEventListener('click', () => {
|
|
663
|
+
const m = cur();
|
|
664
|
+
if (m[o.value] === s.value) delete m[o.value];
|
|
665
|
+
else m[o.value] = s.value;
|
|
666
|
+
for (const x of buttons) x.btn.classList.toggle('sel', m[o.value] === x.val);
|
|
667
|
+
clearErr(q.id);
|
|
668
|
+
});
|
|
669
|
+
buttons.push({ btn: b, val: s.value });
|
|
670
|
+
seg.append(b);
|
|
671
|
+
}
|
|
672
|
+
wrap.append(el('div', { class: 'chk-row' },
|
|
673
|
+
el('div', { class: 'chk-body' }, el('div', { class: 'ol' }, o.label), o.description ? el('div', { class: 'od' }, o.description) : null),
|
|
674
|
+
seg));
|
|
675
|
+
}
|
|
676
|
+
return wrap;
|
|
677
|
+
}
|
|
678
|
+
function controlAllocate(q) {
|
|
679
|
+
const wrap = el('div', { class: 'allocate' });
|
|
680
|
+
const total = q.total || 100;
|
|
681
|
+
const unit = q.unit ? ' ' + q.unit : '';
|
|
682
|
+
const cur = () => (state.answers[q.id] = seedAllocate(q, state.answers[q.id]));
|
|
683
|
+
const sumEl = el('span', { class: 'alloc-sum' });
|
|
684
|
+
const fill = el('div', { class: 'alloc-bar-fill' });
|
|
685
|
+
const nums = [];
|
|
686
|
+
const refresh = () => {
|
|
687
|
+
const m = cur();
|
|
688
|
+
const sum = (q.options || []).reduce((a, o) => a + (Number(m[o.value]) || 0), 0);
|
|
689
|
+
const tail = sum > total ? ' (over by ' + (sum - total) + ')' : sum < total ? ' (' + (total - sum) + ' left)' : ' ✓';
|
|
690
|
+
sumEl.textContent = sum + ' / ' + total + unit + tail;
|
|
691
|
+
sumEl.classList.toggle('over', sum > total);
|
|
692
|
+
sumEl.classList.toggle('exact', sum === total);
|
|
693
|
+
fill.style.width = Math.min(100, (sum / total) * 100) + '%';
|
|
694
|
+
fill.classList.toggle('over', sum > total);
|
|
695
|
+
for (const n of nums) n.el.textContent = String(m[n.value] || 0);
|
|
696
|
+
};
|
|
697
|
+
for (const o of q.options) {
|
|
698
|
+
const m = cur();
|
|
699
|
+
const range = el('input', { type: 'range', min: '0', max: String(total), step: '1', class: 'alloc-range', 'aria-label': o.label });
|
|
700
|
+
range.value = String(m[o.value] || 0);
|
|
701
|
+
const num = el('span', { class: 'alloc-num' }, String(m[o.value] || 0));
|
|
702
|
+
range.addEventListener('input', () => { cur()[o.value] = Number(range.value) || 0; refresh(); clearErr(q.id); });
|
|
703
|
+
nums.push({ value: o.value, el: num });
|
|
704
|
+
wrap.append(el('div', { class: 'alloc-row' },
|
|
705
|
+
el('div', { class: 'alloc-rowhead' }, el('div', { class: 'ol' }, o.label), num),
|
|
706
|
+
range,
|
|
707
|
+
o.description ? el('div', { class: 'od' }, o.description) : null));
|
|
708
|
+
}
|
|
709
|
+
wrap.append(el('div', { class: 'alloc-total' },
|
|
710
|
+
el('div', { class: 'alloc-bar' }, fill),
|
|
711
|
+
el('div', { class: 'alloc-sumwrap' }, 'Total: ', sumEl)));
|
|
712
|
+
refresh();
|
|
713
|
+
return wrap;
|
|
714
|
+
}
|
|
715
|
+
// Reorderable priority list — see the browser board's controlRank. Answer is
|
|
716
|
+
// the ordered array of option values (highest first); always a full permutation.
|
|
717
|
+
function controlRank(q) {
|
|
718
|
+
const wrap = el('div', { class: 'rank' });
|
|
719
|
+
const byVal = new Map(q.options.map((o) => [o.value, o]));
|
|
720
|
+
let dragFrom = null;
|
|
721
|
+
function move(from, to) {
|
|
722
|
+
const arr = state.answers[q.id];
|
|
723
|
+
if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
|
|
724
|
+
const [x] = arr.splice(from, 1);
|
|
725
|
+
arr.splice(to, 0, x);
|
|
726
|
+
paint();
|
|
727
|
+
clearErr(q.id);
|
|
728
|
+
}
|
|
729
|
+
function paint() {
|
|
730
|
+
wrap.replaceChildren();
|
|
731
|
+
const order = state.answers[q.id] || [];
|
|
732
|
+
order.forEach((val, i) => {
|
|
733
|
+
const o = byVal.get(val);
|
|
734
|
+
if (!o) return;
|
|
735
|
+
const up = el('button', { type: 'button', class: 'rank-btn', title: 'Move up', 'aria-label': 'Move "' + o.label + '" up' }, '↑');
|
|
736
|
+
const down = el('button', { type: 'button', class: 'rank-btn', title: 'Move down', 'aria-label': 'Move "' + o.label + '" down' }, '↓');
|
|
737
|
+
up.disabled = i === 0;
|
|
738
|
+
down.disabled = i === order.length - 1;
|
|
739
|
+
up.addEventListener('click', () => move(i, i - 1));
|
|
740
|
+
down.addEventListener('click', () => move(i, i + 1));
|
|
741
|
+
const item = el('div', { class: 'rank-item', draggable: 'true' },
|
|
742
|
+
el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
|
|
743
|
+
el('div', { class: 'rank-body' },
|
|
744
|
+
el('div', { class: 'ol' }, o.label),
|
|
745
|
+
o.description ? el('div', { class: 'od' }, o.description) : null),
|
|
746
|
+
el('div', { class: 'rank-ctrls' }, up, down)
|
|
747
|
+
);
|
|
748
|
+
item.addEventListener('dragstart', (e) => {
|
|
749
|
+
dragFrom = i; item.classList.add('dragging');
|
|
750
|
+
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
|
|
751
|
+
});
|
|
752
|
+
item.addEventListener('dragend', () => { dragFrom = null; item.classList.remove('dragging'); });
|
|
753
|
+
item.addEventListener('dragover', (e) => { if (dragFrom !== null) { e.preventDefault(); item.classList.add('drop-into'); } });
|
|
754
|
+
item.addEventListener('dragleave', () => item.classList.remove('drop-into'));
|
|
755
|
+
item.addEventListener('drop', (e) => {
|
|
756
|
+
e.preventDefault();
|
|
757
|
+
const from = dragFrom; dragFrom = null;
|
|
758
|
+
if (from !== null && from !== i) move(from, i);
|
|
759
|
+
});
|
|
760
|
+
wrap.append(item);
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
paint();
|
|
764
|
+
return wrap;
|
|
765
|
+
}
|
|
598
766
|
|
|
599
|
-
|
|
600
|
-
|
|
767
|
+
// Resolve any CSS color (named/rgb/hsl/hex) to #rrggbb via the browser parser.
|
|
768
|
+
function cssColorToHex(str) {
|
|
769
|
+
const s = String(str || '').trim();
|
|
601
770
|
let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
|
|
602
771
|
if (m) return '#' + m[1].toLowerCase();
|
|
603
772
|
m = /^#?([0-9a-fA-F]{3})$/.exec(s);
|
|
604
773
|
if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
|
|
605
|
-
|
|
774
|
+
try {
|
|
775
|
+
const d = document.createElement('div');
|
|
776
|
+
d.style.color = '';
|
|
777
|
+
d.style.color = s;
|
|
778
|
+
if (!d.style.color) return null;
|
|
779
|
+
d.style.display = 'none';
|
|
780
|
+
document.body.appendChild(d);
|
|
781
|
+
const rgb = getComputedStyle(d).color;
|
|
782
|
+
document.body.removeChild(d);
|
|
783
|
+
const mm = rgb.match(/(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
|
|
784
|
+
if (!mm) return null;
|
|
785
|
+
const h = (n) => Math.max(0, Math.min(255, Number(n))).toString(16).padStart(2, '0');
|
|
786
|
+
return '#' + h(mm[1]) + h(mm[2]) + h(mm[3]);
|
|
787
|
+
} catch { return null; }
|
|
606
788
|
}
|
|
789
|
+
function toHex6(c) { return cssColorToHex(c) || '#000000'; }
|
|
607
790
|
function controlColor(q) {
|
|
608
791
|
const wrap = el('div', { class: 'colorpick' });
|
|
609
792
|
const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
|
|
610
793
|
const swatch = el('input', { type: 'color', class: 'colorswatch' });
|
|
611
|
-
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
|
|
794
|
+
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb / rgb() / name', spellcheck: 'false', autocapitalize: 'off' });
|
|
612
795
|
swatch.value = toHex6(init || '#888888');
|
|
613
796
|
if (init) { hex.value = init; state.answers[q.id] = init; }
|
|
614
|
-
|
|
797
|
+
let syncPaletteSel = () => {};
|
|
798
|
+
const set = (val) => { state.answers[q.id] = val; syncPaletteSel(); clearErr(q.id); };
|
|
615
799
|
swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
|
|
616
|
-
hex.addEventListener('input', () => { const v = hex.value.trim();
|
|
800
|
+
hex.addEventListener('input', () => { const v = hex.value.trim(); const h = cssColorToHex(v); if (h) swatch.value = h; set(v); });
|
|
801
|
+
if (Array.isArray(q.palette) && q.palette.length) {
|
|
802
|
+
const grid = el('div', { class: 'colorpalette' });
|
|
803
|
+
const cards = [];
|
|
804
|
+
for (const p of q.palette) {
|
|
805
|
+
const labelled = p.label && p.label !== p.value;
|
|
806
|
+
const card = el('button', { type: 'button', class: 'colorpal-sw', style: 'background:' + p.value, title: (labelled ? p.label + ' · ' : '') + p.value });
|
|
807
|
+
if (labelled) card.append(el('span', { class: 'colorpal-label' }, p.label));
|
|
808
|
+
card.addEventListener('click', () => { const h = cssColorToHex(p.value); if (h) swatch.value = h; hex.value = p.value; set(p.value); });
|
|
809
|
+
if (Annotate && !composing) Annotate.register(card, { blockId: null, questionId: q.id, target: { kind: 'swatch', label: (labelled ? p.label + ' · ' : '') + p.value } });
|
|
810
|
+
cards.push({ card, val: p.value });
|
|
811
|
+
grid.append(card);
|
|
812
|
+
}
|
|
813
|
+
syncPaletteSel = () => { for (const c of cards) c.card.classList.toggle('sel', state.answers[q.id] === c.val); };
|
|
814
|
+
wrap.append(grid);
|
|
815
|
+
}
|
|
617
816
|
wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
|
|
618
817
|
if (Array.isArray(q.presets) && q.presets.length) {
|
|
619
818
|
const presets = el('div', { class: 'colorpresets' });
|
|
620
819
|
for (const c of q.presets) {
|
|
621
820
|
const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
|
|
622
|
-
b.addEventListener('click', () => {
|
|
821
|
+
b.addEventListener('click', () => { const h = cssColorToHex(c); if (h) swatch.value = h; hex.value = c; set(c); });
|
|
623
822
|
presets.append(b);
|
|
624
823
|
}
|
|
625
824
|
wrap.append(presets);
|
|
626
825
|
}
|
|
826
|
+
syncPaletteSel();
|
|
627
827
|
return wrap;
|
|
628
828
|
}
|
|
629
829
|
|
|
@@ -659,6 +859,9 @@
|
|
|
659
859
|
else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
|
|
660
860
|
else if (q.type === 'scale') control.append(controlScale(q));
|
|
661
861
|
else if (q.type === 'color') control.append(controlColor(q));
|
|
862
|
+
else if (q.type === 'rank') control.append(controlRank(q));
|
|
863
|
+
else if (q.type === 'checklist') control.append(controlChecklist(q));
|
|
864
|
+
else if (q.type === 'allocate') control.append(controlAllocate(q));
|
|
662
865
|
else control.append(controlText(q, q.type === 'textarea'));
|
|
663
866
|
card.append(control);
|
|
664
867
|
if (q.note) {
|
package/src/server.js
CHANGED
|
@@ -42,16 +42,21 @@ function readUi(name) {
|
|
|
42
42
|
// (their bodies are served via /html/b/<id>), embedded images ship only
|
|
43
43
|
// metadata (bytes served via /img/b/<id>), everything else ships as-is.
|
|
44
44
|
function clientBlock(b) {
|
|
45
|
+
// Cross-block fields preserved when we ship metadata-only (ref = reference-link
|
|
46
|
+
// target name; pins = image coordinate comments). The default `return b` path
|
|
47
|
+
// already carries them.
|
|
48
|
+
const extra = {};
|
|
49
|
+
if (b && b.ref !== undefined) extra.ref = b.ref;
|
|
45
50
|
if (b && b.type === 'html') {
|
|
46
|
-
return { id: b.id, type: 'html', height: b.height, hasHtml: Boolean(b.html) };
|
|
51
|
+
return { id: b.id, type: 'html', height: b.height, hasHtml: Boolean(b.html), ...extra };
|
|
47
52
|
}
|
|
48
53
|
if (b && b.type === 'image' && typeof b.src === 'string' && b.src.startsWith('data:')) {
|
|
49
|
-
return { id: b.id, type: 'image', alt: b.alt, height: b.height, hasData: true };
|
|
54
|
+
return { id: b.id, type: 'image', alt: b.alt, height: b.height, hasData: true, ...(b.pins ? { pins: true } : {}), ...extra };
|
|
50
55
|
}
|
|
51
56
|
// Local video: the absolute file path stays server-side; the client gets a
|
|
52
57
|
// flag + mime and loads the bytes (Range-streamed) from /video/b/<id>.
|
|
53
58
|
if (b && b.type === 'video' && typeof b.file === 'string') {
|
|
54
|
-
return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true };
|
|
59
|
+
return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
|
|
55
60
|
}
|
|
56
61
|
return b;
|
|
57
62
|
}
|