@talelk/newui-data 0.0.0-stage → 1.1.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 (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. package/src/url-state.ts +168 -0
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Saving a file, without the Blob/anchor/revoke dance every page wrote.
3
+ *
4
+ * `downloadCsv` quotes every cell and doubles inner quotes, which is the part
5
+ * that is easy to get subtly wrong.
6
+ */
7
+
8
+ /** Save a Blob (or a local `File`) under `name`. */
9
+ export function downloadBlob(name: string, blob: Blob): void {
10
+ const url = URL.createObjectURL(blob)
11
+ const link = document.createElement('a')
12
+ link.href = url
13
+ link.download = name
14
+ link.click()
15
+ URL.revokeObjectURL(url)
16
+ }
17
+
18
+ /** Save text under `name`; plain text unless the caller names a type. */
19
+ export function downloadText(
20
+ name: string,
21
+ text: string,
22
+ mime = 'text/plain;charset=utf-8',
23
+ ): void {
24
+ downloadBlob(name, new Blob([text], { type: mime }))
25
+ }
26
+
27
+ /** Rows → CSV text, every cell quoted, quotes doubled. */
28
+ export function csvText(rows: (string | number)[][]): string {
29
+ return rows
30
+ .map((row) =>
31
+ row.map((value) => `"${String(value).replaceAll('"', '""')}"`).join(','),
32
+ )
33
+ .join('\r\n')
34
+ }
35
+
36
+ /** Save rows as a CSV file under `name`. */
37
+ export function downloadCsv(name: string, rows: (string | number)[][]): void {
38
+ downloadText(name, csvText(rows), 'text/csv;charset=utf-8')
39
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The logic layer's public surface, asserted by name and by entry point.
3
+ *
4
+ * The data package has four entries (`.`, `/query`, `/form`, `/resource`),
5
+ * which is exactly
6
+ * the shape an accidental `export *` or a stray
7
+ * re-export slips into. An entry's cost is part of its contract — 0.77 kB for
8
+ * the helpers, 15.5 for the query — so a new name on an entry is a decision, not
9
+ * a detail.
10
+ */
11
+ import { readFileSync } from 'node:fs'
12
+ import path from 'node:path'
13
+ import { describe, expect, it } from 'vitest'
14
+ import * as form from './form'
15
+ import * as root from './index'
16
+ import * as query from './query'
17
+ import * as resource from './resource'
18
+
19
+ const ENTRIES = {
20
+ '.': root,
21
+ './query': query,
22
+ './form': form,
23
+ './resource': resource,
24
+ } as const
25
+
26
+ const SURFACE: Record<keyof typeof ENTRIES, string[]> = {
27
+ // Formatting, selection, undo, downloads — and nothing that renders.
28
+ '.': [
29
+ 'CONFIG_DOCUMENT_LIMIT',
30
+ 'CONFIG_SCHEMA_VERSION',
31
+ 'DashboardProvider',
32
+ 'aggregate',
33
+ 'bytes',
34
+ 'count',
35
+ 'createConfigStore',
36
+ 'createDashboardState',
37
+ 'createTablePersist',
38
+ 'createUndo',
39
+ 'createUrlState',
40
+ 'csvText',
41
+ 'downloadBlob',
42
+ 'downloadCsv',
43
+ 'downloadText',
44
+ 'exportCsv',
45
+ 'first',
46
+ 'isEmail',
47
+ 'optionsOf',
48
+ 'rollup',
49
+ 'timeBucket',
50
+ 'usd',
51
+ 'usdK',
52
+ 'useDashboardState',
53
+ ],
54
+ './query': [
55
+ 'DataProvider',
56
+ 'createCollection',
57
+ 'createDataClient',
58
+ 'createListQuery',
59
+ 'createServerTableState',
60
+ ],
61
+ './form': [
62
+ 'Form',
63
+ 'createAppForm',
64
+ 'createAutoSave',
65
+ 'fieldError',
66
+ 'hiddenPaths',
67
+ 'isUnderHidden',
68
+ 'pruneHiddenValues',
69
+ ],
70
+ './resource': [
71
+ 'RequestError',
72
+ 'connectRealtime',
73
+ 'createDocumentResource',
74
+ 'createIdbStore',
75
+ 'createListResource',
76
+ 'createLocalStore',
77
+ 'createRealtime',
78
+ 'createResource',
79
+ 'getConfig',
80
+ 'isAbortError',
81
+ 'normalizeError',
82
+ 'request',
83
+ 'resetConfig',
84
+ 'setConfig',
85
+ 'useCall',
86
+ 'useDoc',
87
+ 'useDoctype',
88
+ 'useList',
89
+ 'useNewDoc',
90
+ ],
91
+ }
92
+
93
+ const manifest = JSON.parse(
94
+ readFileSync(path.join(process.cwd(), 'packages/data/package.json'), 'utf8'),
95
+ ) as { exports: Record<string, unknown> }
96
+
97
+ describe('@talelk/newui-data', () => {
98
+ it('publishes exactly the entries this test pins', () => {
99
+ expect(Object.keys(manifest.exports).sort()).toEqual(
100
+ Object.keys(ENTRIES).sort(),
101
+ )
102
+ })
103
+
104
+ for (const [entry, module] of Object.entries(ENTRIES)) {
105
+ it(`${entry} exports exactly its surface`, () => {
106
+ expect(Object.keys(module).sort()).toEqual(
107
+ SURFACE[entry as keyof typeof ENTRIES],
108
+ )
109
+ })
110
+ }
111
+
112
+ it('keeps the root entry headless', () => {
113
+ // The root is the only entry with no component, no query client and no
114
+ // validator; every value it exports is a function over plain data — with
115
+ // the exception of the config-store constants, plain numbers by design.
116
+ for (const [name, value] of Object.entries(root)) {
117
+ if (
118
+ name === 'CONFIG_SCHEMA_VERSION' ||
119
+ name === 'CONFIG_DOCUMENT_LIMIT'
120
+ ) {
121
+ expect(typeof value).toBe('number')
122
+ continue
123
+ }
124
+ expect(typeof value).toBe('function')
125
+ }
126
+ })
127
+
128
+ it('keeps the Frappe resource client off the root', () => {
129
+ // The DocType fetching layer is app-specific cost (fetch + IndexedDB +
130
+ // realtime multiplexer); it must stay behind its earned subpath.
131
+ for (const name of ['useDoc', 'useDoctype', 'frappeRequest', 'request']) {
132
+ expect(name in root).toBe(false)
133
+ }
134
+ })
135
+ })
@@ -0,0 +1,186 @@
1
+ import { type AnyFormApi, createForm } from '@tanstack/solid-form'
2
+ import { createEffect, createRoot } from 'solid-js'
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { createAutoSave } from './autosave'
5
+ import { createAppForm } from './form'
6
+
7
+ function memoryStorage() {
8
+ const map = new Map<string, string>()
9
+ return {
10
+ getItem: (key: string) => map.get(key) ?? null,
11
+ setItem: (key: string, value: string) => void map.set(key, value),
12
+ removeItem: (key: string) => void map.delete(key),
13
+ keys: () => [...map.keys()],
14
+ }
15
+ }
16
+
17
+ function harness() {
18
+ // `createRoot` runs its callback synchronously, so `form` is set by return.
19
+ let form!: AnyFormApi
20
+ createRoot((dispose) => {
21
+ form = createForm(() => ({
22
+ defaultValues: { name: '' } satisfies { name: string },
23
+ validators: {
24
+ onChange: ({ value }: { value: { name: string } }) =>
25
+ value.name.length < 3 ? 'Too short.' : undefined,
26
+ },
27
+ }))
28
+ // Keep the root alive for the duration of the test.
29
+ void dispose
30
+ })
31
+ return form
32
+ }
33
+
34
+ beforeEach(() => vi.useFakeTimers())
35
+
36
+ describe('createAutoSave', () => {
37
+ it('saves a valid dirty value after the debounce', async () => {
38
+ const form = harness()
39
+ const save = vi.fn()
40
+ const auto = createAutoSave<{ name: string }>(form, { save, debounce: 100 })
41
+
42
+ form.setFieldValue('name', 'Ada Lovelace')
43
+ await vi.advanceTimersByTimeAsync(100)
44
+
45
+ expect(save).toHaveBeenCalledTimes(1)
46
+ expect(save.mock.calls[0]?.[0]).toEqual({ name: 'Ada Lovelace' })
47
+ expect(auto.saved()).toBe(true)
48
+ expect(auto.dirty()).toBe(false)
49
+ })
50
+
51
+ it('does not save while the form is invalid', async () => {
52
+ const form = harness()
53
+ const save = vi.fn()
54
+ createAutoSave<{ name: string }>(form, { save, debounce: 50 })
55
+
56
+ form.setFieldValue('name', 'ad')
57
+ await vi.advanceTimersByTimeAsync(50)
58
+
59
+ expect(save).not.toHaveBeenCalled()
60
+ })
61
+
62
+ it('skips the write when the filter rejects', async () => {
63
+ const form = harness()
64
+ const save = vi.fn()
65
+ createAutoSave<{ name: string }>(form, {
66
+ save,
67
+ debounce: 50,
68
+ filter: (values) => values.name !== 'skip',
69
+ })
70
+
71
+ form.setFieldValue('name', 'skip')
72
+ await vi.advanceTimersByTimeAsync(50)
73
+
74
+ expect(save).not.toHaveBeenCalled()
75
+ })
76
+
77
+ it('persists a draft and clears it after a successful save', async () => {
78
+ const form = harness()
79
+ const storage = memoryStorage()
80
+ const save = vi.fn()
81
+ const auto = createAutoSave<{ name: string }>(form, {
82
+ save,
83
+ debounce: 50,
84
+ id: 'profile',
85
+ storage,
86
+ })
87
+
88
+ form.setFieldValue('name', 'Ada Lovelace')
89
+ // The draft is written on the change, before the debounce fires.
90
+ expect(auto.hasDraft()).toBe(true)
91
+ expect(auto.restoreDraft()).toEqual({ name: 'Ada Lovelace' })
92
+
93
+ await vi.advanceTimersByTimeAsync(50)
94
+ expect(save).toHaveBeenCalled()
95
+ expect(auto.hasDraft()).toBe(false)
96
+ })
97
+
98
+ it('tracks hasDraft reactively', async () => {
99
+ const form = harness()
100
+ const seen: boolean[] = []
101
+ createRoot(() => {
102
+ const auto = createAutoSave<{ name: string }>(form, {
103
+ save: vi.fn(),
104
+ debounce: 50,
105
+ id: 'reactive',
106
+ storage: memoryStorage(),
107
+ })
108
+ createEffect(() => {
109
+ seen.push(auto.hasDraft())
110
+ })
111
+ })
112
+ form.setFieldValue('name', 'Ada Lovelace')
113
+ await vi.advanceTimersByTimeAsync(50)
114
+ expect(seen).toEqual([false, true, false])
115
+ })
116
+ })
117
+
118
+ interface Doc {
119
+ mode: string
120
+ host: string
121
+ name: string
122
+ }
123
+
124
+ /** An app form whose `host` is hidden unless `mode` is `advanced`. */
125
+ function appHarness(onSubmit: (doc: Doc) => void = () => {}) {
126
+ let app!: ReturnType<typeof createAppForm<Doc>>
127
+ createRoot(() => {
128
+ app = createAppForm<Doc>({
129
+ defaultValues: { mode: 'simple', host: 'stale.example', name: '' },
130
+ ui: { host: { when: (values) => values.mode === 'advanced' } },
131
+ onSubmit,
132
+ })
133
+ })
134
+ return app
135
+ }
136
+
137
+ describe('createAutoSave with an app form', () => {
138
+ it('saves the pruned document, the one submit sends', async () => {
139
+ const app = appHarness()
140
+ const save = vi.fn()
141
+ createAutoSave<Doc>(app, { save, debounce: 50 })
142
+
143
+ app.form.setFieldValue('name', 'Ada')
144
+ await vi.advanceTimersByTimeAsync(50)
145
+
146
+ expect(save).toHaveBeenCalledWith({ mode: 'simple', name: 'Ada' })
147
+ })
148
+
149
+ it('keeps the draft unpruned', () => {
150
+ const app = appHarness()
151
+ const auto = createAutoSave<Doc>(app, {
152
+ save: vi.fn(),
153
+ debounce: 50,
154
+ id: 'settings',
155
+ storage: memoryStorage(),
156
+ })
157
+
158
+ app.form.setFieldValue('name', 'Ada')
159
+
160
+ expect(auto.restoreDraft()).toEqual({
161
+ mode: 'simple',
162
+ host: 'stale.example',
163
+ name: 'Ada',
164
+ })
165
+ })
166
+
167
+ it('drops the draft on a successful submit and does not autosave after it', async () => {
168
+ const app = appHarness()
169
+ const save = vi.fn()
170
+ const auto = createAutoSave<Doc>(app, {
171
+ save,
172
+ debounce: 50,
173
+ id: 'settings',
174
+ storage: memoryStorage(),
175
+ })
176
+
177
+ app.form.setFieldValue('name', 'Ada')
178
+ expect(auto.hasDraft()).toBe(true)
179
+ await app.form.handleSubmit()
180
+
181
+ expect(auto.hasDraft()).toBe(false)
182
+ expect(auto.dirty()).toBe(false)
183
+ await vi.advanceTimersByTimeAsync(50)
184
+ expect(save).not.toHaveBeenCalled()
185
+ })
186
+ })
@@ -0,0 +1,194 @@
1
+ import type { AnyFormApi } from '@tanstack/solid-form'
2
+ import { type Accessor, createSignal, onCleanup } from 'solid-js'
3
+ import { type ConfigStorage, createConfigStore } from '../config-store'
4
+
5
+ /**
6
+ * Autosave and drafts for a live form.
7
+ *
8
+ * Two stores, two rules. The **autosave** is a debounced backend write of a
9
+ * valid, pruned document: every change re-arms the timer, and when it fires
10
+ * the form validates itself before anything is written — an invalid document
11
+ * is never saved behind the user's back. It is not a submit; the submit
12
+ * lifecycle does not move. The **draft** is the local, unpruned copy written
13
+ * on every change (through the config store under `newui.form-draft`, document
14
+ * name = `id`), so a closed tab loses nothing and re-showing a hidden field
15
+ * gives back what was typed. A successful autosave or submit drops the draft.
16
+ */
17
+ export interface AutoSaveOptions<T extends object> {
18
+ /** Persists the valid, dirty document — usually a query mutation. */
19
+ save: (values: T) => void | Promise<void>
20
+ /**
21
+ * Milliseconds a change waits before it is saved.
22
+ * @default 800
23
+ */
24
+ debounce?: number
25
+ /** Skips the write when it returns `false` (an unfinished required section). */
26
+ filter?: (values: T) => boolean
27
+ /** Draft identity. Omit it to autosave without persisting drafts. */
28
+ id?: string
29
+ /** Draft storage backend. Defaults to `localStorage`, like the config store. */
30
+ storage?: ConfigStorage
31
+ }
32
+
33
+ /**
34
+ * What autosave watches: a TanStack form, or the `createAppForm` result —
35
+ * pass the latter so `save` receives the same pruned document submit sends.
36
+ */
37
+ export type AutoSaveSource<T extends object> =
38
+ | AnyFormApi
39
+ | { form: AnyFormApi; document: (values: T) => T }
40
+
41
+ /** What `createAutoSave` returns; the chrome reads the accessors. */
42
+ export interface AutoSave<T extends object = Record<string, unknown>> {
43
+ /** True while a save is in flight. */
44
+ saving: Accessor<boolean>
45
+ /** True once a save has landed and nothing has changed since. */
46
+ saved: Accessor<boolean>
47
+ /** True when the form holds an unsaved change. */
48
+ dirty: Accessor<boolean>
49
+ /** Save now instead of waiting out the debounce. */
50
+ flush: () => Promise<void>
51
+ /** Whether a stored draft exists, reactive. */
52
+ hasDraft: Accessor<boolean>
53
+ /**
54
+ * The stored draft, if any — the app decides whether to apply it. A draft
55
+ * may predate the current schema or field map: treat it as untrusted input
56
+ * and let the form's validation report what no longer fits.
57
+ */
58
+ restoreDraft: () => T | undefined
59
+ /** Drop the stored draft (on submit, discard, or after `restoreDraft`). */
60
+ discardDraft: () => void
61
+ }
62
+
63
+ function clone<T>(value: T): T {
64
+ return structuredClone(value)
65
+ }
66
+
67
+ function deepEqual(a: unknown, b: unknown): boolean {
68
+ if (a === b) return true
69
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime()
70
+ if (
71
+ a === null ||
72
+ b === null ||
73
+ typeof a !== 'object' ||
74
+ typeof b !== 'object'
75
+ )
76
+ return false
77
+ if (Array.isArray(a) !== Array.isArray(b)) return false
78
+ const aKeys = Object.keys(a as object)
79
+ const bKeys = Object.keys(b as object)
80
+ if (aKeys.length !== bKeys.length) return false
81
+ return aKeys.every(
82
+ (key) =>
83
+ Object.hasOwn(b, key) &&
84
+ deepEqual(
85
+ (a as Record<string, unknown>)[key],
86
+ (b as Record<string, unknown>)[key],
87
+ ),
88
+ )
89
+ }
90
+
91
+ function isAppForm<T extends object>(
92
+ source: AutoSaveSource<T>,
93
+ ): source is { form: AnyFormApi; document: (values: T) => T } {
94
+ return 'document' in source && typeof source.document === 'function'
95
+ }
96
+
97
+ export function createAutoSave<T extends object>(
98
+ source: AutoSaveSource<T>,
99
+ options: AutoSaveOptions<T>,
100
+ ): AutoSave<T> {
101
+ const form = isAppForm(source) ? source.form : source
102
+ const document = isAppForm(source) ? source.document : (values: T) => values
103
+ const debounce = options.debounce ?? 800
104
+ const store = options.id
105
+ ? createConfigStore({ key: 'newui.form-draft', storage: options.storage })
106
+ : undefined
107
+ const id = options.id
108
+
109
+ let lastSaved: T = clone(form.state.values as T)
110
+ let running: Promise<void> = Promise.resolve()
111
+ let timer: ReturnType<typeof setTimeout> | undefined
112
+ const [saving, setSaving] = createSignal(false)
113
+ const [saved, setSaved] = createSignal(false)
114
+ const [dirty, setDirty] = createSignal(false)
115
+ const [hasDraft, setHasDraft] = createSignal(
116
+ store !== undefined && id !== undefined && store.load(id) !== undefined,
117
+ )
118
+ const writeDraft = (values: T) => {
119
+ if (!store || !id) return
120
+ store.save(id, values, { type: 'form-draft' })
121
+ setHasDraft(true)
122
+ }
123
+ const dropDraft = () => {
124
+ if (!store || !id) return
125
+ store.remove(id)
126
+ setHasDraft(false)
127
+ }
128
+ let submissions = form.state.submissionAttempts as number
129
+
130
+ const run = async (): Promise<void> => {
131
+ const values = clone(form.state.values as T)
132
+ if (deepEqual(values, lastSaved)) return
133
+ if (options.filter && !options.filter(values)) return
134
+ setSaving(true)
135
+ try {
136
+ await form.validateAllFields('submit')
137
+ if ((form.state.errors as unknown[]).length === 0) {
138
+ await options.save(document(values))
139
+ lastSaved = values
140
+ setSaved(true)
141
+ setDirty(false)
142
+ dropDraft()
143
+ }
144
+ } finally {
145
+ setSaving(false)
146
+ }
147
+ }
148
+
149
+ const subscription = form.store.subscribe(() => {
150
+ const state = form.state
151
+ // A successful submit wrote the document: nothing is unsaved, the draft
152
+ // goes, and the values it sent are the new baseline.
153
+ if (
154
+ state.isSubmitSuccessful &&
155
+ (state.submissionAttempts as number) !== submissions
156
+ ) {
157
+ submissions = state.submissionAttempts as number
158
+ clearTimeout(timer)
159
+ lastSaved = clone(state.values as T)
160
+ setDirty(false)
161
+ dropDraft()
162
+ return
163
+ }
164
+ // Mid-submit changes (an `onSubmit` reset) are the submit's, not edits.
165
+ if (state.isSubmitting) return
166
+ const values = state.values as T
167
+ if (deepEqual(values, lastSaved)) return
168
+ setDirty(true)
169
+ setSaved(false)
170
+ writeDraft(values)
171
+ clearTimeout(timer)
172
+ timer = setTimeout(() => {
173
+ running = run()
174
+ }, debounce)
175
+ })
176
+ onCleanup(() => {
177
+ clearTimeout(timer)
178
+ subscription.unsubscribe()
179
+ })
180
+
181
+ return {
182
+ saving,
183
+ saved,
184
+ dirty,
185
+ async flush() {
186
+ clearTimeout(timer)
187
+ await running
188
+ await run()
189
+ },
190
+ hasDraft,
191
+ restoreDraft: () => (store && id ? store.load<T>(id)?.doc : undefined),
192
+ discardDraft: dropDraft,
193
+ }
194
+ }