@magicpixelart/cli 0.5.24 → 0.5.27
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 +29 -0
- package/README.md +0 -1
- package/dist/commands/sync.js +40 -55
- package/dist/config.js +0 -1
- package/dist/util/pullDecision.js +24 -0
- package/dist/util/unityFilter.js +19 -31
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.27] — 2026-09-21
|
|
11
|
+
|
|
12
|
+
- Fixed Sync presses that changed only an artboard selection being skipped as
|
|
13
|
+
unchanged, which left the server without the selected-artboard metadata.
|
|
14
|
+
- Removed connected-folder and `unitySyncAll` selection fallbacks. Game sync
|
|
15
|
+
now downloads only artboards explicitly selected with Sync to Game.
|
|
16
|
+
- Previously downloaded, now-unselected variants remain eligible for safe,
|
|
17
|
+
tracked-only pruning during reconciliation.
|
|
18
|
+
|
|
19
|
+
## [0.5.26] — 2026-09-21
|
|
20
|
+
|
|
21
|
+
- Clicking Sync now selects exactly that artboard and clears sibling selections,
|
|
22
|
+
so generated variants and pasted items in the same document stay out of the
|
|
23
|
+
game project.
|
|
24
|
+
- Unity, Godot, and GameMaker now strictly require an explicit per-artboard
|
|
25
|
+
sync flag. Existing working-set paths can no longer bypass that choice.
|
|
26
|
+
- Previously downloaded, now-unmarked siblings are removed on the next sync;
|
|
27
|
+
locally edited files remain protected as conflicts.
|
|
28
|
+
|
|
29
|
+
## [0.5.25] — 2026-09-21
|
|
30
|
+
|
|
31
|
+
- A sync no longer spends one request per file just to be told nothing changed:
|
|
32
|
+
files whose MagicPixel document has not been touched since the last sync are
|
|
33
|
+
skipped outright, instead of being re-checked one at a time.
|
|
34
|
+
- "Nothing changed" replies no longer count against the daily request
|
|
35
|
+
allowance, so a watcher can idle all day without exhausting it.
|
|
36
|
+
- A watcher that has been quiet for 15 minutes now polls every 30 seconds
|
|
37
|
+
(was 10), and snaps straight back to the fast interval on the first change.
|
|
38
|
+
|
|
10
39
|
## [0.5.24] — 2026-09-21
|
|
11
40
|
|
|
12
41
|
- `push --force` keeps your local copy when a sprite changed in both places. It
|
package/README.md
CHANGED
|
@@ -150,7 +150,6 @@ Sync is built to be cheap: a no-op run is one small manifest request, zero PNG b
|
|
|
150
150
|
| `connect` | `string[]` | `["**"]` on engines | Game PNG globs to keep in Connected. Empty on JS projects. |
|
|
151
151
|
| `emitIndex`| `boolean` | `true` | Emit `<outDir>/index.ts` with typed asset map. |
|
|
152
152
|
| `unityPpu` | `number?` | `32` | Unity only: pixels-per-unit in generated `.meta`. |
|
|
153
|
-
| `unitySyncAll` | `boolean?` | `false` | Unity only: sync every artboard, not just those flagged in the editor. |
|
|
154
153
|
| `push` | `boolean?` | `true` | Upload local PNG changes (outDir + `connect` working set) to MagicPixel on every `sync`. Set `false` for pull-only. |
|
|
155
154
|
| `endpoint` | `string?` | production URL | Override the API base (testing only). Must be **HTTPS**. |
|
|
156
155
|
|
package/dist/commands/sync.js
CHANGED
|
@@ -15,7 +15,7 @@ import { detectProjectKind, isEngineKind } from '../util/framework.js';
|
|
|
15
15
|
import { indexGamePngs, matchConnectGlobs, GAME_INDEX_CAP_HINT, connectCapMessage, countingSpritesText } from '../util/gameScan.js';
|
|
16
16
|
import { aliasCollisionKeys, collectSourceRelMap, isPathInside, syncDiskPathFromKey } from '../util/syncPath.js';
|
|
17
17
|
import { DEFAULT_UNITY_PPU, writeMissingUnityMetas } from '../util/unityMeta.js';
|
|
18
|
-
import {
|
|
18
|
+
import { filterUnityManifest, isWorkingSetEntry, partitionWithheldEntries, shouldPruneDeselectedEntry, workingSetPullKeys } from '../util/unityFilter.js';
|
|
19
19
|
import { selectFullSyncOrphans } from '../util/prunePolicy.js';
|
|
20
20
|
import { runTmpJanitor } from '../util/tmpJanitor.js';
|
|
21
21
|
import { friendlyFsError } from '../util/errors.js';
|
|
@@ -24,7 +24,7 @@ import { formatBytes } from '../util/format.js';
|
|
|
24
24
|
import { computePreviousKeyOrphans } from '../util/previousKeyOrphans.js';
|
|
25
25
|
import { cmd } from '../util/invoke.js';
|
|
26
26
|
import { formatSlowTickLine, formatWatchSpriteLine, SLOW_TICK_HEARTBEAT_MS } from '../util/watchCopy.js';
|
|
27
|
-
import { decidePull, hasNewExplicitRelease } from '../util/pullDecision.js';
|
|
27
|
+
import { canSkipWithoutHashing, decidePull, hasNewExplicitRelease } from '../util/pullDecision.js';
|
|
28
28
|
import { hasUnpushedLocalEdit } from '../util/localEdit.js';
|
|
29
29
|
import { shouldReconcile } from '../util/reconcile.js';
|
|
30
30
|
import { runPush } from './push.js';
|
|
@@ -372,10 +372,12 @@ function isNetworkError(err) {
|
|
|
372
372
|
}
|
|
373
373
|
/**
|
|
374
374
|
* Idle thresholds (seconds of consecutive idleness) at which the watcher steps
|
|
375
|
-
* down from the configured interval to 5s, then
|
|
376
|
-
* for both the watch loop and its regression tests.
|
|
375
|
+
* down from the configured interval to 5s, then 10s, then 30s. Single source of
|
|
376
|
+
* truth for both the watch loop and its regression tests. The last step keeps a
|
|
377
|
+
* watcher left running overnight from spending the daily request allowance on
|
|
378
|
+
* polls nobody is waiting for.
|
|
377
379
|
*/
|
|
378
|
-
export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300 };
|
|
380
|
+
export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300, coldSec: 900 };
|
|
379
381
|
/**
|
|
380
382
|
* Pure helper: given how long we've been idle (wall-clock seconds since the
|
|
381
383
|
* last pulled or pushed change) and the configured poll interval, return the
|
|
@@ -385,6 +387,8 @@ export const IDLE_BACKOFF_THRESHOLDS = { softSec: 60, hardSec: 300 };
|
|
|
385
387
|
* off, ticks are longer than `intervalSec`.
|
|
386
388
|
*/
|
|
387
389
|
export function nextBackoffForIdle(idleSeconds, intervalSec, thresholds = IDLE_BACKOFF_THRESHOLDS) {
|
|
390
|
+
if (thresholds.coldSec != null && idleSeconds >= thresholds.coldSec)
|
|
391
|
+
return Math.max(intervalSec, 30);
|
|
388
392
|
if (idleSeconds >= thresholds.hardSec)
|
|
389
393
|
return Math.max(intervalSec, 10);
|
|
390
394
|
if (idleSeconds >= thresholds.softSec)
|
|
@@ -565,9 +569,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
565
569
|
}
|
|
566
570
|
throw e;
|
|
567
571
|
}
|
|
568
|
-
//
|
|
569
|
-
// editor (parity with the in-app sync button).
|
|
570
|
-
// magicpixel.json opts back into everything.
|
|
572
|
+
// Game-engine projects sync only artboards explicitly selected in the
|
|
573
|
+
// editor (parity with the in-app sync button).
|
|
571
574
|
onStatus?.('Looking through your game sprites…');
|
|
572
575
|
const projectKind = await detectProjectKind();
|
|
573
576
|
const gameIndex = runOpts.gameIndex
|
|
@@ -613,23 +616,21 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
613
616
|
}
|
|
614
617
|
}
|
|
615
618
|
const workingSet = workingSetPullKeys(sourceByKey, state.synced);
|
|
616
|
-
// Files whose artboard is no longer
|
|
619
|
+
// Files whose artboard is no longer selected. Deleted even in
|
|
617
620
|
// incremental mode: un-checking the box in the editor must actually remove
|
|
618
621
|
// the sprite from the game project, not leave a stale copy behind.
|
|
619
622
|
// Working-set (sourceRel) files are never deleted — they're the user's art.
|
|
620
623
|
const deselectedPaths = [];
|
|
621
|
-
// Sprites whose
|
|
624
|
+
// Sprites whose game-sync flag the server omitted. Not downloaded
|
|
622
625
|
// (strict opt-in) but explicitly shielded from pruning: a transient server
|
|
623
626
|
// response must never delete art from someone's game project.
|
|
624
627
|
const unknownFlagPaths = new Set();
|
|
625
|
-
if (projectKind
|
|
626
|
-
const filtered =
|
|
627
|
-
|
|
628
|
-
alwaysPull: (entry) => isWorkingSetEntry(entry, workingSet),
|
|
629
|
-
});
|
|
628
|
+
if (isEngineKind(projectKind)) {
|
|
629
|
+
const filtered = filterUnityManifest(manifest);
|
|
630
|
+
const syncedKeys = new Set(Object.keys(state.synced ?? {}));
|
|
630
631
|
if (filtered.unknown.length > 0) {
|
|
631
632
|
const n = filtered.unknown.length;
|
|
632
|
-
const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a
|
|
633
|
+
const msg = `${n} artboard${n === 1 ? '' : 's'} came back without a game-sync flag — skipped (existing files kept).`;
|
|
633
634
|
if (verbose) {
|
|
634
635
|
console.log(kleur.yellow(`! ${msg}`));
|
|
635
636
|
console.log(kleur.dim(` Fix: re-run \`${cmd('sync')}\` in a moment, or upgrade with \`npm i -D @magicpixelart/cli@latest\`.`));
|
|
@@ -648,49 +649,24 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
648
649
|
unknownFlagPaths.add(p);
|
|
649
650
|
continue;
|
|
650
651
|
}
|
|
651
|
-
|
|
652
|
+
// A tracked download is ours to reconcile even when its path now falls
|
|
653
|
+
// under a connect glob. Genuine game-authored files have no synced
|
|
654
|
+
// baseline and remain protected.
|
|
655
|
+
if (!shouldPruneDeselectedEntry(entry, { sourceByKey, syncedKeys }))
|
|
652
656
|
continue;
|
|
653
657
|
if (existsSync(p))
|
|
654
658
|
deselectedPaths.push(p);
|
|
655
659
|
}
|
|
656
|
-
if (filtered.noneFlagged) {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
console.log(kleur.yellow('! No artboards are flagged "Sync to Unity" — nothing to pull.'));
|
|
662
|
-
console.log(kleur.dim(` Working-set sprites (\`connect\` in magicpixel.json) still push into MagicPixel.`));
|
|
663
|
-
console.log(kleur.dim(' Flag folders/artboards in the library to pull them back into Unity.'));
|
|
664
|
-
}
|
|
665
|
-
const empty = {
|
|
666
|
-
added: [],
|
|
667
|
-
modified: [],
|
|
668
|
-
removed: [],
|
|
669
|
-
unchanged: 0,
|
|
670
|
-
failed: 0,
|
|
671
|
-
bytesIn: 0,
|
|
672
|
-
bytesSaved: 0,
|
|
673
|
-
renamed: [],
|
|
674
|
-
conflicts: [],
|
|
675
|
-
};
|
|
676
|
-
// Persist validators even when the cursor didn't move, so the next
|
|
677
|
-
// one-shot run starts from a conditional GET.
|
|
678
|
-
const lastSync = observedUpdatedAt && !(state.lastSync && state.lastSync > observedUpdatedAt)
|
|
679
|
-
? observedUpdatedAt
|
|
680
|
-
: state.lastSync;
|
|
681
|
-
await saveState({
|
|
682
|
-
...state,
|
|
683
|
-
...(lastSync ? { lastSync } : {}),
|
|
684
|
-
...(since ? {} : { lastReconcile: startedAt }),
|
|
685
|
-
manifestEtags: manifestEtagsSnapshot(),
|
|
686
|
-
});
|
|
687
|
-
await maybePushLocalSprites(config.push, verbose, gameIndex, live && !!runOpts.watchMode, onStatus);
|
|
688
|
-
return empty;
|
|
660
|
+
if (filtered.noneFlagged && verbose) {
|
|
661
|
+
console.log();
|
|
662
|
+
console.log(kleur.dim(since
|
|
663
|
+
? ' No changed artboards are selected for download.'
|
|
664
|
+
: ' No artboards are selected for download.'));
|
|
689
665
|
}
|
|
690
666
|
const skipped = manifest.length - filtered.entries.length;
|
|
691
667
|
manifest = filtered.entries;
|
|
692
668
|
if (verbose && skipped > 0) {
|
|
693
|
-
console.log(kleur.dim(`
|
|
669
|
+
console.log(kleur.dim(` game sync → ${skipped} artboard${skipped === 1 ? '' : 's'} not selected (skipped)`));
|
|
694
670
|
}
|
|
695
671
|
}
|
|
696
672
|
// Detect renames: same id, different key vs prior snapshot.
|
|
@@ -770,7 +746,14 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
770
746
|
previousReleasedVersion: synced[entry.key]?.releasedVersion,
|
|
771
747
|
lastSync: state.lastSync,
|
|
772
748
|
});
|
|
773
|
-
if (
|
|
749
|
+
if (canSkipWithoutHashing({
|
|
750
|
+
freshRelease,
|
|
751
|
+
fileExists: existsSync(pathFor(entry)),
|
|
752
|
+
cloudSha256: entry.sha256,
|
|
753
|
+
previousCloudSha256,
|
|
754
|
+
cloudUpdatedAt: entry.updated_at,
|
|
755
|
+
previousCloudUpdatedAt: synced[entry.key]?.cloudUpdatedAt,
|
|
756
|
+
})) {
|
|
774
757
|
shaByEntryId.set(entry.id, null);
|
|
775
758
|
unchanged++;
|
|
776
759
|
if (entry.size_bytes)
|
|
@@ -911,8 +894,8 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
911
894
|
};
|
|
912
895
|
for (const p of renameStalePaths)
|
|
913
896
|
addOrphan(p);
|
|
914
|
-
//
|
|
915
|
-
//
|
|
897
|
+
// Deselected artboards: their PNG (and Unity .meta) leaves the game project
|
|
898
|
+
// on the next sync, incremental or not. Working-set originals
|
|
916
899
|
// are never in this list.
|
|
917
900
|
for (const p of deselectedPaths)
|
|
918
901
|
addOrphan(p);
|
|
@@ -953,7 +936,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
953
936
|
}
|
|
954
937
|
}
|
|
955
938
|
// A locally-edited file is never deleted, whatever put it in the prune set
|
|
956
|
-
// (deselected
|
|
939
|
+
// (deselected game sync, trashed document, rename fallback,
|
|
957
940
|
// previous-key sweep): that edit is the only copy of the work until the user
|
|
958
941
|
// pushes it or discards it. Manifest-resident conflicts were already detected
|
|
959
942
|
// during the diff, so they skip the re-hash.
|
|
@@ -1317,6 +1300,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1317
1300
|
sha256: sha,
|
|
1318
1301
|
diskSha256,
|
|
1319
1302
|
legacy: true,
|
|
1303
|
+
...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
|
|
1320
1304
|
...(sourceRel ? { sourceRel } : {}),
|
|
1321
1305
|
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1322
1306
|
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
|
@@ -1329,6 +1313,7 @@ async function runOnce(opts, runOpts = {}) {
|
|
|
1329
1313
|
sha256: sha,
|
|
1330
1314
|
diskSha256,
|
|
1331
1315
|
...(typeof entry.layer_count === 'number' ? { layers: entry.layer_count } : {}),
|
|
1316
|
+
...(entry.updated_at ? { cloudUpdatedAt: entry.updated_at } : {}),
|
|
1332
1317
|
...(sourceRel ? { sourceRel } : {}),
|
|
1333
1318
|
...(entry.released_at ? { releasedAt: entry.released_at } : {}),
|
|
1334
1319
|
...(entry.released_version != null ? { releasedVersion: entry.released_version } : {}),
|
package/dist/config.js
CHANGED
|
@@ -30,6 +30,30 @@ export function hasNewExplicitRelease(opts) {
|
|
|
30
30
|
const previous = Date.parse(baseline);
|
|
31
31
|
return Number.isFinite(previous) && released > previous;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Cheap "nothing to do" test that runs before we hash the local PNG and before
|
|
35
|
+
* any per-file request. Two ways to prove a row is unchanged:
|
|
36
|
+
*
|
|
37
|
+
* 1. The manifest still reports the exact cloud composite sha we recorded.
|
|
38
|
+
* 2. The manifest has no sha at all (legacy / uncached documents) but the
|
|
39
|
+
* row's `updated_at` has not moved since we last synced this key. A row
|
|
40
|
+
* cannot change — including an editor Sync release — without its
|
|
41
|
+
* `updated_at` advancing, so there is nothing to fetch.
|
|
42
|
+
*
|
|
43
|
+
* Case 2 is what stops a quiet project from spending one conditional download
|
|
44
|
+
* per file on every sync just to be told "304 Not Modified".
|
|
45
|
+
*/
|
|
46
|
+
export function canSkipWithoutHashing(opts) {
|
|
47
|
+
if (opts.freshRelease || !opts.fileExists)
|
|
48
|
+
return false;
|
|
49
|
+
if (opts.cloudSha256 && opts.previousCloudSha256 === opts.cloudSha256)
|
|
50
|
+
return true;
|
|
51
|
+
if (opts.cloudSha256)
|
|
52
|
+
return false;
|
|
53
|
+
return Boolean(opts.cloudUpdatedAt &&
|
|
54
|
+
opts.previousCloudUpdatedAt &&
|
|
55
|
+
opts.cloudUpdatedAt === opts.previousCloudUpdatedAt);
|
|
56
|
+
}
|
|
33
57
|
export function decidePull(opts) {
|
|
34
58
|
const cloud = opts.cloudSha256 ?? null;
|
|
35
59
|
const local = opts.localSha256;
|
package/dist/util/unityFilter.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-artboard
|
|
2
|
+
* Per-artboard game sync opt-in (strict).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* mirrors the in-app sync button and pulls ONLY the flagged ones.
|
|
4
|
+
* A project may hold 100 drafts but only 3 artboards that belong in the game,
|
|
5
|
+
* so the CLI mirrors the in-app Sync to Game action and pulls ONLY the
|
|
6
|
+
* explicitly flagged ones.
|
|
8
7
|
*
|
|
9
8
|
* Strict opt-in: `unity === true` syncs, anything else does not. A missing
|
|
10
9
|
* flag means the server didn't say (older edge deploy, or a budget-starved
|
|
@@ -12,15 +11,12 @@
|
|
|
12
11
|
* `unknown` so the caller can warn instead of silently syncing the whole
|
|
13
12
|
* library into someone's game project.
|
|
14
13
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* next manifest often omits `unity` (or rebuilds it as false because the
|
|
18
|
-
* layer JSON dropped `syncToUnity`). Those saves must still write back to
|
|
19
|
-
* the original PNG.
|
|
14
|
+
* There is deliberately no working-set override: a local path or previous
|
|
15
|
+
* download must never turn an unmarked sibling into a pull candidate.
|
|
20
16
|
*/
|
|
21
17
|
/**
|
|
22
18
|
* Split the manifest into syncable entries and withheld ones. Withheld entries
|
|
23
|
-
* are never pull candidates
|
|
19
|
+
* are never pull candidates and carry no bytes;
|
|
24
20
|
* callers use them only to protect what's already on disk from pruning.
|
|
25
21
|
*/
|
|
26
22
|
export function partitionWithheldEntries(manifest) {
|
|
@@ -30,10 +26,8 @@ export function partitionWithheldEntries(manifest) {
|
|
|
30
26
|
(e.withheld === true ? withheld : entries).push(e);
|
|
31
27
|
return { entries, withheld };
|
|
32
28
|
}
|
|
33
|
-
export function filterUnityManifest(manifest
|
|
29
|
+
export function filterUnityManifest(manifest) {
|
|
34
30
|
const syncable = partitionWithheldEntries(manifest).entries;
|
|
35
|
-
if (opts.syncAll)
|
|
36
|
-
return { entries: syncable, noneFlagged: false, unknown: [] };
|
|
37
31
|
const entries = syncable.filter((e) => e.unity === true);
|
|
38
32
|
const unknown = syncable.filter((e) => typeof e.unity !== 'boolean');
|
|
39
33
|
return {
|
|
@@ -69,24 +63,18 @@ export function isWorkingSetEntry(entry, pull) {
|
|
|
69
63
|
}
|
|
70
64
|
return false;
|
|
71
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether an explicitly unmarked manifest entry should leave the local game.
|
|
68
|
+
* A file previously pulled by MagicPixel is safe to reconcile even if it now
|
|
69
|
+
* matches a connect glob; a game-authored working-set file without a sync
|
|
70
|
+
* baseline remains protected.
|
|
71
|
+
*/
|
|
72
|
+
export function shouldPruneDeselectedEntry(entry, opts) {
|
|
73
|
+
if (!entry.key)
|
|
74
|
+
return false;
|
|
75
|
+
return !opts.sourceByKey.has(entry.key) || opts.syncedKeys.has(entry.key);
|
|
76
|
+
}
|
|
72
77
|
/**
|
|
73
78
|
* Flagged artboards, plus any working-set sprite whose Unity flag is missing
|
|
74
79
|
* or false after an editor save / index rebuild.
|
|
75
80
|
*/
|
|
76
|
-
export function applyUnityPullPolicy(manifest, opts = {}) {
|
|
77
|
-
const filtered = filterUnityManifest(manifest, { syncAll: opts.syncAll });
|
|
78
|
-
if (opts.syncAll || !opts.alwaysPull)
|
|
79
|
-
return filtered;
|
|
80
|
-
const already = new Set(filtered.entries);
|
|
81
|
-
// Withheld entries stay out: a working-set sprite the user hasn't released
|
|
82
|
-
// must not be pulled over the copy in the game project.
|
|
83
|
-
const extra = partitionWithheldEntries(manifest).entries.filter((e) => !already.has(e) && opts.alwaysPull(e));
|
|
84
|
-
if (extra.length === 0)
|
|
85
|
-
return filtered;
|
|
86
|
-
const extraSet = new Set(extra);
|
|
87
|
-
return {
|
|
88
|
-
entries: filtered.entries.concat(extra),
|
|
89
|
-
unknown: filtered.unknown.filter((e) => !extraSet.has(e)),
|
|
90
|
-
noneFlagged: false,
|
|
91
|
-
};
|
|
92
|
-
}
|