@electerm/electerm-react 3.10.0 → 3.11.11
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/client/common/bookmark-schemas.js +165 -0
- package/client/common/constants.js +7 -0
- package/client/common/parse-quick-connect.js +13 -10
- package/client/common/sanitize-filename.js +66 -0
- package/client/common/ws.js +25 -6
- package/client/common/zod.js +180 -0
- package/client/components/ai/agent-tool-call-card.jsx +90 -0
- package/client/components/ai/agent-tools.js +193 -0
- package/client/components/ai/agent.js +159 -0
- package/client/components/ai/ai-chat-entry.jsx +11 -0
- package/client/components/ai/ai-chat-history-item.jsx +48 -2
- package/client/components/ai/ai-chat.jsx +25 -6
- package/client/components/ai/ai-config.jsx +45 -4
- package/client/components/ai/ai.styl +73 -0
- package/client/components/bookmark-form/bookmark-schema.js +1 -0
- package/client/components/bookmark-form/config/serial.js +2 -1
- package/client/components/common/font-select.jsx +45 -0
- package/client/components/main/main.jsx +3 -3
- package/client/components/rdp/file-transfer.js +3 -0
- package/client/components/session/session.jsx +2 -2
- package/client/components/setting-panel/setting-terminal.jsx +6 -28
- package/client/components/setting-panel/text-bg-modal.jsx +8 -27
- package/client/components/setting-sync/setting-sync-form.jsx +1 -1
- package/client/components/sftp/file-item.jsx +5 -4
- package/client/components/shortcuts/shortcut-handler.js +9 -9
- package/client/components/terminal/terminal-error-handle.jsx +1 -1
- package/client/components/terminal/terminal-interactive-ui.jsx +157 -0
- package/client/components/terminal/terminal-interactive.jsx +64 -163
- package/client/components/terminal/terminal.jsx +11 -0
- package/client/components/terminal-info/terminal-info-entry.jsx +11 -0
- package/client/components/text-editor/text-editor-entry.jsx +11 -0
- package/client/components/widgets/widget-form.jsx +27 -2
- package/client/entry/worker.js +9 -5
- package/client/store/mcp-handler.js +22 -2
- package/client/store/watch.js +38 -36
- package/package.json +1 -1
- package/client/common/safe-name.js +0 -19
|
@@ -1,30 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* handle terminal interactive operation
|
|
2
|
+
* handle terminal interactive operation - queue based
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { useEffect, useState } from 'react'
|
|
6
|
-
import { Form, Button } from 'antd'
|
|
7
|
-
import Modal from '../common/modal'
|
|
8
|
-
import InputAutoFocus from '../common/input-auto-focus'
|
|
5
|
+
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
9
6
|
import wait from '../../common/wait'
|
|
10
|
-
|
|
11
|
-
const e = window.translate
|
|
12
|
-
const FormItem = Form.Item
|
|
7
|
+
import TermInteractiveUI from './terminal-interactive-ui'
|
|
13
8
|
|
|
14
9
|
export default function TermInteractive () {
|
|
15
|
-
const [
|
|
16
|
-
const
|
|
10
|
+
const [current, setCurrent] = useState(null)
|
|
11
|
+
const queueRef = useRef([])
|
|
12
|
+
const hasCurrentRef = useRef(false)
|
|
13
|
+
|
|
17
14
|
function updateTab (data) {
|
|
18
15
|
window.store.updateTab(data.tabId, data.update)
|
|
19
16
|
}
|
|
20
|
-
|
|
17
|
+
|
|
18
|
+
function processNext () {
|
|
19
|
+
const next = queueRef.current.shift()
|
|
20
|
+
if (next) {
|
|
21
|
+
setCurrent(next)
|
|
22
|
+
} else {
|
|
23
|
+
hasCurrentRef.current = false
|
|
24
|
+
setCurrent(null)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const onMsgRef = useRef(null)
|
|
29
|
+
onMsgRef.current = function onMsg (e) {
|
|
21
30
|
if (
|
|
22
31
|
e &&
|
|
23
32
|
e.data &&
|
|
24
33
|
typeof e.data === 'string' &&
|
|
25
34
|
e.data.includes('session-interactive')
|
|
26
35
|
) {
|
|
27
|
-
|
|
36
|
+
const parsed = JSON.parse(e.data)
|
|
37
|
+
if (hasCurrentRef.current) {
|
|
38
|
+
queueRef.current.push(parsed)
|
|
39
|
+
} else {
|
|
40
|
+
hasCurrentRef.current = true
|
|
41
|
+
setCurrent(parsed)
|
|
42
|
+
}
|
|
28
43
|
} else if (
|
|
29
44
|
e &&
|
|
30
45
|
e.data &&
|
|
@@ -34,166 +49,52 @@ export default function TermInteractive () {
|
|
|
34
49
|
updateTab(JSON.parse(e.data))
|
|
35
50
|
}
|
|
36
51
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
function onCancel () {
|
|
42
|
-
window.et.commonWs.s({
|
|
43
|
-
id: opts.id,
|
|
44
|
-
results: []
|
|
45
|
-
})
|
|
46
|
-
clear()
|
|
47
|
-
}
|
|
48
|
-
function onOk () {
|
|
49
|
-
form.submit()
|
|
50
|
-
}
|
|
51
|
-
function onConfirm () {
|
|
52
|
-
window.et.commonWs.s({
|
|
53
|
-
id: opts.id,
|
|
54
|
-
results: [opts.options.confirmResult || 'yes']
|
|
55
|
-
})
|
|
56
|
-
clear()
|
|
57
|
-
}
|
|
58
|
-
function onIgnore () {
|
|
59
|
-
window.et.commonWs.s({
|
|
60
|
-
id: opts.id,
|
|
61
|
-
results: Object.keys(opts.options.prompts).map(() => '')
|
|
62
|
-
})
|
|
63
|
-
clear()
|
|
64
|
-
}
|
|
65
|
-
function onFinish (res) {
|
|
66
|
-
window.et.commonWs.s({
|
|
67
|
-
id: opts.id,
|
|
68
|
-
results: Object.values(res)
|
|
69
|
-
})
|
|
70
|
-
clear()
|
|
71
|
-
}
|
|
72
|
-
function renderFormItem (pro, i) {
|
|
73
|
-
const {
|
|
74
|
-
prompt,
|
|
75
|
-
echo
|
|
76
|
-
} = pro
|
|
77
|
-
const note = (opts.options.instructions || [])[i]
|
|
78
|
-
const type = echo
|
|
79
|
-
? 'input'
|
|
80
|
-
: 'password'
|
|
81
|
-
return (
|
|
82
|
-
<FormItem
|
|
83
|
-
key={prompt + i}
|
|
84
|
-
label={prompt}
|
|
85
|
-
rules={[{
|
|
86
|
-
required: true, message: 'required'
|
|
87
|
-
}]}
|
|
88
|
-
>
|
|
89
|
-
<div>
|
|
90
|
-
<pre>{note}</pre>
|
|
91
|
-
</div>
|
|
92
|
-
<FormItem noStyle name={'item' + i}>
|
|
93
|
-
<InputAutoFocus
|
|
94
|
-
type={type}
|
|
95
|
-
placeholder={note}
|
|
96
|
-
/>
|
|
97
|
-
</FormItem>
|
|
98
|
-
</FormItem>
|
|
99
|
-
)
|
|
52
|
+
|
|
53
|
+
function onSend (data) {
|
|
54
|
+
window.et.commonWs.s(data)
|
|
100
55
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
56
|
+
|
|
57
|
+
const onClose = useCallback(() => {
|
|
58
|
+
processNext()
|
|
59
|
+
}, [])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let cancelled = false
|
|
63
|
+
function handler (e) {
|
|
64
|
+
if (!cancelled) {
|
|
65
|
+
onMsgRef.current(e)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function initWatch () {
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (cancelled) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
if (window.et.commonWs) {
|
|
74
|
+
window.et.commonWs.addEventListener('message', handler)
|
|
75
|
+
return
|
|
109
76
|
}
|
|
110
|
-
<FormItem>
|
|
111
|
-
<Button
|
|
112
|
-
type='primary'
|
|
113
|
-
onClick={onConfirm}
|
|
114
|
-
>
|
|
115
|
-
{opts.options.submitText || e('submit')}
|
|
116
|
-
</Button>
|
|
117
|
-
<Button
|
|
118
|
-
className='mg1l'
|
|
119
|
-
onClick={onCancel}
|
|
120
|
-
>
|
|
121
|
-
{opts.options.cancelText || e('cancel')}
|
|
122
|
-
</Button>
|
|
123
|
-
</FormItem>
|
|
124
|
-
</div>
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
async function initWatch () {
|
|
128
|
-
let done = false
|
|
129
|
-
while (!done) {
|
|
130
|
-
if (window.et.commonWs) {
|
|
131
|
-
window.et.commonWs.addEventListener('message', onMsg)
|
|
132
|
-
done = true
|
|
133
|
-
} else {
|
|
134
77
|
await wait(400)
|
|
135
78
|
}
|
|
136
79
|
}
|
|
137
|
-
}
|
|
138
|
-
function init () {
|
|
139
80
|
initWatch()
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
81
|
+
return () => {
|
|
82
|
+
cancelled = true
|
|
83
|
+
if (window.et.commonWs) {
|
|
84
|
+
window.et.commonWs.removeEventListener('message', handler)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
143
87
|
}, [])
|
|
144
|
-
|
|
88
|
+
|
|
89
|
+
if (!current) {
|
|
145
90
|
return null
|
|
146
91
|
}
|
|
147
|
-
|
|
148
|
-
maskClosable: false,
|
|
149
|
-
okText: e('submit'),
|
|
150
|
-
onCancel,
|
|
151
|
-
onOk,
|
|
152
|
-
closable: false,
|
|
153
|
-
open: true,
|
|
154
|
-
title: opts.options?.name || '?',
|
|
155
|
-
footer: null
|
|
156
|
-
}
|
|
92
|
+
|
|
157
93
|
return (
|
|
158
|
-
<
|
|
159
|
-
{
|
|
160
|
-
|
|
161
|
-
{
|
|
162
|
-
|
|
163
|
-
? renderConfirmBody()
|
|
164
|
-
: (
|
|
165
|
-
<Form
|
|
166
|
-
form={form}
|
|
167
|
-
layout='vertical'
|
|
168
|
-
onFinish={onFinish}
|
|
169
|
-
>
|
|
170
|
-
{
|
|
171
|
-
opts.options.prompts.map(renderFormItem)
|
|
172
|
-
}
|
|
173
|
-
<FormItem>
|
|
174
|
-
<Button
|
|
175
|
-
type='primary'
|
|
176
|
-
htmlType='submit'
|
|
177
|
-
>
|
|
178
|
-
{e('submit')}
|
|
179
|
-
</Button>
|
|
180
|
-
<Button
|
|
181
|
-
type='dashed'
|
|
182
|
-
className='mg1l'
|
|
183
|
-
onClick={onIgnore}
|
|
184
|
-
>
|
|
185
|
-
{e('ignore')}
|
|
186
|
-
</Button>
|
|
187
|
-
<Button
|
|
188
|
-
className='mg1l'
|
|
189
|
-
onClick={onCancel}
|
|
190
|
-
>
|
|
191
|
-
{e('cancel')}
|
|
192
|
-
</Button>
|
|
193
|
-
</FormItem>
|
|
194
|
-
</Form>
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
</Modal>
|
|
94
|
+
<TermInteractiveUI
|
|
95
|
+
opts={current}
|
|
96
|
+
onSend={onSend}
|
|
97
|
+
onClose={onClose}
|
|
98
|
+
/>
|
|
198
99
|
)
|
|
199
100
|
}
|
|
@@ -1263,6 +1263,17 @@ class Term extends Component {
|
|
|
1263
1263
|
this.handleError({ message: text, from, srcId })
|
|
1264
1264
|
}
|
|
1265
1265
|
})
|
|
1266
|
+
// Guard: component was unmounted while createTerm was pending.
|
|
1267
|
+
// The child process is already running; connect briefly to trigger its cleanup.
|
|
1268
|
+
if (this.onClose) {
|
|
1269
|
+
if (r && r.port) {
|
|
1270
|
+
try {
|
|
1271
|
+
const tmpSock = new WebSocket(this.buildWsUrl(r.port))
|
|
1272
|
+
tmpSock.onopen = () => tmpSock.close()
|
|
1273
|
+
} catch (_e) {}
|
|
1274
|
+
}
|
|
1275
|
+
return
|
|
1276
|
+
}
|
|
1266
1277
|
if (typeof r === 'string' && r.includes('fail')) {
|
|
1267
1278
|
return this.promote()
|
|
1268
1279
|
}
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Widget form component
|
|
3
3
|
*/
|
|
4
4
|
import React, { useState, useEffect } from 'react'
|
|
5
|
-
import { Form, Input, InputNumber, Switch, Select, Button, Tooltip, Alert } from 'antd'
|
|
5
|
+
import { Form, Input, InputNumber, Switch, Select, Button, Tooltip, Alert, Space } from 'antd'
|
|
6
6
|
import { formItemLayout, tailFormItemLayout } from '../../common/form-layout'
|
|
7
7
|
import HelpIcon from '../common/help-icon'
|
|
8
|
+
import { nanoid } from 'nanoid'
|
|
8
9
|
import BatchOpEditor from '../batch-op/batch-op-editor'
|
|
9
10
|
|
|
10
11
|
export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInstance }) {
|
|
@@ -43,12 +44,36 @@ export default function WidgetForm ({ widget, onSubmit, loading, hasRunningInsta
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
const renderFormItem = (config) => {
|
|
46
|
-
const { name, type, description, choices } = config
|
|
47
|
+
const { name, type, description, choices, showGenerator } = config
|
|
47
48
|
let control = null
|
|
48
49
|
|
|
49
50
|
switch (type) {
|
|
50
51
|
case 'string':
|
|
51
52
|
control = <Input placeholder={description} />
|
|
53
|
+
if (showGenerator) {
|
|
54
|
+
return (
|
|
55
|
+
<Form.Item
|
|
56
|
+
key={name}
|
|
57
|
+
{...formItemLayout}
|
|
58
|
+
label={name}
|
|
59
|
+
tooltip={description}
|
|
60
|
+
>
|
|
61
|
+
<Space.Compact style={{ width: '100%' }}>
|
|
62
|
+
<Form.Item
|
|
63
|
+
noStyle
|
|
64
|
+
name={name}
|
|
65
|
+
>
|
|
66
|
+
<Input placeholder={description} />
|
|
67
|
+
</Form.Item>
|
|
68
|
+
<Button
|
|
69
|
+
onClick={() => form.setFieldValue(name, 'ett_' + nanoid())}
|
|
70
|
+
>
|
|
71
|
+
Generate
|
|
72
|
+
</Button>
|
|
73
|
+
</Space.Compact>
|
|
74
|
+
</Form.Item>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
52
77
|
break
|
|
53
78
|
case 'textarea':
|
|
54
79
|
control = <Input.TextArea autoSize={{ minRows: 3 }} placeholder={description} />
|
package/client/entry/worker.js
CHANGED
|
@@ -114,7 +114,10 @@ async function onMsg (e) {
|
|
|
114
114
|
} else if (action === 'addEventListener') {
|
|
115
115
|
const ws = self.insts[wsId]
|
|
116
116
|
if (ws) {
|
|
117
|
-
ws.
|
|
117
|
+
if (!ws.cbs) {
|
|
118
|
+
ws.cbs = {}
|
|
119
|
+
}
|
|
120
|
+
const cb = (e) => {
|
|
118
121
|
send({
|
|
119
122
|
wsId,
|
|
120
123
|
id,
|
|
@@ -123,13 +126,14 @@ async function onMsg (e) {
|
|
|
123
126
|
}
|
|
124
127
|
})
|
|
125
128
|
}
|
|
126
|
-
ws.
|
|
129
|
+
ws.cbs[id] = cb
|
|
130
|
+
ws.addEventListener(type, cb)
|
|
127
131
|
}
|
|
128
132
|
} else if (action === 'removeEventListener') {
|
|
129
133
|
const ws = self.insts[wsId]
|
|
130
|
-
if (ws) {
|
|
131
|
-
ws.removeEventListener(type, ws.
|
|
132
|
-
delete ws.
|
|
134
|
+
if (ws && ws.cbs && ws.cbs[id]) {
|
|
135
|
+
ws.removeEventListener(type, ws.cbs[id])
|
|
136
|
+
delete ws.cbs[id]
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
}
|
|
@@ -172,8 +172,28 @@ export default Store => {
|
|
|
172
172
|
|
|
173
173
|
// ==================== Bookmark APIs ====================
|
|
174
174
|
|
|
175
|
+
const bookmarkSensitiveFields = [
|
|
176
|
+
'password', 'privateKey', 'passphrase', 'certificate', 'proxy',
|
|
177
|
+
'connectionHoppings', 'sshTunnels'
|
|
178
|
+
]
|
|
179
|
+
const bookmarkFeatureFields = [
|
|
180
|
+
'connectionHoppings', 'sshTunnels', 'quickCommands', 'runScripts'
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
function sanitizeBookmark (b) {
|
|
184
|
+
const safe = Object.fromEntries(
|
|
185
|
+
Object.entries(b).filter(([k]) => !bookmarkSensitiveFields.includes(k))
|
|
186
|
+
)
|
|
187
|
+
for (const key of bookmarkFeatureFields) {
|
|
188
|
+
if (Array.isArray(b[key]) && b[key].length) {
|
|
189
|
+
safe[`has${key.charAt(0).toUpperCase() + key.slice(1)}`] = true
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return safe
|
|
193
|
+
}
|
|
194
|
+
|
|
175
195
|
Store.prototype.mcpListBookmarks = function () {
|
|
176
|
-
return deepCopy(window.store.bookmarks)
|
|
196
|
+
return deepCopy(window.store.bookmarks).map(sanitizeBookmark)
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
Store.prototype.mcpGetBookmark = function (args) {
|
|
@@ -182,7 +202,7 @@ export default Store => {
|
|
|
182
202
|
if (!bookmark) {
|
|
183
203
|
throw new Error(`Bookmark not found: ${args.id}`)
|
|
184
204
|
}
|
|
185
|
-
return deepCopy(bookmark)
|
|
205
|
+
return deepCopy(sanitizeBookmark(bookmark))
|
|
186
206
|
}
|
|
187
207
|
|
|
188
208
|
Store.prototype.mcpAddBookmark = async function (args) {
|
package/client/store/watch.js
CHANGED
|
@@ -21,49 +21,51 @@ import dataCompare from '../common/data-compare'
|
|
|
21
21
|
|
|
22
22
|
export default store => {
|
|
23
23
|
for (const name of dbNamesForWatch) {
|
|
24
|
+
window[`watch${name}Running`] = false
|
|
24
25
|
window[`watch${name}`] = autoRun(async () => {
|
|
25
|
-
if (window.migrating) {
|
|
26
|
+
if (window.migrating || window[`watch${name}Running`]) {
|
|
26
27
|
return
|
|
27
28
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
await remove(name, item.id)
|
|
36
|
-
}
|
|
37
|
-
for (const item of updated) {
|
|
38
|
-
await update(item.id, item, name, false)
|
|
39
|
-
}
|
|
40
|
-
for (const item of added) {
|
|
41
|
-
await insert(name, item)
|
|
42
|
-
}
|
|
43
|
-
const newOrder = (n || []).map(d => d.id)
|
|
44
|
-
await update(
|
|
45
|
-
`${name}:order`,
|
|
46
|
-
newOrder
|
|
47
|
-
)
|
|
48
|
-
refsStatic.add('oldState-' + name, deepCopy(n) || [])
|
|
49
|
-
if (name === 'bookmarks') {
|
|
50
|
-
store.bookmarksMap = new Map(
|
|
51
|
-
n.map(d => [d.id, d])
|
|
29
|
+
window[`watch${name}Running`] = true
|
|
30
|
+
try {
|
|
31
|
+
const old = refsStatic.get('oldState-' + name)
|
|
32
|
+
const n = store.getItems(name)
|
|
33
|
+
const { updated, added, removed } = dataCompare(
|
|
34
|
+
old,
|
|
35
|
+
n
|
|
52
36
|
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
37
|
+
await Promise.all([
|
|
38
|
+
...removed.map(item => remove(name, item.id)),
|
|
39
|
+
...updated.map(item => update(item.id, item, name, false)),
|
|
40
|
+
added.length ? insert(name, added) : Promise.resolve()
|
|
41
|
+
])
|
|
42
|
+
const newOrder = (n || []).map(d => d.id)
|
|
43
|
+
await update(
|
|
44
|
+
`${name}:order`,
|
|
45
|
+
newOrder
|
|
46
|
+
)
|
|
47
|
+
refsStatic.add('oldState-' + name, deepCopy(n) || [])
|
|
48
|
+
if (name === 'bookmarks') {
|
|
49
|
+
store.bookmarksMap = new Map(
|
|
50
|
+
n.map(d => [d.id, d])
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
await store.updateLastDataUpdateTime()
|
|
54
|
+
if (dbNamesForSync.includes(name)) {
|
|
55
|
+
const syncSetting = store.config.syncSetting || {}
|
|
56
|
+
const { autoSync, autoSyncInterval, autoSyncDirection } = syncSetting
|
|
57
|
+
if (autoSync && autoSyncInterval === 0) {
|
|
58
|
+
if (autoSyncDirection === 'download') {
|
|
59
|
+
await store.downloadSettingAll()
|
|
60
|
+
} else {
|
|
61
|
+
await store.uploadSettingAll()
|
|
62
|
+
}
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
return store[name]
|
|
66
|
+
} finally {
|
|
67
|
+
window[`watch${name}Running`] = false
|
|
65
68
|
}
|
|
66
|
-
return store[name]
|
|
67
69
|
})
|
|
68
70
|
window[`watch${name}`].start()
|
|
69
71
|
}
|
package/package.json
CHANGED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* convert string to safe name
|
|
3
|
-
* from https://github.com/Jaliborc/safe-filename/blob/master/index.js
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export default (name) => {
|
|
7
|
-
return name
|
|
8
|
-
.replace(/\.$/, '')
|
|
9
|
-
.replace('?', '❓')
|
|
10
|
-
.replace('\\', ' ⃥')
|
|
11
|
-
.replace('/', '⟋')
|
|
12
|
-
.replace('|', '│')
|
|
13
|
-
.replace(':', '꞉')
|
|
14
|
-
.replace('<', 'ᐸ')
|
|
15
|
-
.replace('>', 'ᐳ')
|
|
16
|
-
.replace('>', 'ᐳ')
|
|
17
|
-
.replace('"', 'ᐦ')
|
|
18
|
-
.replace('*', '꘎')
|
|
19
|
-
}
|