@kubohiroya/turbowarp-extension-loader 0.7.0 → 0.8.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/CHANGELOG.md +16 -1
- package/README.ja.md +33 -4
- package/README.md +35 -4
- package/dist/indexeddb-cache.d.ts +5 -1
- package/dist/indexeddb-cache.js +14 -2
- package/dist/project-authoring.d.ts +3 -2
- package/dist/project-authoring.js +3 -2
- package/dist/project-codec.d.ts +17 -0
- package/dist/project-codec.js +19 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
Released on 2026-11-03. Two additions a host needs to give a project an address at save time and to keep one cache across
|
|
6
|
+
projects. Both are additive: the string form of `appURI`, every existing method and every default
|
|
7
|
+
behave as before.
|
|
8
|
+
|
|
9
|
+
- `createIndexedDBContentCache({appURI})` also accepts `() => string | undefined`, read at each
|
|
10
|
+
write like `sharedDigests`, so a host that keeps one cache while projects are reopened files each
|
|
11
|
+
write under the project open at that moment. `undefined` files the write as shared, as a missing
|
|
12
|
+
`appURI` always has. An `appURI` that is neither a string nor a function is now refused with
|
|
13
|
+
`Invalid IndexedDB cache options` instead of being used as a bucket name.
|
|
14
|
+
- `ProjectIO.assignProjectURI(context, uri, {replace})` in `./project-codec` returns a new context
|
|
15
|
+
whose Manifest carries `uri`, so a project whose Manifest has no address can be given one at its
|
|
16
|
+
first save and keep it on later saves. The address is checked like the `uri` option of `prepare*`
|
|
17
|
+
(`INVALID_PROJECT_URI`). An existing different address is refused with the new code
|
|
18
|
+
`PROJECT_URI_CONFLICT` unless `replace: true`; the same address returns the context unchanged.
|
|
4
19
|
|
|
5
20
|
## 0.7.0
|
|
6
21
|
|
package/README.ja.md
CHANGED
|
@@ -151,6 +151,17 @@ await cache.deleteBucket(manifest.uri); // 手放す。他が参照してい
|
|
|
151
151
|
ごとに読むので、カタログの読み込みに合わせて埋めていけます。どの作品を開いているか言えないホスト
|
|
152
152
|
は、すべてを shared に入れます。名前を推測するのは、無いと認めるより悪いからです。
|
|
153
153
|
|
|
154
|
+
作品を開き直しても1つのキャッシュを使い続けるホスト(たとえば VM ごとに1つ)は、`appURI` に
|
|
155
|
+
関数を渡します。`sharedDigests` と同じく書き込みごと(`put` を呼んだとき)に読むので、各書き込みは
|
|
156
|
+
その時点で開いている作品の下に入り、関数が `undefined` を返した書き込みは shared に入ります。
|
|
157
|
+
文字列を渡せば、これまでどおりキャッシュの寿命の間ずっと1つの作品を指します。
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
let openProject; // いま開いている作品の URI。無ければ undefined
|
|
161
|
+
const cache = createIndexedDBContentCache({appURI: () => openProject, sharedDigests: catalogDigests});
|
|
162
|
+
// 読み込むたびに: openProject = prepared.manifest?.uri;
|
|
163
|
+
```
|
|
164
|
+
|
|
154
165
|
バケットを手放すと claim が落ち、**他が参照していない**バイト列だけが回収されます。2回参照されて
|
|
155
166
|
いる1つの実体は残ります。もう一方の作品は失うことを求めていません。容量超過で追い出すときは
|
|
156
167
|
claim も一緒に落ちるので、バケットが「もう無いもの」を並べることはありません。
|
|
@@ -228,18 +239,20 @@ Manifest は `uri` を持てます。その作品をダウンロードできる
|
|
|
228
239
|
`prepareCatalogExtensionChanges` と `prepareExtensionAddition` は `uri` を受け取ります。作品が
|
|
229
240
|
最初の住所を得るのも、作者が選んだ住所へ移るのも、この経路です。何も言わなければ作品が持って
|
|
230
241
|
いる住所がそのまま残り、`readProjectAuthoringState` がその住所を報告するので、ホストは初回保存
|
|
231
|
-
|
|
242
|
+
かどうかを決める前に見分けられます。`./project-codec` で開いた作品の Manifest に住所が
|
|
243
|
+
無ければ、保存のときに `assignProjectURI` で与えます(「SB3 codec」を参照)。割り当てるかどうかはホストの判断です。作者が住所を選んだ
|
|
232
244
|
のか、既定が必要な初回保存なのかを知っているのはホストだけだからです。
|
|
233
245
|
|
|
234
246
|
`./project-authoring` の `createProjectUUID` と `defaultProjectURI` が、作者が住所を選んで
|
|
235
247
|
いないときに与える住所を組みます。
|
|
236
248
|
|
|
237
249
|
```js
|
|
238
|
-
const uri = defaultProjectURI(location.origin
|
|
250
|
+
const uri = defaultProjectURI(location.origin, createProjectUUID());
|
|
239
251
|
```
|
|
240
252
|
|
|
241
|
-
|
|
242
|
-
|
|
253
|
+
オリジンはエディターをブラウザーで起動したドメインのもので、パスは付けません(`editor.html` の
|
|
254
|
+
パスを付けると、住所がその下にできてしまいます)。配信していない host を名乗ることはありません。
|
|
255
|
+
この住所は作品を人に渡すとき SB3 の中に入って一緒に移動するからです。識別子は UUID version 7 なので、初回保存
|
|
243
256
|
の時刻順に並びます。2段のディレクトリは UUID の**末尾**から取ります。version 7 の先頭はミリ秒
|
|
244
257
|
時計の上位で、ほとんど動かないので、先頭でシャーディングすると**作られた全作品が1つの
|
|
245
258
|
ディレクトリに入ります**。
|
|
@@ -280,6 +293,22 @@ npm run check
|
|
|
280
293
|
|
|
281
294
|
`prepare` は JSON object / string / UTF-8 bytes / ArrayBuffer / Blob と SB3 ZIP を受け取り、`{manifest, input, context}` を返します。Manifest があれば core で検証し、legacy input の Plus ID に対する `extensionURLs` を除去して自動 URL 実行を防ぎます。ID 自体は GUI の登録 hook 用に残します。保存時は Manifest・元の Plus URL metadata・embedded bytes・未知の ZIP entry・未知の top-level metadata を復元します。`restoreArchive` は GUI の `toJSON` hook が `project.json` を復元済みであることを前提とします。
|
|
282
295
|
|
|
296
|
+
`assignProjectURI(context, uri, {replace})` は、Manifest が `uri` を持つ新しい context を返し、
|
|
297
|
+
以後の restore に使います。Manifest に住所の無い作品は、これで最初の保存のときに住所を得ます。
|
|
298
|
+
ホストが返された context を保てば、その後の保存でも同じ住所が入ります。住所は `prepare*` の
|
|
299
|
+
`uri` と同じ規則で検証し(HTTPS で、資格情報もフラグメントも無いこと。違えば
|
|
300
|
+
`INVALID_PROJECT_URI`)、正規化した形で記録します。**作品が既に持っている住所は、
|
|
301
|
+
`replace: true` と言わない限り置き換えません**。違う住所は `PROJECT_URI_CONFLICT` で拒否し、
|
|
302
|
+
同じ住所なら context をそのまま返します。渡した context は変わらず有効で、`null` の context
|
|
303
|
+
(Manifest が無い)は住所を載せる先が無いので拒否します。
|
|
304
|
+
|
|
305
|
+
```js
|
|
306
|
+
let saved = prepared.context;
|
|
307
|
+
// 保存のとき、prepared.manifest が uri を持たない場合だけ:
|
|
308
|
+
if (saved && !prepared.manifest.uri) saved = io.assignProjectURI(saved, defaultProjectURI(location.origin, createProjectUUID()));
|
|
309
|
+
io.restoreJSON(json, saved);
|
|
310
|
+
```
|
|
311
|
+
|
|
283
312
|
未対応形式は ZIP64、multidisk、暗号化、STORE/DEFLATE 以外、UTF-8 以外の非 ASCII entry 名、Unicode path extra field です。既定上限は archive と全展開量64 MiB、1 entry16 MiB、project.json 32 MiB、4096 entry、圧縮比1000です。上限はホスト起動設定で変更できます。Manifest のない ZIP もこの安全検査を受け、通過後は元 input をそのまま返します。このため、従来 VM が許していた未対応 ZIP もフラグ有効時には拒否されます。フラグ OFF では codec 自体を呼びません。
|
|
284
313
|
|
|
285
314
|
## 追加のホスト向けAPI
|
package/README.md
CHANGED
|
@@ -152,6 +152,17 @@ is read at each write, so a host may keep filling it as its catalog loads. A hos
|
|
|
152
152
|
which project it is opening files everything as shared, because guessing a name is worse than
|
|
153
153
|
admitting there is none.
|
|
154
154
|
|
|
155
|
+
A host that keeps one cache while projects are opened and closed — one per VM, say — passes
|
|
156
|
+
`appURI` as a function instead. Like `sharedDigests`, it is read at each write (when `put` is
|
|
157
|
+
called), so each write is filed under the project open at that moment, and a function that returns
|
|
158
|
+
`undefined` files that write as shared. A string still names one project for the cache's lifetime.
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
let openProject; // the URI of the project open now, or undefined
|
|
162
|
+
const cache = createIndexedDBContentCache({appURI: () => openProject, sharedDigests: catalogDigests});
|
|
163
|
+
// on each load: openProject = prepared.manifest?.uri;
|
|
164
|
+
```
|
|
165
|
+
|
|
155
166
|
Releasing a bucket drops its claims and reclaims only the bytes **nothing else refers to**. One
|
|
156
167
|
copy referred to twice stays: the other project did not ask to lose it. An eviction takes the
|
|
157
168
|
claims on it as well, so a bucket never lists bytes that are gone.
|
|
@@ -231,7 +242,9 @@ do.
|
|
|
231
242
|
`prepareCatalogExtensionChanges` and `prepareExtensionAddition` take a `uri` option, which is how
|
|
232
243
|
a project gets its first address and how it moves to one the author chose. Saying nothing keeps
|
|
233
244
|
the address the project already has, and `readProjectAuthoringState` reports that address, so a
|
|
234
|
-
host can tell a first save from a later one before deciding.
|
|
245
|
+
host can tell a first save from a later one before deciding. A project opened through
|
|
246
|
+
`./project-codec` whose Manifest has no address is given one at save time with
|
|
247
|
+
`assignProjectURI` (see "SB3 codec"). Whether to assign one is the host's
|
|
235
248
|
call: only the host knows whether the author chose an address or whether this is a first save
|
|
236
249
|
that needs a default.
|
|
237
250
|
|
|
@@ -239,11 +252,12 @@ that needs a default.
|
|
|
239
252
|
is given when its author has not chosen one:
|
|
240
253
|
|
|
241
254
|
```js
|
|
242
|
-
const uri = defaultProjectURI(location.origin
|
|
255
|
+
const uri = defaultProjectURI(location.origin, createProjectUUID());
|
|
243
256
|
```
|
|
244
257
|
|
|
245
|
-
The origin is the
|
|
246
|
-
|
|
258
|
+
The origin is that of the domain the editor was opened at, with no path (passing the path of
|
|
259
|
+
`editor.html` would put the address under it). It is never a host the editor does not serve from,
|
|
260
|
+
because this address travels inside the SB3 when the work is handed on. The identifier is a UUID version 7, so it sorts by when
|
|
247
261
|
the project was first saved, and the two directory levels are taken from its **end** — a version 7
|
|
248
262
|
UUID begins with the top of a millisecond clock, which barely moves, so sharding on its first
|
|
249
263
|
characters would put every project ever created in one directory.
|
|
@@ -285,6 +299,23 @@ Wire the Loader inside a host feature flag that is fixed at startup and off by d
|
|
|
285
299
|
|
|
286
300
|
`prepare` accepts a JSON object, a string, UTF-8 bytes, an ArrayBuffer, a Blob and an SB3 ZIP, and returns `{manifest, input, context}`. When a Manifest is present, the core validates it and strips `extensionURLs` for Plus IDs in legacy input to prevent automatic URL execution. The IDs themselves remain for the GUI's registration hook. On save it restores the Manifest, the original Plus URL metadata, embedded bytes, unknown ZIP entries and unknown top-level metadata. `restoreArchive` assumes the GUI's `toJSON` hook has already restored `project.json`.
|
|
287
301
|
|
|
302
|
+
`assignProjectURI(context, uri, {replace})` returns a new context whose Manifest carries `uri`, for
|
|
303
|
+
the restore calls that follow. It is how a project whose Manifest has no address gets one when it is
|
|
304
|
+
first saved: the host keeps the context it returns, so every later save carries the same address.
|
|
305
|
+
The address follows the same rule as the `uri` option of `prepare*` (`INVALID_PROJECT_URI` unless
|
|
306
|
+
HTTPS without credentials or fragment) and is recorded normalized. **An address the project already
|
|
307
|
+
has is never replaced unless `replace: true` says so**: a different one is refused with
|
|
308
|
+
`PROJECT_URI_CONFLICT`, and the same one returns the context unchanged. The context passed in stays
|
|
309
|
+
valid and unchanged, and a `null` context (no Manifest) is refused, since there is nothing to carry
|
|
310
|
+
the address.
|
|
311
|
+
|
|
312
|
+
```js
|
|
313
|
+
let saved = prepared.context;
|
|
314
|
+
// at save time, only when prepared.manifest has no uri:
|
|
315
|
+
if (saved && !prepared.manifest.uri) saved = io.assignProjectURI(saved, defaultProjectURI(location.origin, createProjectUUID()));
|
|
316
|
+
io.restoreJSON(json, saved);
|
|
317
|
+
```
|
|
318
|
+
|
|
288
319
|
Unsupported formats are ZIP64, multidisk, encryption, compression other than STORE/DEFLATE, non-ASCII entry names that are not UTF-8, and the Unicode path extra field. The default limits are 64 MiB for the archive and for total expansion, 16 MiB per entry, 32 MiB for `project.json`, 4096 entries, and a compression ratio of 1000. The limits are configurable through the host's startup settings. A ZIP without a Manifest goes through the same safety checks, and the original input is returned unchanged once it passes. As a result, an unsupported ZIP that the previous VM accepted is rejected while the flag is on. With the flag off, the codec is not called at all.
|
|
289
320
|
|
|
290
321
|
## Additional host-facing API
|
|
@@ -30,8 +30,12 @@ export interface IndexedDBContentCacheOptions {
|
|
|
30
30
|
* Without one, everything is filed as shared: a host that cannot say which project it is
|
|
31
31
|
* opening cannot have per-project accounting, and guessing a name would be worse than
|
|
32
32
|
* admitting there is none.
|
|
33
|
+
*
|
|
34
|
+
* A host that keeps one cache while projects are opened and closed passes a function instead:
|
|
35
|
+
* it is read at the time of each write, like `sharedDigests`, so each write is filed under the
|
|
36
|
+
* project open at that moment. A function that returns `undefined` files that write as shared.
|
|
33
37
|
*/
|
|
34
|
-
appURI?: string;
|
|
38
|
+
appURI?: string | (() => string | undefined);
|
|
35
39
|
/**
|
|
36
40
|
* Digests that belong to no one project, read at the time of each write.
|
|
37
41
|
*
|
package/dist/indexeddb-cache.js
CHANGED
|
@@ -17,8 +17,17 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
17
17
|
throw new Error('IndexedDB unavailable');
|
|
18
18
|
if (typeof databaseName !== 'string' || !databaseName || !Number.isSafeInteger(maxBytes) || maxBytes < 1 ||
|
|
19
19
|
!Number.isSafeInteger(maxDecompressedBytes) || maxDecompressedBytes < 1 ||
|
|
20
|
-
typeof now !== 'function'
|
|
20
|
+
typeof now !== 'function' ||
|
|
21
|
+
(appURI !== undefined && typeof appURI !== 'string' && typeof appURI !== 'function')) {
|
|
21
22
|
throw new TypeError('Invalid IndexedDB cache options');
|
|
23
|
+
}
|
|
24
|
+
/** The project a write is filed under, read when it happens. */
|
|
25
|
+
function currentAppURI() {
|
|
26
|
+
const uri = typeof appURI === 'function' ? appURI() : appURI;
|
|
27
|
+
if (uri !== undefined && typeof uri !== 'string')
|
|
28
|
+
throw new TypeError('appURI must return a string or undefined');
|
|
29
|
+
return uri;
|
|
30
|
+
}
|
|
22
31
|
let dbPromise;
|
|
23
32
|
function open() {
|
|
24
33
|
if (!dbPromise) {
|
|
@@ -192,12 +201,15 @@ export function createIndexedDBContentCache({ indexedDB = globalThis.indexedDB,
|
|
|
192
201
|
throw signal.reason;
|
|
193
202
|
if (bytes.byteLength > maxDecompressedBytes)
|
|
194
203
|
throw new RangeError('Cache input exceeds decompressed size limit');
|
|
204
|
+
// Read when put() is called, not after compressing: the project open now is the one
|
|
205
|
+
// these bytes were fetched for, even if another is opened before the write lands.
|
|
206
|
+
const uri = currentAppURI();
|
|
195
207
|
const originalBytes = bytes.byteLength;
|
|
196
208
|
const compressed = await gzipBytes(bytes);
|
|
197
209
|
if (signal?.aborted)
|
|
198
210
|
throw signal.reason;
|
|
199
211
|
const db = await open();
|
|
200
|
-
const bucket =
|
|
212
|
+
const bucket = uri === undefined || sharedDigests?.has(key) ? SHARED_BUCKET : uri;
|
|
201
213
|
return transaction(db, 'readwrite', (store, _set, tx) => {
|
|
202
214
|
const usage = tx.objectStore('usage');
|
|
203
215
|
const time = now();
|
|
@@ -47,8 +47,9 @@ export declare const PROJECT_URI_PREFIX = "turbowarp-plus-projects";
|
|
|
47
47
|
* A fresh identifier for one project: a UUID version 7.
|
|
48
48
|
*
|
|
49
49
|
* Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
|
|
50
|
-
* itself sorts by when the project was first saved. That costs nothing over a
|
|
51
|
-
* leaves the ordering available to anything that wants it.
|
|
50
|
+
* itself sorts by when the project was first saved, to the millisecond. That costs nothing over a
|
|
51
|
+
* random UUID and leaves the ordering available to anything that wants it. Two identifiers made in
|
|
52
|
+
* the same millisecond are ordered by their random bits, not by which came first.
|
|
52
53
|
* @returns {string} The UUID, in the canonical lowercase form.
|
|
53
54
|
*/
|
|
54
55
|
export declare function createProjectUUID(): string;
|
|
@@ -173,8 +173,9 @@ export const PROJECT_URI_PREFIX = 'turbowarp-plus-projects';
|
|
|
173
173
|
* A fresh identifier for one project: a UUID version 7.
|
|
174
174
|
*
|
|
175
175
|
* Version 7 and not 4 because the first 48 bits are the millisecond clock, so the identifier
|
|
176
|
-
* itself sorts by when the project was first saved. That costs nothing over a
|
|
177
|
-
* leaves the ordering available to anything that wants it.
|
|
176
|
+
* itself sorts by when the project was first saved, to the millisecond. That costs nothing over a
|
|
177
|
+
* random UUID and leaves the ordering available to anything that wants it. Two identifiers made in
|
|
178
|
+
* the same millisecond are ordered by their random bits, not by which came first.
|
|
178
179
|
* @returns {string} The UUID, in the canonical lowercase form.
|
|
179
180
|
*/
|
|
180
181
|
export function createProjectUUID() {
|
package/dist/project-codec.d.ts
CHANGED
|
@@ -53,6 +53,23 @@ export interface ProjectIO {
|
|
|
53
53
|
restoreJSON<T extends string | Record<string, unknown>>(json: T, context: ProjectContext | null): T;
|
|
54
54
|
restoreFiles(files: Record<string, Uint8Array>, context: ProjectContext | null): Record<string, Uint8Array>;
|
|
55
55
|
restoreArchive<T extends ZipArchive>(archive: T, context: ProjectContext | null): T;
|
|
56
|
+
/**
|
|
57
|
+
* A context like `context` whose Manifest carries `uri`, for the restore calls that follow.
|
|
58
|
+
*
|
|
59
|
+
* This is how a project whose Manifest has no address gets one when it is first saved, and
|
|
60
|
+
* keeps it on every later save: the host holds on to the context this returns. The address
|
|
61
|
+
* follows the same rule as the `uri` option of `prepareExtensionAddition` and
|
|
62
|
+
* `prepareCatalogExtensionChanges` (`INVALID_PROJECT_URI` unless HTTPS without credentials or
|
|
63
|
+
* fragment), and comes back normalized in the Manifest.
|
|
64
|
+
*
|
|
65
|
+
* An address the project already has is never replaced unless `replace` says so: a different
|
|
66
|
+
* one is refused with `PROJECT_URI_CONFLICT`, and the same one returns `context` itself. The
|
|
67
|
+
* context passed in is left as it was. A project with no Manifest (a `null` context) has no
|
|
68
|
+
* address to carry, and is refused.
|
|
69
|
+
*/
|
|
70
|
+
assignProjectURI(context: ProjectContext, uri: string, options?: {
|
|
71
|
+
replace?: boolean;
|
|
72
|
+
}): ProjectContext;
|
|
56
73
|
}
|
|
57
74
|
/** The one carrier spelling this family writes, and the only one a standard host is given. */
|
|
58
75
|
export declare const DATA_CARRIER_PREFIX = "data:text/javascript;base64,";
|
package/dist/project-codec.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { discoverManifest, LoaderError } from './index.js';
|
|
1
|
+
import { discoverManifest, LoaderError, validateManifest } from './index.js';
|
|
2
2
|
import { DEFAULT_ENTRY_BYTES, DEFAULT_PROJECT_JSON_BYTES, DEFAULT_TOTAL_BYTES } from './limits.js';
|
|
3
3
|
const DEFAULTS = Object.freeze({ maxArchiveBytes: DEFAULT_TOTAL_BYTES, maxTotalBytes: DEFAULT_TOTAL_BYTES,
|
|
4
4
|
maxEntryBytes: DEFAULT_ENTRY_BYTES, maxProjectBytes: DEFAULT_PROJECT_JSON_BYTES, maxEntries: 4096, maxRatio: 1000 });
|
|
@@ -342,6 +342,24 @@ export function createProjectIO({ zip: Zip, ...options } = {}) {
|
|
|
342
342
|
if (name !== 'project.json' && (!archive.file(name) || name.startsWith('extensions/')))
|
|
343
343
|
archive.file(name, new Uint8Array(bytes), { dir: name.endsWith('/'), createFolders: false });
|
|
344
344
|
return archive;
|
|
345
|
+
},
|
|
346
|
+
assignProjectURI(context, uri, { replace = false } = {}) {
|
|
347
|
+
const state = get(context);
|
|
348
|
+
if (!state)
|
|
349
|
+
throw new TypeError('A project without a manifest has no address to assign');
|
|
350
|
+
// The one address rule, through the one place that applies it.
|
|
351
|
+
const address = validateManifest({ schemaVersion: 1, uri, extensions: [] }).uri;
|
|
352
|
+
const current = state.manifest.uri;
|
|
353
|
+
if (current === address)
|
|
354
|
+
return context;
|
|
355
|
+
// Saying nothing keeps the address a project has; replacing it has to be asked for.
|
|
356
|
+
if (current !== undefined && replace !== true) {
|
|
357
|
+
throw new LoaderError('PROJECT_URI_CONFLICT', 'This project already has a different address');
|
|
358
|
+
}
|
|
359
|
+
const manifest = Object.freeze({ schemaVersion: 1, uri: address, extensions: state.manifest.extensions });
|
|
360
|
+
const next = Object.freeze({});
|
|
361
|
+
contexts.set(next, { manifest, project: state.project, files: state.files });
|
|
362
|
+
return next;
|
|
345
363
|
}
|
|
346
364
|
});
|
|
347
365
|
}
|