fix: admin/options: unusable mime editor

Massimo Melina committed Aug 7, 2023 at 00:01 UTC 100c1fd5e802671889ae1df31f81eafe0030fa0d
11 files changed +153 -194
admin/src/ArrayField.ts
+88 -62
@@ -1,81 +1,28 @@
1 // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2
3 -import { createElement as h, Fragment, useMemo } from 'react'
4 -import { IconBtn, setHidden } from './misc'
5 -import { Add, Edit, Delete } from '@mui/icons-material'
3 +import { createElement as h, Fragment, useMemo, useState } from 'react'
4 +import { IconBtn, isOrderedEqual, setHidden, swap } from './misc'
5 +import { Add, Edit, Delete, ArrowUpward, ArrowDownward, Undo } from '@mui/icons-material'
6 import { formDialog } from './dialog'
7 -import { DataGrid, GridAlignment } from '@mui/x-data-grid'
7 +import { DataGrid, GridActionsCellItem, GridAlignment } from '@mui/x-data-grid'
8 import { FieldDescriptor, FieldProps, labelFromKey } from '@hfs/mui-grid-form'
9 import { Box, FormHelperText, FormLabel } from '@mui/material'
10
11 -type ArrayFieldProps<T> = FieldProps<T[]> & { fields: FieldDescriptor[], height?: number }
12 -export function ArrayField<T extends object>({ label, helperText, fields, value, onChange, onError, getApi, ...rest }: ArrayFieldProps<T>) {
11 +type ArrayFieldProps<T> = FieldProps<T[]> & { fields: FieldDescriptor[], height?: number, reorder?: boolean, prepend?: boolean }
12 +export function ArrayField<T extends object>({ label, helperText, fields, value, onChange, onError, setApi, reorder, prepend, ...rest }: ArrayFieldProps<T>) {
13 const rows = useMemo(() => (value||[]).map((x,$idx) =>
14 setHidden({ ...x } as any, x.hasOwnProperty('id') ? { $idx } : { id: $idx })),
15 [JSON.stringify(value)]) //eslint-disable-line
16 const form = {
17 fields: fields.map(({ $width, $column, ...rest }) => rest)
18 }
19 - const columns = useMemo(() => {
20 - return [
21 - ...fields.map(f => ({
22 - field: f.k,
23 - headerName: f.headerName ?? (typeof f.label === 'string' ? f.label : labelFromKey(f.k)),
24 - disableColumnMenu: true,
25 - ...f.$width >= 8 ? { width: f.$width } : { flex: f.$width || 1 },
26 - ...f.$column,
27 - })),
28 - {
29 - field: '',
30 - width: 80,
31 - disableColumnMenu: true,
32 - sortable: false,
33 - align: 'center' as GridAlignment,
34 - headerAlign: 'center' as GridAlignment,
35 - renderHeader(){
36 - const title = "Add"
37 - return h(IconBtn, {
38 - icon: Add,
39 - title,
40 - onClick: (event:any) =>
41 - formDialog({ form, title }).then(o => // @ts-ignore
42 - o && onChange([...value||[], o], { was: value, event }))
43 - })
44 - },
45 - renderCell({ row }: any) {
46 - const { $idx=row.id } = row
47 - const title = "Modify"
48 - return h('div', {},
49 - h(IconBtn, {
50 - icon: Edit,
51 - title,
52 - onClick: (event:any) =>
53 - formDialog<T>({ values: row, form, title }).then(newRec => {
54 - if (!newRec) return
55 - const newValue = value!.map((oldRec, i) => i === $idx ? newRec : oldRec)
56 - onChange(newValue, { was: value, event })
57 - }),
58 - }),
59 - h(IconBtn, {
60 - icon: Delete,
61 - title: "Delete",
62 - confirm: "Delete?",
63 - onClick(event: any) {
64 - const newValue = value!.filter((rec, i) => i !== $idx)
65 - onChange(newValue, { was: value, event })
66 - },
67 - }),
68 - )
69 - }
70 - }
71 - ]
72 - }, [fields, value, onChange])
19 + setApi?.({ isEqual: isOrderedEqual }) // don't rely on stringify, as it wouldn't work with non-json values
20 + const [undo, setUndo] = useState<typeof value>()
21 return h(Fragment, {},
22 label && h(FormLabel, { sx: { ml: 1 } }, label),
23 helperText && h(FormHelperText, {}, helperText),
76 - h(Box, { height: '20em', ...rest },
24 + h(Box, { ...rest },
25 h(DataGrid, {
78 - columns,
26 rows,
27 hideFooterSelectedRowCount: true,
28 hideFooter: true,
@@ -85,7 +32,86 @@ export function ArrayField<T extends object>({ label, helperText, fields, value,
32 showLastButton: true,
33 }
34 },
35 + columns: [
36 + ...fields.map(f => ({
37 + field: f.k,
38 + headerName: f.headerName ?? (typeof f.label === 'string' ? f.label : labelFromKey(f.k)),
39 + disableColumnMenu: true,
40 + ...f.$width >= 8 ? { width: f.$width } : { flex: f.$width || 1 },
41 + ...f.$column,
42 + })),
43 + {
44 + field: '',
45 + type: 'actions',
46 + width: 90,
47 + headerAlign: 'center' as GridAlignment,
48 + renderHeader(){
49 + const title = "Add"
50 + return h(Fragment, {},
51 + h(IconBtn, {
52 + icon: Add,
53 + title,
54 + size: 'small',
55 + onClick: ev =>
56 + formDialog<T>({ form, title }).then(x => {
57 + if (!x) return
58 + const newValue = value?.slice() || []
59 + if (prepend) newValue.unshift(x)
60 + else newValue.push(x)
61 + set(newValue, ev)
62 + })
63 + }),
64 + undo !== undefined && h(IconBtn, {
65 + icon: Undo,
66 + title: "Undo",
67 + size: 'small',
68 + onClick: ev => set(undo!, ev)
69 + }),
70 + )
71 + },
72 + getActions({ row }) {
73 + const { $idx=row.id } = row
74 + const title = "Modify"
75 + return [
76 + h(GridActionsCellItem as any, {
77 + icon: h(Edit),
78 + label: title,
79 + onClick(event: MouseEvent) {
80 + formDialog<T>({ values: row as any, form, title }).then(x => {
81 + if (x)
82 + set(value!.map((oldRec, i) => i === $idx ? x : oldRec), event)
83 + })
84 + }
85 + }),
86 + h(GridActionsCellItem as any, {
87 + icon: h(Delete),
88 + label: "Delete",
89 + showInMenu: reorder,
90 + onClick: ev => set(value!.filter((rec, i) => i !== $idx), ev),
91 + }),
92 + reorder && $idx && h(GridActionsCellItem as any, {
93 + icon: h(ArrowUpward),
94 + label: "Move up",
95 + showInMenu: true,
96 + onClick: ev => set(swap(value!.slice(), $idx, $idx - 1), ev),
97 + }),
98 + reorder && $idx < rows.length - 1 && h(GridActionsCellItem as any, {
99 + icon: h(ArrowDownward),
100 + label: "Move down",
101 + showInMenu: true,
102 + onClick: ev => set(swap(value!.slice(), $idx, $idx + 1), ev),
103 + }),
104 + ].filter(Boolean)
105 + }
106 + }
107 + ]
108 })
109 )
110 )
111 +
112 + function set(newValue: NonNullable<typeof value>, event?: any) {
113 + onChange(newValue, { was: value, event })
114 + setUndo(value)
115 + }
116 +
117 }
admin/src/FileForm.ts
+2 -2
@@ -173,7 +173,7 @@ interface WhoFieldProps extends FieldProps<Who | undefined> {
173 contentText?: string
174 }
175 function WhoField({ value, onChange, parent, inherit, accounts, helperText, showInherited, otherPerms, byMasks,
176 - isChildren, isDir, contentText="folder content", ...rest }: WhoFieldProps): ReactElement {
176 + isChildren, isDir, contentText="folder content", setApi, ...rest }: WhoFieldProps): ReactElement {
177 const defaultLabel = (byMasks !== undefined ? "As per mask: " : parent !== undefined ? "As parent: " : "Default: " )
178 + who2desc(byMasks ?? inherit)
179 const objectMode = value != null && typeof value === 'object' && !Array.isArray(value)
@@ -197,7 +197,7 @@ function WhoField({ value, onChange, parent, inherit, accounts, helperText, show
197 const arrayMode = Array.isArray(thisValue)
198 // a large side band will convey union across the fields
199 return h(Box, { sx: { borderRight: objectMode ? '8px solid #8884' : undefined, transition: `all ${timeout}ms` } },
200 - h(SelectField as Field<typeof thisValue>, {
200 + h(SelectField as typeof SelectField<typeof thisValue>, {
201 ...rest,
202 value: arrayMode ? [] : thisValue,
203 onChange(v, { event }) {
admin/src/MonitorPage.ts
+1 -1
@@ -180,7 +180,7 @@ function Connections() {
180 hideUnder: Infinity,
181 renderCell: ({ value, row }) => h(Fragment, {},
182 "IPv" + value,
183 - row.secure && iconTooltip(Lock, "HTTPS", { opacity: .5 })
183 + iconTooltip(Lock, "HTTPS", { opacity: .5 })
184 )
185 },
186 {
admin/src/OptionsPage.ts
+20 -16
@@ -5,22 +5,15 @@ import { createElement as h, Fragment, useEffect, useRef } from 'react';
5 import { apiCall, useApi, useApiEx } from './api'
6 import { state, useSnapState } from './state'
7 import { Info, Refresh, Warning } from '@mui/icons-material'
8 -import { Dict, Flex, modifiedSx, wikiLink, with_ } from './misc'
9 -import {
10 - Form,
11 - BoolField,
12 - NumberField,
13 - SelectField,
14 - StringStringField,
15 - FieldProps,
16 - Field,
17 - StringField
18 -} from '@hfs/mui-grid-form';
8 +import { Dict, Flex, iconTooltip, modifiedSx, wikiLink, with_ } from './misc'
9 +import { Form, BoolField, NumberField, SelectField, FieldProps, Field, StringField } from '@hfs/mui-grid-form';
10 +import { ArrayField } from './ArrayField'
11 import FileField from './FileField'
12 import { alertDialog, closeDialog, confirmDialog, newDialog, toast } from './dialog'
13 import { proxyWarning } from './HomePage'
14 import _ from 'lodash';
15 import { proxy, subscribe, useSnapshot } from 'valtio'
16 +import md from './md'
17
18 let loaded: Dict | undefined
19 let exposedReloadStatus: undefined | (() => void)
@@ -154,9 +147,20 @@ export default function OptionsPage() {
147 { k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address",
148 helperText: h(Fragment, {}, "IP address of browser machine. ", h(WildcardsSupported))
149 },
157 - { k: 'mime', comp: StringStringField,
158 - keyLabel: "Files", keyWidth: 7,
159 - valueLabel: "Mime type", valueWidth: 4
150 + { k: 'mime', comp: ArrayField, label: false, reorder: true, prepend: true,
151 + fields: [
152 + { k: 'k', label: "File mask", $width: 1, $column: {
153 + renderCell: ({ value, id }: any) => h('code', {},
154 + value,
155 + value === '*' && id < Object.keys(values.mime).length - 1
156 + && iconTooltip(Warning, md("Mime with `*` should be the last, because first matching row applies"), {
157 + color: 'warning.main', ml: 1
158 + }))
159 + } },
160 + { k: 'v', label: "Mime type", $width: 2 },
161 + ],
162 + toField: x => Object.entries(x || {}).map(([k,v]) => ({ k, v })),
163 + fromField: x => Object.fromEntries(x.map((row: any) => [row.k, row.v])),
164 },
165 { k: 'block', label: "Blocked IPs", multiline: true, minRows:3, helperText: h(Fragment, {}, "Enter an IP address for each line. ", h(WildcardsSupported)),
166 fromField: (all:string) => all.split('\n').map(s => s.trim()).filter(Boolean).map(ip => ({ ip })),
@@ -223,7 +227,7 @@ export function isKeyError(error: any) {
227 return /private key/.test(error)
228 }
229
226 -function ServerPort({ label, value, onChange, getApi, status, suggestedPort=1, error, helperText }: FieldProps<number | null>) {
230 +function ServerPort({ label, value, onChange, setApi, status, suggestedPort=1, error, helperText }: FieldProps<number | null>) {
231 const lastCustom = useRef(suggestedPort)
232 if (value! > 0)
233 lastCustom.current = value!
@@ -253,7 +257,7 @@ function ServerPort({ label, value, onChange, getApi, status, suggestedPort=1, e
257 fullWidth: false,
258 value,
259 onChange,
256 - getApi,
260 + setApi,
261 error,
262 min: 1,
263 max: 65535,
admin/src/misc.ts
+7 -8
@@ -68,15 +68,14 @@ export const IconBtn = forwardRef(({ title, icon, onClick, disabled, progress, l
68 ret.catch(alertDialog).finally(()=> setLoading(false))
69 }
70 }
71 - }, h(icon))
72 - if ((progress || loading) && progress !== false) // false is also useful to inhibit behavior with loading
73 - ret = h(Box, { position:'relative', display: 'inline-block' },
74 - h(CircularProgress, {
71 + },
72 + (progress || loading) && progress !== false // false is also useful to inhibit behavior with loading
73 + && h(CircularProgress, {
74 ...(typeof progress === 'number' ? { value: progress*100, variant: 'determinate' } : null),
75 style: { position:'absolute', top: 4, left: 4, width: 32, height: 32 }
76 }),
78 - ret
79 - )
77 + h(icon)
78 + )
79 if (title)
80 ret = h(Tooltip, { title, ...tooltipProps, children: h('span',{},ret) })
81 return ret
@@ -123,8 +122,8 @@ export function Btn({ icon, title, onClick, disabled, progress, link, tooltipPro
122 return ret
123 }
124
126 -export function iconTooltip(icon: SvgIconComponent, tooltip: string, sx?: SxProps) {
127 - return h(Tooltip, { title: tooltip, children: h(icon, { sx }) })
125 +export function iconTooltip(icon: SvgIconComponent, tooltip: ReactNode, sx?: SxProps) {
126 + return h(Tooltip, { title: tooltip, children: h(icon, { sx: { verticalAlign: 'bottom', ...sx } }) })
127 }
128
129 export function InLink(props:any) {
mui-grid-form/SelectField.ts
+2 -2
@@ -20,7 +20,7 @@ type SelectOption<T> = SelectPair<T> | (T extends string | number ? T : never)
20 interface SelectPair<T> { label: string, value:T }
21
22 export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
23 - const { value, onChange, getApi, options, sx, ...rest } = props
23 + const { value, onChange, setApi, options, sx, ...rest } = props
24 return h(TextField, { // using TextField because Select is not displaying label correctly
25 ...commonSelectProps(props),
26 ...rest,
@@ -36,7 +36,7 @@ export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
36 }
37
38 export function MultiSelectField<T>(props: FieldProps<T[]> & CommonSelectProps<T>) {
39 - const { value, onChange, getApi, options, sx, ...rest } = props
39 + const { value, onChange, setApi, options, sx, ...rest } = props
40 return h(TextField, {
41 ...commonSelectProps({ ...props, value: undefined }),
42 ...rest,
mui-grid-form/StringField.ts
+2 -2
@@ -14,9 +14,9 @@ interface StringFieldProps extends FieldProps<string>, Partial<Omit<StandardText
14 start?: ReactNode
15 end?: ReactNode
16 }
17 -export function StringField({ value, onChange, min, max, required, getApi, typing, start, end, onTyping, suggestions, ...props }: StringFieldProps) {
17 +export function StringField({ value, onChange, min, max, required, setApi, typing, start, end, onTyping, suggestions, ...props }: StringFieldProps) {
18 const normalized = value ?? ''
19 - getApi?.({
19 + setApi?.({
20 getError() {
21 return !value && required ? "required"
22 : value?.length! < min! ? "too short"
mui-grid-form/StringStringField.ts deleted
-88
@@ -1,88 +0,0 @@
1 -// This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt
2 -
3 -import { createElement as h, useRef } from 'react'
4 -import { Grid, IconButton } from '@mui/material'
5 -import { Add, Delete } from '@mui/icons-material'
6 -import { FieldProps} from '.'
7 -import { StringField } from './StringField'
8 -
9 -export function StringStringField({ value, onChange, keyLabel='key', valueLabel='value', keyWidth=5, valueWidth=5, actionsWidth=1 }: FieldProps<Record<string,string>> & { keyLabel:string }) {
10 - const refNew = useRef()
11 - const justEntered = useRef<any>()
12 - const tableHeader = {
13 - padding: '.5em 1em',
14 - fontWeight: 'bold',
15 - }
16 - return h(Grid, { container: true },
17 - // header
18 - h(Grid, { item: true, xs:keyWidth, sx: tableHeader }, keyLabel),
19 - h(Grid, { item: true, xs:valueWidth, sx: tableHeader }, valueLabel),
20 - h(Grid, { item: true, xs:actionsWidth },
21 - h(IconButton, {
22 - onClick() { // @ts-ignore
23 - refNew.current.focus()
24 - }
25 - }, h(Add))),
26 - // existing entries
27 - Object.entries(value||{}).map(([id,v]) => [
28 - h(Grid, { key:'k', item: true, xs: keyWidth, },
29 - h(StringField, {
30 - value: id,
31 - onChange(v, { was, ...rest }){
32 - const copy = { ...value }
33 - if (v)
34 - copy[v] = was !== undefined ? copy[was] : ''
35 - if (was !== undefined)
36 - delete copy[was]
37 - onChange(copy, { was:value, ...rest })
38 - }
39 - })),
40 - h(Grid, { key:'v', item: true, xs: valueWidth, },
41 - h(StringField, {
42 - inputRef(el: HTMLInputElement) {
43 - if (justEntered.current !== id) return
44 - el?.focus()
45 - justEntered.current = null
46 - },
47 - value: v,
48 - onChange(v, { was, ...rest }){
49 - const copy = { ...value }
50 - if (v)
51 - copy[id] = v
52 - else
53 - delete copy[id]
54 - onChange(copy, { was:value, ...rest })
55 - }
56 - })),
57 - h(Grid, { key:'actions', item: true, xs: actionsWidth, sx: { display: 'flex' } },
58 - h(IconButton, {
59 - onClick(event){
60 - const copy = { ...value }
61 - delete copy[id]
62 - onChange(copy, { was:value, event })
63 - }
64 - }, h(Delete)))
65 - ]),
66 - // empty row for adding
67 - h(Grid, { item: true, xs: keyWidth, },
68 - h(StringField, {
69 - inputRef: refNew,
70 - value: '',
71 - onChange(v, more){
72 - if (!v) return
73 - more.cancel()
74 - if (value?.hasOwnProperty(v))
75 - return alert(keyLabel + " entry already present")
76 - justEntered.current = v // the way dom is manipulated will cause focus on wrong element, so we have to re-focus
77 - onChange({ ...value, [v]:'' }, { ...more, was:value })
78 - }
79 - })),
80 - h(Grid, { item: true, xs: valueWidth, },
81 - h(StringField, {
82 - value: '',
83 - onChange(){},
84 - disabled: true,
85 - })),
86 - )
87 -}
88 -
mui-grid-form/index.ts
+9 -10
@@ -22,16 +22,14 @@ import { GridProps } from '@mui/material/Grid/Grid'
22 import { useDebounce } from 'usehooks-ts'
23 export * from './SelectField'
24 export * from './misc-fields'
25 -export * from './StringStringField'
25 export { StringField }
26
27 type ValidationError = ReactNode // false = no error
29 -export interface FieldDescriptor<T=any> {
28 +export interface FieldDescriptor<T=any> extends FieldApi<T> {
29 k: string
30 comp?: any
31 label?: ReactNode
32 error?: ReactNode
34 - getError?: (v: any, extra?: any) => Promisable<ValidationError>
33 toField?: (v: T) => any
34 fromField?: (v: any) => T
35 before?: ReactNode
@@ -43,15 +41,16 @@ export interface FieldDescriptor<T=any> {
41 export type Field<T> = FC<FieldProps<T>>
42
43 export type Promisable<T> = T | Promise<T>
46 -interface FieldApi {
44 +interface FieldApi<T> {
45 // provide getError if you want your error to be visible by the Form component
48 - getError: () => Promisable<ValidationError>, [rest: string]: any
46 + getError?: (v: any, extra?: any) => Promisable<ValidationError>
47 + isEqual?: (a: T, b: T) => boolean,
48 }
49 export interface FieldProps<T> {
50 label?: string | ReactElement
51 value?: T
52 onChange: (v: T, more: { was?: T, event: any, [rest: string]: any }) => void
54 - getApi?: (api: FieldApi) => void
53 + setApi?: (api: FieldApi<T>) => void
54 error?: boolean
55 helperText?: ReactNode
56 [rest: string]: any
@@ -108,7 +107,7 @@ export function Form<Values extends Dict>({
107 const validateUpTo = useRef('')
108 useEffect(() => void(phaseChange()), [phase]) //eslint-disable-line
109
111 - const apis: Dict<FieldApi> = {}
110 + const apis: Dict<FieldApi<unknown>> = {} // consider { [K in keyof Values]?: FieldApi<Values[K]> }
111 return h('form', {
112 ref: formRef && (x => formRef.current = x ? x as HTMLFormElement : undefined),
113 ...formProps,
@@ -142,7 +141,7 @@ export function Form<Values extends Dict>({
141 Object.assign(field, {
142 value: toField(originalValue),
143 error: Boolean(errMsg || error) || undefined,
145 - getApi(api) { apis[k] = api },
144 + setApi(api) { apis[k] = api },
145 onBlur() {
146 pleaseValidate(k)
147 },
@@ -154,7 +153,7 @@ export function Form<Values extends Dict>({
153 try {
154 v = fromField(v)
155 setFieldExceptions(x => ({ ...x, [k]: false }))
157 - if (_.isEqual(v, originalValue)) return
156 + if ((apis[k]?.isEqual || _.isEqual)(v, originalValue)) return
157 set(v, k)
158 pleaseValidate(k)
159 }
@@ -231,7 +230,7 @@ export function Form<Values extends Dict>({
230 if (!f || isValidElement(f) || !f.k) continue
231 const { k } = f
232 const v = values?.[k]
234 - const err = await apis[k]?.getError()
233 + const err = await apis[k]?.getError?.(v, { values, fields })
234 || await f.getError?.(v, { values, fields })
235 || fieldExceptions[k]
236 errs[k] = err || false
mui-grid-form/misc-fields.ts
+3 -3
@@ -22,8 +22,8 @@ export function DisplayField({ value, empty='-', ...props }: any) {
22 return h(StringField, { ...props, value, disabled: true })
23 }
24
25 -export function NumberField({ value, onChange, getApi, required, min, max, step, unit, ...props }: FieldProps<number | null>) {
26 - getApi?.({
25 +export function NumberField({ value, onChange, setApi, required, min, max, step, unit, ...props }: FieldProps<number | null>) {
26 + setApi?.({
27 getError() {
28 return value == null ? (required ? "required" : false)
29 : (value < min ? "too low" : value > max ? "too high" : false)
@@ -52,7 +52,7 @@ export function NumberField({ value, onChange, getApi, required, min, max, step,
52 })
53 }
54
55 -export function BoolField({ label='', value, onChange, getApi, helperText, error,
55 +export function BoolField({ label='', value, onChange, setApi, helperText, error,
56 type, // avoid passing this by accident, as it disrupts the control
57 ...props }: FieldProps<boolean>) {
58 const setter = () => value ?? false
shared/index.ts
+19
@@ -201,3 +201,22 @@ export function tryJson(s?: string) {
201 try { return s && JSON.parse(s) }
202 catch {}
203 }
204 +
205 +export function swap<T>(obj: T, k1: keyof T, k2: keyof T) {
206 + const temp = obj[k1]
207 + obj[k1] = obj[k2]
208 + obj[k2] = temp
209 + return obj
210 +}
211 +
212 +export function isOrderedEqual(a: any, b: any): boolean {
213 + return _.isEqualWith(a, b, (a1, b1) => {
214 + if (!_.isPlainObject(a1) || !_.isPlainObject(b1)) return
215 + const ka = Object.keys(a1)
216 + const kb = Object.keys(b1)
217 + return ka.length === kb.length && ka.every((ka1, i) => {
218 + const kb1 = kb[i]
219 + return ka1 === kb1 && isOrderedEqual(a1[ka1], b1[kb1])
220 + })
221 + })
222 +}
\ No newline at end of file