sliftutils 0.1.1
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/.cursorrules +161 -0
- package/.eslintrc.js +38 -0
- package/.vscode/settings.json +39 -0
- package/bundler/buffer.js +2370 -0
- package/bundler/bundleEntry.ts +32 -0
- package/bundler/bundleEntryCaller.ts +8 -0
- package/bundler/bundleRequire.ts +244 -0
- package/bundler/bundleWrapper.ts +115 -0
- package/bundler/bundler.ts +72 -0
- package/bundler/flattenSourceMaps.ts +0 -0
- package/bundler/sourceMaps.ts +261 -0
- package/misc/environment.ts +11 -0
- package/misc/types.ts +3 -0
- package/misc/zip.ts +37 -0
- package/package.json +24 -0
- package/spec.txt +33 -0
- package/storage/CachedStorage.ts +32 -0
- package/storage/DelayedStorage.ts +30 -0
- package/storage/DiskCollection.ts +272 -0
- package/storage/FileFolderAPI.tsx +427 -0
- package/storage/IStorage.ts +40 -0
- package/storage/IndexedDBFileFolderAPI.ts +170 -0
- package/storage/JSONStorage.ts +35 -0
- package/storage/PendingManager.tsx +63 -0
- package/storage/PendingStorage.ts +47 -0
- package/storage/PrivateFileSystemStorage.ts +192 -0
- package/storage/StorageObservable.ts +122 -0
- package/storage/TransactionStorage.ts +485 -0
- package/storage/fileSystemPointer.ts +81 -0
- package/storage/storage.d.ts +41 -0
- package/tsconfig.json +31 -0
- package/web/DropdownCustom.tsx +150 -0
- package/web/FullscreenModal.tsx +75 -0
- package/web/GenericFormat.tsx +186 -0
- package/web/Input.tsx +350 -0
- package/web/InputLabel.tsx +288 -0
- package/web/InputPicker.tsx +158 -0
- package/web/LocalStorageParam.ts +56 -0
- package/web/SyncedController.ts +405 -0
- package/web/SyncedLoadingIndicator.tsx +37 -0
- package/web/Table.tsx +188 -0
- package/web/URLParam.ts +84 -0
- package/web/asyncObservable.ts +40 -0
- package/web/colors.tsx +14 -0
- package/web/mobxTyped.ts +29 -0
- package/web/modal.tsx +18 -0
- package/web/observer.tsx +35 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import preact from "preact";
|
|
2
|
+
import { observable } from "mobx";
|
|
3
|
+
import { observer } from "./observer";
|
|
4
|
+
import { css } from "typesafecss";
|
|
5
|
+
import { LengthOrPercentage, LengthOrPercentageOrAuto } from "typesafecss/cssTypes";
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@observer
|
|
9
|
+
export class DropdownCustom<T> extends preact.Component<{
|
|
10
|
+
class?: string;
|
|
11
|
+
optionClass?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
value: T;
|
|
14
|
+
onChange: (value: T, index: number) => void;
|
|
15
|
+
maxWidth?: LengthOrPercentage;
|
|
16
|
+
options: { value: T; label: (isOpen: boolean) => preact.ComponentChild; }[];
|
|
17
|
+
}> {
|
|
18
|
+
synced = observable({
|
|
19
|
+
isOpen: false,
|
|
20
|
+
tempIndexSelected: null as null | number,
|
|
21
|
+
});
|
|
22
|
+
onUnmount: (() => void)[] = [];
|
|
23
|
+
componentDidMount(): void {
|
|
24
|
+
const handler = (e: MouseEvent) => {
|
|
25
|
+
if (!this.synced.isOpen) return;
|
|
26
|
+
let el = e.target as HTMLElement | null;
|
|
27
|
+
while (el) {
|
|
28
|
+
if (el === this.base) return;
|
|
29
|
+
el = el.parentElement;
|
|
30
|
+
}
|
|
31
|
+
this.synced.isOpen = false;
|
|
32
|
+
};
|
|
33
|
+
window.addEventListener("click", handler);
|
|
34
|
+
this.onUnmount.push(() => window.removeEventListener("click", handler));
|
|
35
|
+
}
|
|
36
|
+
componentDidUnmount(): void {
|
|
37
|
+
for (let f of this.onUnmount) f();
|
|
38
|
+
}
|
|
39
|
+
render() {
|
|
40
|
+
const { options, value, title, onChange } = this.props;
|
|
41
|
+
let selectedIndex = options.findIndex(o => o.value === value);
|
|
42
|
+
if (selectedIndex === -1) selectedIndex = 0;
|
|
43
|
+
let selectedItem = options[selectedIndex] || { value: undefined, label: () => { } };
|
|
44
|
+
|
|
45
|
+
let renderOptions = () => {
|
|
46
|
+
let selIndex = this.synced.tempIndexSelected ?? selectedIndex;
|
|
47
|
+
return (
|
|
48
|
+
<div className={css.absolute.width(this.props.maxWidth || "50vw")}>
|
|
49
|
+
<button
|
|
50
|
+
className={css.opacity(0).visibility("hidden").absolute}
|
|
51
|
+
data-hotkey={"Enter"}
|
|
52
|
+
onClick={() => {
|
|
53
|
+
if (this.synced.tempIndexSelected !== null) {
|
|
54
|
+
this.props.onChange(options[this.synced.tempIndexSelected].value, this.synced.tempIndexSelected);
|
|
55
|
+
}
|
|
56
|
+
this.synced.isOpen = false;
|
|
57
|
+
this.synced.tempIndexSelected = null;
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
<button
|
|
61
|
+
className={css.opacity(0).visibility("hidden").absolute}
|
|
62
|
+
data-hotkey={"ArrowUp"}
|
|
63
|
+
onClick={(e) => {
|
|
64
|
+
e.stopPropagation();
|
|
65
|
+
this.synced.tempIndexSelected = (this.synced.tempIndexSelected ?? selectedIndex) - 1;
|
|
66
|
+
if (this.synced.tempIndexSelected < 0) this.synced.tempIndexSelected = options.length - 1;
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
<button
|
|
70
|
+
className={css.opacity(0).visibility("hidden").absolute}
|
|
71
|
+
data-hotkey={"ArrowDown"}
|
|
72
|
+
onClick={(e) => {
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
this.synced.tempIndexSelected = (this.synced.tempIndexSelected ?? selectedIndex) + 1;
|
|
75
|
+
if (this.synced.tempIndexSelected >= options.length) this.synced.tempIndexSelected = 0;
|
|
76
|
+
}}
|
|
77
|
+
/>
|
|
78
|
+
<div
|
|
79
|
+
className={
|
|
80
|
+
css.pad(2).margin(2)
|
|
81
|
+
.absolute.pos(0, 0).zIndex(1)
|
|
82
|
+
.hsl(0, 0, 25)
|
|
83
|
+
.vbox(2).alignItems("stretch")
|
|
84
|
+
.overflow("auto")
|
|
85
|
+
.maxHeight("50vh")
|
|
86
|
+
.border("1px solid hsl(0, 0%, 10%)")
|
|
87
|
+
+ this.props.optionClass
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
{this.props.options.map(({ value, label }, index, list) =>
|
|
91
|
+
<>
|
|
92
|
+
{index !== 0 &&
|
|
93
|
+
<div className={css.height(1).hsl(0, 0, 20)} />
|
|
94
|
+
}
|
|
95
|
+
<div
|
|
96
|
+
onClick={() => {
|
|
97
|
+
this.props.onChange(value, index);
|
|
98
|
+
this.synced.isOpen = false;
|
|
99
|
+
this.synced.tempIndexSelected = null;
|
|
100
|
+
}}
|
|
101
|
+
className={
|
|
102
|
+
" "
|
|
103
|
+
+ (
|
|
104
|
+
index === selIndex && css.hsl(0, 0, 40)
|
|
105
|
+
|| index % 2 === 0 && css.hsl(0, 0, 25)
|
|
106
|
+
|| index % 2 === 1 && css.hsl(0, 0, 20)
|
|
107
|
+
|| ""
|
|
108
|
+
)
|
|
109
|
+
+ css.button.pad(2, 6)
|
|
110
|
+
+ css.background("hsl(0, 0%, 60%)", "hover")
|
|
111
|
+
+ this.props.optionClass
|
|
112
|
+
}
|
|
113
|
+
>
|
|
114
|
+
{label(true)}
|
|
115
|
+
</div>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</div >
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
className={(this.synced.isOpen && css.zIndex(1)) + (this.props.class || "")}
|
|
126
|
+
>
|
|
127
|
+
{this.props.title && (
|
|
128
|
+
<div
|
|
129
|
+
style={{
|
|
130
|
+
fontWeight: "bold",
|
|
131
|
+
}}
|
|
132
|
+
onClick={() => this.synced.isOpen = !this.synced.isOpen}
|
|
133
|
+
>
|
|
134
|
+
{this.props.title}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
<div className={css.relative.vbox0.maxWidth(this.props.maxWidth)}>
|
|
138
|
+
<div
|
|
139
|
+
className={css.hbox(10).hsl(0, 0, 25).pad(4, 10).button + this.props.optionClass}
|
|
140
|
+
onClick={() => this.synced.isOpen = !this.synced.isOpen}
|
|
141
|
+
>
|
|
142
|
+
{selectedItem?.label(false)}
|
|
143
|
+
<button>Expand</button>
|
|
144
|
+
</div>
|
|
145
|
+
{this.synced.isOpen && renderOptions()}
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import preact from "preact";
|
|
2
|
+
import { showModal } from "./modal";
|
|
3
|
+
import { observable } from "mobx";
|
|
4
|
+
import { observer } from "./observer";
|
|
5
|
+
|
|
6
|
+
export function showFullscreenModal(contents: preact.ComponentChildren) {
|
|
7
|
+
let { close } = showModal({
|
|
8
|
+
contents: <FullscreenModal onCancel={() => close()}>
|
|
9
|
+
{contents}
|
|
10
|
+
</FullscreenModal>
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@observer
|
|
15
|
+
export class FullscreenModal extends preact.Component<{
|
|
16
|
+
parentState?: { open: boolean };
|
|
17
|
+
onCancel?: () => void;
|
|
18
|
+
style?: preact.JSX.CSSProperties;
|
|
19
|
+
outerStyle?: preact.JSX.CSSProperties;
|
|
20
|
+
}> {
|
|
21
|
+
render() {
|
|
22
|
+
let { parentState } = this.props;
|
|
23
|
+
return (
|
|
24
|
+
<div>
|
|
25
|
+
<div style={{ display: "none" }}>
|
|
26
|
+
<button data-hotkey={"Escape"} onClick={() => {
|
|
27
|
+
if (parentState) parentState.open = false;
|
|
28
|
+
this.props.onCancel?.();
|
|
29
|
+
}}>Close</button>
|
|
30
|
+
</div>
|
|
31
|
+
<div
|
|
32
|
+
style={{
|
|
33
|
+
position: "fixed",
|
|
34
|
+
top: 0,
|
|
35
|
+
left: 0,
|
|
36
|
+
width: "100vw",
|
|
37
|
+
height: "100vh",
|
|
38
|
+
background: "hsla(0, 0%, 30%, 0.5)",
|
|
39
|
+
padding: 100,
|
|
40
|
+
display: "flex",
|
|
41
|
+
alignItems: "center",
|
|
42
|
+
justifyContent: "center",
|
|
43
|
+
overflow: "auto",
|
|
44
|
+
cursor: "pointer",
|
|
45
|
+
...this.props.outerStyle,
|
|
46
|
+
}}
|
|
47
|
+
onClick={e => {
|
|
48
|
+
if (e.currentTarget === e.target) {
|
|
49
|
+
if (parentState) parentState.open = false;
|
|
50
|
+
this.props.onCancel?.();
|
|
51
|
+
}
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<div
|
|
55
|
+
style={{
|
|
56
|
+
background: "hsl(0, 0%, 100%)",
|
|
57
|
+
padding: 20,
|
|
58
|
+
color: "hsl(0, 0%, 7%)",
|
|
59
|
+
cursor: "default",
|
|
60
|
+
width: "100%",
|
|
61
|
+
display: "flex",
|
|
62
|
+
flexDirection: "column",
|
|
63
|
+
gap: 10,
|
|
64
|
+
maxHeight: "calc(100% - 200px)",
|
|
65
|
+
overflow: "auto",
|
|
66
|
+
...this.props.style
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{this.props.children}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import preact, { ContextType } from "preact";
|
|
2
|
+
import { formatNumber, formatTime } from "socket-function/src/formatting/format";
|
|
3
|
+
import { canHaveChildren } from "socket-function/src/types";
|
|
4
|
+
import { css } from "typesafecss";
|
|
5
|
+
|
|
6
|
+
export const errorMessage = css.hsl(0, 75, 50).color("white", "important", "soft")
|
|
7
|
+
.padding("4px 6px", "soft")
|
|
8
|
+
.whiteSpace("pre-wrap").display("inline-block", "soft")
|
|
9
|
+
;
|
|
10
|
+
export const warnMessage = css.hsl(50, 75, 50).color("hsl(0, 0%, 7%)", "important", "soft")
|
|
11
|
+
.padding("4px 6px", "soft")
|
|
12
|
+
.whiteSpace("pre-wrap").display("inline-block", "soft")
|
|
13
|
+
;
|
|
14
|
+
|
|
15
|
+
export type RowType = { [columnName: string]: unknown };
|
|
16
|
+
export type FormatContext<RowT extends RowType = RowType> = {
|
|
17
|
+
row?: RowT;
|
|
18
|
+
columnName?: RowT extends undefined ? string : keyof RowT;
|
|
19
|
+
};
|
|
20
|
+
export type JSXFormatter<T = unknown, RowT extends RowType = RowType> = (
|
|
21
|
+
StringFormatters
|
|
22
|
+
| `varray:${StringFormatters}`
|
|
23
|
+
| `link:${string}`
|
|
24
|
+
| ((value: T, context?: FormatContext<RowT>) => preact.ComponentChild)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
type StringFormatters = (
|
|
28
|
+
"guess"
|
|
29
|
+
| "string" | "number"
|
|
30
|
+
| "timeSpan" | "date"
|
|
31
|
+
| "error" | "link"
|
|
32
|
+
| "toSpaceCase"
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
function d(value: unknown, formattedValue: preact.ComponentChild) {
|
|
36
|
+
if (value === undefined || value === null) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
return formattedValue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toSpaceCase(text: string) {
|
|
43
|
+
return text
|
|
44
|
+
// "camelCase" => "camel Case"
|
|
45
|
+
// "URL" => "URL"
|
|
46
|
+
.replace(/([a-z][A-Z])/g, str => str[0] + " " + str[1])
|
|
47
|
+
// "snake_case" => "snake case"
|
|
48
|
+
.replace(/_([A-Za-z])/g, " $1")
|
|
49
|
+
// "firstletter" => "Firstletter"
|
|
50
|
+
.replace(/^./, str => str.toUpperCase())
|
|
51
|
+
// "first letter" => "first Letter"
|
|
52
|
+
.replace(/ ./, str => str.toUpperCase())
|
|
53
|
+
// Convert multiple spaces to a single space
|
|
54
|
+
.replace(/\s+/g, " ");
|
|
55
|
+
;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** YYYY/MM/DD HH:MM:SS PM/AM */
|
|
59
|
+
function formatDateTime(time: number) {
|
|
60
|
+
function p(s: number) {
|
|
61
|
+
return s.toString().padStart(2, "0");
|
|
62
|
+
}
|
|
63
|
+
let date = new Date(time);
|
|
64
|
+
let hours = date.getHours();
|
|
65
|
+
let minutes = date.getMinutes();
|
|
66
|
+
let seconds = date.getSeconds();
|
|
67
|
+
let ampm = hours >= 12 ? "PM" : "AM";
|
|
68
|
+
hours = hours % 12;
|
|
69
|
+
hours = hours ? hours : 12; // the hour '0' should be '12'
|
|
70
|
+
let strTime = p(hours) + ":" + p(minutes) + ":" + p(seconds) + " " + ampm;
|
|
71
|
+
return date.getFullYear() + "/" + p(date.getMonth() + 1) + "/" + p(date.getDate()) + " " + strTime;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const startGuessDateRange = +new Date(2010, 0, 1).getTime();
|
|
75
|
+
const endGuessDateRange = +new Date(2040, 0, 1).getTime();
|
|
76
|
+
let formatters: { [formatter in StringFormatters]: (value: unknown) => preact.ComponentChild } = {
|
|
77
|
+
string: (value) => d(value, String(value || "")),
|
|
78
|
+
number: (value) => d(value, formatNumber(Number(value))),
|
|
79
|
+
timeSpan: (value) => d(value, formatTime(Number(value))),
|
|
80
|
+
date: (value) => d(value, formatDateTime(Number(value))),
|
|
81
|
+
error: (value) => d(value, <span className={errorMessage}>{String(value)}</span>),
|
|
82
|
+
toSpaceCase: (value) => d(value, toSpaceCase(String(value))),
|
|
83
|
+
link: (value) => {
|
|
84
|
+
if (value === undefined || value === null) {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
let href = String(value);
|
|
88
|
+
let str = String(value);
|
|
89
|
+
// https://google.com<google> => href = https://google.com, str = google
|
|
90
|
+
let match = str.match(/<([^>]+)>/);
|
|
91
|
+
if (match) {
|
|
92
|
+
href = str.slice(0, match.index);
|
|
93
|
+
str = match[1];
|
|
94
|
+
}
|
|
95
|
+
return <a target="_blank" href={href}>{str}</a>;
|
|
96
|
+
},
|
|
97
|
+
guess: (value) => {
|
|
98
|
+
if (typeof value === "number") {
|
|
99
|
+
// NOTE: These special values don't represent real values, and if they are shown
|
|
100
|
+
// to the user, it is a mistake anyways. So instead of showing a large number
|
|
101
|
+
// that is not meaningful to the user, we show a string, so they the issue
|
|
102
|
+
// is not the system having a large number, but the system not changing
|
|
103
|
+
// the default value.
|
|
104
|
+
if (value === Number.MAX_SAFE_INTEGER) {
|
|
105
|
+
return "Number.MAX_SAFE_INTEGER";
|
|
106
|
+
}
|
|
107
|
+
if (value === Number.MIN_SAFE_INTEGER) {
|
|
108
|
+
return "Number.MIN_SAFE_INTEGER";
|
|
109
|
+
}
|
|
110
|
+
if (value === Number.MAX_VALUE) {
|
|
111
|
+
return "Number.MAX_VALUE";
|
|
112
|
+
}
|
|
113
|
+
if (value === Number.MIN_VALUE) {
|
|
114
|
+
return "Number.MIN_VALUE";
|
|
115
|
+
}
|
|
116
|
+
// Infinity should be somewhat understood by the user, if they are even a little
|
|
117
|
+
// bit literate. Of course, the value is likely a bug, but at least the consequences
|
|
118
|
+
// may be inferrable (the threshold is +Infinity, so it will never be reached, etc).
|
|
119
|
+
if (value === Number.POSITIVE_INFINITY) {
|
|
120
|
+
return "+Infinity";
|
|
121
|
+
}
|
|
122
|
+
if (value === Number.NEGATIVE_INFINITY) {
|
|
123
|
+
return "-Infinity";
|
|
124
|
+
}
|
|
125
|
+
// These are somewhat a mistake, and should almost always be displayed as ""
|
|
126
|
+
if (Number.isNaN(value)) {
|
|
127
|
+
return "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (startGuessDateRange < value && value < endGuessDateRange) {
|
|
131
|
+
return formatters.date(value);
|
|
132
|
+
}
|
|
133
|
+
return formatters.number(value);
|
|
134
|
+
}
|
|
135
|
+
if (typeof value === "string" && value.startsWith("Error:")) {
|
|
136
|
+
return formatters.error(value.slice("Error:".length));
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === "string" && value.startsWith("https://")) {
|
|
139
|
+
return formatters.link(value);
|
|
140
|
+
}
|
|
141
|
+
// Don't render nested objects, etc, otherwise passing large arrays
|
|
142
|
+
// could just in us blowing the output up, when their intention
|
|
143
|
+
// is to just show "Array()"
|
|
144
|
+
if (Array.isArray(value) && !value.some(x => canHaveChildren(x))) {
|
|
145
|
+
return formatValue(value, "varray:guess");
|
|
146
|
+
}
|
|
147
|
+
return formatters.string(value);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
function formatVArray(value: unknown[], formatter: StringFormatters) {
|
|
151
|
+
if (!Array.isArray(value)) {
|
|
152
|
+
return <span className={errorMessage}>Expected array, got {typeof value}</span>;
|
|
153
|
+
}
|
|
154
|
+
let values = value.map(v => {
|
|
155
|
+
let formatted = formatValue(v, formatter);
|
|
156
|
+
if (!canHaveChildren(formatted)) {
|
|
157
|
+
return <span>{formatted}</span>;
|
|
158
|
+
}
|
|
159
|
+
return formatted;
|
|
160
|
+
});
|
|
161
|
+
return (
|
|
162
|
+
<div className={css.vbox(4)}>
|
|
163
|
+
{values}
|
|
164
|
+
</div>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function formatValue(value: unknown, formatter: JSXFormatter = "guess", context?: FormatContext) {
|
|
169
|
+
if (typeof formatter === "function") {
|
|
170
|
+
return formatter(value, context);
|
|
171
|
+
}
|
|
172
|
+
let formatterT = formatter as StringFormatters;
|
|
173
|
+
if (formatterT.startsWith("varray:")) {
|
|
174
|
+
formatterT = formatterT.slice("varray:".length) as StringFormatters;
|
|
175
|
+
return formatVArray(value as unknown[], formatterT);
|
|
176
|
+
}
|
|
177
|
+
if (formatterT.startsWith("link:")) {
|
|
178
|
+
let href = formatterT.slice("link:".length);
|
|
179
|
+
href = href.replaceAll("$VALUE$", String(value));
|
|
180
|
+
return formatters.link(href);
|
|
181
|
+
}
|
|
182
|
+
if (!formatters[formatterT]) {
|
|
183
|
+
throw new Error(`Unknown formatter: ${formatter}`);
|
|
184
|
+
}
|
|
185
|
+
return formatters[formatterT](value);
|
|
186
|
+
}
|