@gmickel/gno 1.32.0 → 1.33.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 (33) hide show
  1. package/README.md +11 -2
  2. package/assets/skill/SKILL.md +11 -0
  3. package/assets/skill/cli-reference.md +10 -2
  4. package/browser-extension/artifacts/{gno-browser-clipper-v1.32.0.zip → gno-browser-clipper-v1.33.0.zip} +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.33.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/manifest.json +1 -1
  7. package/package.json +5 -1
  8. package/spec/cli.md +16 -1
  9. package/spec/output-schemas/publish-artifact.schema.json +76 -1
  10. package/src/cli/commands/publish.ts +43 -7
  11. package/src/ingestion/strip.ts +152 -26
  12. package/src/publish/artifact-asset-codec.ts +75 -0
  13. package/src/publish/artifact-asset-contract.ts +152 -0
  14. package/src/publish/artifact-asset-parse.ts +401 -0
  15. package/src/publish/artifact-asset-sniff.ts +108 -0
  16. package/src/publish/artifact-asset-validate.ts +209 -0
  17. package/src/publish/artifact-assets.ts +58 -0
  18. package/src/publish/artifact-validation.ts +32 -6
  19. package/src/publish/artifact.ts +50 -3
  20. package/src/publish/attachment-bundle.ts +145 -0
  21. package/src/publish/attachment-discover.ts +203 -0
  22. package/src/publish/attachment-load.ts +133 -0
  23. package/src/publish/attachment-obsidian.ts +45 -0
  24. package/src/publish/attachment-path.ts +334 -0
  25. package/src/publish/attachment-raster.ts +852 -0
  26. package/src/publish/attachment-resolver.ts +280 -0
  27. package/src/publish/attachment-types.ts +54 -0
  28. package/src/publish/encrypted-export.ts +121 -44
  29. package/src/publish/export-attachments.ts +224 -0
  30. package/src/publish/export-service.ts +142 -80
  31. package/src/publish/obsidian-sanitize.ts +121 -13
  32. package/src/serve/routes/api.ts +2 -1
  33. package/browser-extension/artifacts/gno-browser-clipper-v1.32.0.zip.sha256 +0 -1
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Shared note sanitize + v1/v2 asset finalize helpers for publish export.
3
+ *
4
+ * @module src/publish/export-attachments
5
+ */
6
+
7
+ import type { EgressLineage } from "../core/egress-provenance";
8
+ import type {
9
+ PublishArtifactNote,
10
+ PublishArtifactV1,
11
+ PublishArtifactV2,
12
+ } from "./artifact";
13
+ import type { AttachmentDiagnostic } from "./attachment-types";
14
+
15
+ import { stripFrontmatter } from "../ingestion/frontmatter";
16
+ import {
17
+ BUNDLED_RASTER_ASSETS_CAPABILITY,
18
+ buildEncryptedPublishArtifact,
19
+ } from "./artifact";
20
+ import { measureArtifactUploadBytes } from "./artifact-asset-codec";
21
+ import { validatePublishAssetContract } from "./artifact-asset-validate";
22
+ import {
23
+ attachAssetsToV1Artifact,
24
+ buildDeterministicAssets,
25
+ summarizeAssetEgress,
26
+ type PendingAssetPayload,
27
+ type PublishAssetEgressSummary,
28
+ } from "./attachment-resolver";
29
+ import { buildEncryptedArtifactPayload } from "./encrypted-export";
30
+ import {
31
+ sanitizeObsidianMarkdown,
32
+ sanitizePublishMarkdown,
33
+ type SanitizeWarning,
34
+ } from "./obsidian-sanitize";
35
+
36
+ export interface NoteBuildAccumulator {
37
+ diagnostics: AttachmentDiagnostic[];
38
+ encodedAssetBytes: number;
39
+ externalCount: number;
40
+ payloads: Map<string, PendingAssetPayload>;
41
+ preDedupRawBytes: number;
42
+ }
43
+
44
+ export async function sanitizeNoteMarkdown(input: {
45
+ basenameIndex: Map<string, string[]> | null;
46
+ collectionExcludes?: readonly string[];
47
+ collectionRoot: string | null;
48
+ existingAssetIds?: ReadonlySet<string>;
49
+ existingEncodedAssetBytes?: number;
50
+ noteSlug: string;
51
+ rawMarkdown: string;
52
+ sourceRelPath: string;
53
+ warnings: SanitizeWarning[];
54
+ }): Promise<{
55
+ diagnostics: AttachmentDiagnostic[];
56
+ externalCount: number;
57
+ markdown: string;
58
+ payloads: Map<string, PendingAssetPayload>;
59
+ preDedupRawBytes: number;
60
+ }> {
61
+ if (input.basenameIndex && input.collectionRoot) {
62
+ const sanitized = await sanitizePublishMarkdown(input.rawMarkdown, {
63
+ basenameIndex: input.basenameIndex,
64
+ collectionExcludes: input.collectionExcludes,
65
+ collectionRoot: input.collectionRoot,
66
+ existingAssetIds: input.existingAssetIds,
67
+ existingEncodedAssetBytes: input.existingEncodedAssetBytes,
68
+ noteSlug: input.noteSlug,
69
+ sourceRelPath: input.sourceRelPath,
70
+ });
71
+ input.warnings.push(...sanitized.warnings);
72
+ return {
73
+ diagnostics: sanitized.diagnostics,
74
+ externalCount: sanitized.externalCount,
75
+ markdown: stripFrontmatter(sanitized.markdown),
76
+ payloads: sanitized.payloads,
77
+ preDedupRawBytes: sanitized.preDedupRawBytes,
78
+ };
79
+ }
80
+
81
+ const sanitized = sanitizeObsidianMarkdown(input.rawMarkdown);
82
+ input.warnings.push(...sanitized.warnings);
83
+ return {
84
+ diagnostics: [],
85
+ externalCount: 0,
86
+ markdown: stripFrontmatter(sanitized.markdown),
87
+ payloads: new Map(),
88
+ preDedupRawBytes: 0,
89
+ };
90
+ }
91
+
92
+ export function mergePayloads(
93
+ target: Map<string, PendingAssetPayload>,
94
+ source: Map<string, PendingAssetPayload>
95
+ ): number {
96
+ let addedEncodedBytes = 0;
97
+ for (const [id, payload] of source) {
98
+ const existing = target.get(id);
99
+ if (!existing) {
100
+ target.set(id, {
101
+ ...payload,
102
+ references: [...payload.references],
103
+ });
104
+ addedEncodedBytes += payload.data.length;
105
+ continue;
106
+ }
107
+ existing.references.push(...payload.references);
108
+ }
109
+ return addedEncodedBytes;
110
+ }
111
+
112
+ export function finalizeV1Artifact(
113
+ artifact: PublishArtifactV1,
114
+ acc: NoteBuildAccumulator
115
+ ): { artifact: PublishArtifactV1; assetSummary: PublishAssetEgressSummary } {
116
+ const assets = buildDeterministicAssets(acc.payloads);
117
+ const withAssets = attachAssetsToV1Artifact(artifact, assets);
118
+ const contract = validatePublishAssetContract(withAssets, {
119
+ serializedUploadBytes: measureArtifactUploadBytes(withAssets),
120
+ });
121
+ if (!contract.ok) {
122
+ throw new Error(
123
+ `${contract.diagnostic.code}: ${contract.diagnostic.message}`
124
+ );
125
+ }
126
+ const assetSummary = summarizeAssetEgress({
127
+ artifact: withAssets,
128
+ diagnostics: acc.diagnostics,
129
+ externalCount: acc.externalCount,
130
+ preDedupRawBytes: acc.preDedupRawBytes,
131
+ });
132
+ return { artifact: withAssets, assetSummary };
133
+ }
134
+
135
+ /** Validate assets, encrypt inside ReaderSpaceData, enforce outer upload size. */
136
+ export async function finalizeEncryptedArtifact(input: {
137
+ acc: NoteBuildAccumulator;
138
+ egressLineage: EgressLineage;
139
+ exportedAt: string;
140
+ homeNoteSlug?: string;
141
+ notes: PublishArtifactNote[];
142
+ passphrase: string;
143
+ routeSlug: string;
144
+ sourceType: "note" | "collection";
145
+ summary: string;
146
+ title: string;
147
+ }): Promise<{
148
+ artifact: PublishArtifactV2;
149
+ assetSummary: PublishAssetEgressSummary;
150
+ }> {
151
+ const assets = buildDeterministicAssets(input.acc.payloads);
152
+ // Probe uses a non-encrypted visibility so asset contract validation runs
153
+ // without requiring a public manifest; ciphertext wraps the real payload.
154
+ const space: PublishArtifactV1["spaces"][number] = {
155
+ notes: input.notes,
156
+ routeSlug: input.routeSlug,
157
+ sourceType: input.sourceType,
158
+ summary: input.summary,
159
+ title: input.title,
160
+ visibility: "secret-link",
161
+ };
162
+ if (input.homeNoteSlug !== undefined) {
163
+ space.homeNoteSlug = input.homeNoteSlug;
164
+ }
165
+ const probe: PublishArtifactV1 = {
166
+ egressLineage: input.egressLineage,
167
+ exportedAt: input.exportedAt,
168
+ source: input.routeSlug,
169
+ spaces: [space],
170
+ version: 1,
171
+ ...(assets.length > 0
172
+ ? {
173
+ assets,
174
+ requiredCapabilities: [BUNDLED_RASTER_ASSETS_CAPABILITY],
175
+ }
176
+ : {}),
177
+ };
178
+ const contract = validatePublishAssetContract(probe);
179
+ if (!contract.ok) {
180
+ throw new Error(
181
+ `${contract.diagnostic.code}: ${contract.diagnostic.message}`
182
+ );
183
+ }
184
+
185
+ const encrypted = await buildEncryptedArtifactPayload({
186
+ assets,
187
+ exportedAt: input.exportedAt,
188
+ homeNoteSlug: input.homeNoteSlug,
189
+ notes: input.notes,
190
+ passphrase: input.passphrase,
191
+ routeSlug: input.routeSlug,
192
+ sourceType: input.sourceType,
193
+ summary: input.summary,
194
+ title: input.title,
195
+ });
196
+
197
+ const artifact = buildEncryptedPublishArtifact({
198
+ egressLineage: input.egressLineage,
199
+ encryptedPayload: encrypted.encryptedPayload,
200
+ requiredCapabilities:
201
+ assets.length > 0 ? [BUNDLED_RASTER_ASSETS_CAPABILITY] : undefined,
202
+ routeSlug: input.routeSlug,
203
+ secretToken: encrypted.secretToken,
204
+ sourceType: input.sourceType,
205
+ });
206
+
207
+ const outerContract = validatePublishAssetContract(artifact, {
208
+ serializedUploadBytes: measureArtifactUploadBytes(artifact),
209
+ });
210
+ if (!outerContract.ok) {
211
+ throw new Error(
212
+ `${outerContract.diagnostic.code}: ${outerContract.diagnostic.message}`
213
+ );
214
+ }
215
+
216
+ const assetSummary = summarizeAssetEgress({
217
+ assets,
218
+ artifact,
219
+ diagnostics: input.acc.diagnostics,
220
+ externalCount: input.acc.externalCount,
221
+ preDedupRawBytes: input.acc.preDedupRawBytes,
222
+ });
223
+ return { artifact, assetSummary };
224
+ }
@@ -9,10 +9,9 @@ import type { DocumentRow, StorePort, TagRow } from "../store/types";
9
9
 
10
10
  import { enforceCollectionEgressWithAudit } from "../core/egress-enforcement";
11
11
  import { parseRef } from "../core/ref-parser";
12
- import { parseFrontmatter, stripFrontmatter } from "../ingestion/frontmatter";
12
+ import { parseFrontmatter } from "../ingestion/frontmatter";
13
13
  import { getContentBatch } from "../store/content-batch";
14
14
  import {
15
- buildEncryptedPublishArtifact,
16
15
  buildPublishArtifact,
17
16
  buildExportedMetadata,
18
17
  derivePublishSlug,
@@ -24,10 +23,19 @@ import {
24
23
  type PublishArtifactNote,
25
24
  type PublishVisibility,
26
25
  } from "./artifact";
27
- import { buildEncryptedArtifactPayload } from "./encrypted-export";
26
+ import {
27
+ buildAttachmentBasenameIndex,
28
+ type PublishAssetEgressSummary,
29
+ } from "./attachment-resolver";
30
+ import {
31
+ finalizeEncryptedArtifact,
32
+ finalizeV1Artifact,
33
+ mergePayloads,
34
+ sanitizeNoteMarkdown,
35
+ type NoteBuildAccumulator,
36
+ } from "./export-attachments";
28
37
  import {
29
38
  isPublishDisabledByFrontmatter,
30
- sanitizeObsidianMarkdown,
31
39
  type SanitizeWarning,
32
40
  } from "./obsidian-sanitize";
33
41
 
@@ -138,7 +146,10 @@ async function exportCollectionArtifact(
138
146
  target: string,
139
147
  options: PublishExportCoreOptions,
140
148
  warnings: SanitizeWarning[]
141
- ) {
149
+ ): Promise<{
150
+ artifact: PublishArtifact;
151
+ assetSummary: PublishAssetEgressSummary;
152
+ } | null> {
142
153
  const collection = resolveCollection(collections, target);
143
154
  if (!collection) {
144
155
  return null;
@@ -174,8 +185,21 @@ async function exportCollectionArtifact(
174
185
 
175
186
  const contentByHash = contentResult.value;
176
187
  const tagsByDocId = tagsResult.value;
188
+ const visibility = resolveVisibility(options.visibility);
189
+ const basenameIndex = await buildAttachmentBasenameIndex(
190
+ collection.path,
191
+ collection.exclude
192
+ );
177
193
 
194
+ const acc: NoteBuildAccumulator = {
195
+ diagnostics: [],
196
+ encodedAssetBytes: 0,
197
+ externalCount: 0,
198
+ payloads: new Map(),
199
+ preDedupRawBytes: 0,
200
+ };
178
201
  const notes: PublishArtifactNote[] = [];
202
+
179
203
  for (const doc of activeDocs) {
180
204
  if (!doc.mirrorHash) {
181
205
  throw new Error(`Document has no converted content: ${doc.uri}`);
@@ -187,17 +211,34 @@ async function exportCollectionArtifact(
187
211
  if (isPublishDisabledByFrontmatter(rawMarkdown)) {
188
212
  continue;
189
213
  }
214
+
190
215
  const frontmatter = parseFrontmatter(rawMarkdown).metadata;
191
- const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
192
- warnings.push(...sanitized.warnings);
193
- const markdown = stripFrontmatter(sanitized.markdown);
194
- const tags = tagsByDocId.get(doc.id) ?? [];
195
216
  const title = deriveExportedTitle(doc);
217
+ const slug = deriveExportedSlug(doc);
218
+ const sanitized = await sanitizeNoteMarkdown({
219
+ basenameIndex,
220
+ collectionExcludes: collection.exclude,
221
+ collectionRoot: collection.path,
222
+ existingAssetIds: new Set(acc.payloads.keys()),
223
+ existingEncodedAssetBytes: acc.encodedAssetBytes,
224
+ noteSlug: slug,
225
+ rawMarkdown,
226
+ sourceRelPath: doc.relPath,
227
+ warnings,
228
+ });
229
+ acc.diagnostics.push(...sanitized.diagnostics);
230
+ acc.externalCount += sanitized.externalCount;
231
+ acc.preDedupRawBytes += sanitized.preDedupRawBytes;
232
+ acc.encodedAssetBytes += mergePayloads(acc.payloads, sanitized.payloads);
196
233
  notes.push({
197
- markdown,
198
- metadata: buildExportedMetadata(doc, frontmatter, tags),
199
- slug: deriveExportedSlug(doc),
200
- summary: deriveExportedSummary(markdown, frontmatter),
234
+ markdown: sanitized.markdown,
235
+ metadata: buildExportedMetadata(
236
+ doc,
237
+ frontmatter,
238
+ tagsByDocId.get(doc.id) ?? []
239
+ ),
240
+ slug,
241
+ summary: deriveExportedSummary(sanitized.markdown, frontmatter),
201
242
  title,
202
243
  });
203
244
  }
@@ -217,7 +258,6 @@ async function exportCollectionArtifact(
217
258
  collection.name,
218
259
  target,
219
260
  ]);
220
- const visibility = resolveVisibility(options.visibility);
221
261
  const { lineage } = await enforceCollectionEgressWithAudit({
222
262
  collections,
223
263
  collectionNames: [collection.name],
@@ -235,7 +275,9 @@ async function exportCollectionArtifact(
235
275
  );
236
276
  }
237
277
 
238
- const encrypted = await buildEncryptedArtifactPayload({
278
+ return finalizeEncryptedArtifact({
279
+ acc,
280
+ egressLineage: lineage,
239
281
  exportedAt: new Date().toISOString(),
240
282
  homeNoteSlug: chooseHomeNoteSlug(notes),
241
283
  notes,
@@ -245,26 +287,21 @@ async function exportCollectionArtifact(
245
287
  summary,
246
288
  title,
247
289
  });
290
+ }
248
291
 
249
- return buildEncryptedPublishArtifact({
292
+ return finalizeV1Artifact(
293
+ buildPublishArtifact({
250
294
  egressLineage: lineage,
251
- encryptedPayload: encrypted.encryptedPayload,
295
+ homeNoteSlug: chooseHomeNoteSlug(notes),
296
+ notes,
252
297
  routeSlug,
253
- secretToken: encrypted.secretToken,
254
298
  sourceType: "collection",
255
- });
256
- }
257
-
258
- return buildPublishArtifact({
259
- egressLineage: lineage,
260
- homeNoteSlug: chooseHomeNoteSlug(notes),
261
- notes,
262
- routeSlug,
263
- sourceType: "collection",
264
- summary,
265
- title,
266
- visibility,
267
- });
299
+ summary,
300
+ title,
301
+ visibility,
302
+ }),
303
+ acc
304
+ );
268
305
  }
269
306
 
270
307
  async function exportDocumentArtifact(
@@ -273,28 +310,47 @@ async function exportDocumentArtifact(
273
310
  target: string,
274
311
  options: PublishExportCoreOptions,
275
312
  warnings: SanitizeWarning[]
276
- ) {
313
+ ): Promise<{
314
+ artifact: PublishArtifact;
315
+ assetSummary: PublishAssetEgressSummary;
316
+ }> {
277
317
  const doc = await lookupDocument(store, target);
278
318
  if (!doc?.active) {
279
319
  throw new Error(`Document not found: ${target}`);
280
320
  }
281
321
 
322
+ const collection =
323
+ collections.find((entry) => entry.name === doc.collection) ?? null;
282
324
  const rawMarkdown = await loadDocumentMarkdown(store, doc);
283
325
  if (isPublishDisabledByFrontmatter(rawMarkdown)) {
284
326
  throw new Error(
285
327
  `Refused to export: ${doc.uri} has publish: false in frontmatter`
286
328
  );
287
329
  }
330
+
288
331
  const frontmatter = parseFrontmatter(rawMarkdown).metadata;
289
- const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
290
- warnings.push(...sanitized.warnings);
291
- const markdown = stripFrontmatter(sanitized.markdown);
292
- const tags = await loadDocumentTags(store, doc);
293
332
  const title = options.title ?? deriveExportedTitle(doc);
294
- const summary =
295
- options.summary ?? deriveExportedSummary(markdown, frontmatter);
296
333
  const slug = deriveExportedSlug(doc);
297
334
  const visibility = resolveVisibility(options.visibility);
335
+ const bundleAttachments = collection !== null;
336
+ const basenameIndex =
337
+ bundleAttachments && collection
338
+ ? await buildAttachmentBasenameIndex(collection.path, collection.exclude)
339
+ : null;
340
+
341
+ const sanitized = await sanitizeNoteMarkdown({
342
+ basenameIndex,
343
+ collectionExcludes: collection?.exclude,
344
+ collectionRoot: bundleAttachments && collection ? collection.path : null,
345
+ noteSlug: slug,
346
+ rawMarkdown,
347
+ sourceRelPath: doc.relPath,
348
+ warnings,
349
+ });
350
+ const markdown = sanitized.markdown;
351
+ const summary =
352
+ options.summary ?? deriveExportedSummary(markdown, frontmatter);
353
+ const tags = await loadDocumentTags(store, doc);
298
354
  const routeSlug = derivePublishSlug([options.routeSlug ?? "", slug, target]);
299
355
  const { lineage } = await enforceCollectionEgressWithAudit({
300
356
  collections,
@@ -306,6 +362,25 @@ async function exportDocumentArtifact(
306
362
  store,
307
363
  });
308
364
 
365
+ const note: PublishArtifactNote = {
366
+ markdown,
367
+ metadata: buildExportedMetadata(doc, frontmatter, tags),
368
+ slug,
369
+ summary,
370
+ title,
371
+ };
372
+
373
+ const acc: NoteBuildAccumulator = {
374
+ diagnostics: sanitized.diagnostics,
375
+ encodedAssetBytes: [...sanitized.payloads.values()].reduce(
376
+ (total, payload) => total + payload.data.length,
377
+ 0
378
+ ),
379
+ externalCount: sanitized.externalCount,
380
+ payloads: sanitized.payloads,
381
+ preDedupRawBytes: sanitized.preDedupRawBytes,
382
+ };
383
+
309
384
  if (visibility === "encrypted") {
310
385
  if (!options.encryptionPassphrase) {
311
386
  throw new Error(
@@ -313,54 +388,36 @@ async function exportDocumentArtifact(
313
388
  );
314
389
  }
315
390
 
316
- const encrypted = await buildEncryptedArtifactPayload({
391
+ return finalizeEncryptedArtifact({
392
+ acc,
393
+ egressLineage: lineage,
317
394
  exportedAt: new Date().toISOString(),
318
- notes: [
319
- {
320
- markdown,
321
- metadata: buildExportedMetadata(doc, frontmatter, tags),
322
- slug,
323
- summary,
324
- title,
325
- },
326
- ],
395
+ notes: [note],
327
396
  passphrase: options.encryptionPassphrase,
328
397
  routeSlug,
329
398
  sourceType: "note",
330
399
  summary,
331
400
  title,
332
401
  });
402
+ }
333
403
 
334
- return buildEncryptedPublishArtifact({
404
+ return finalizeV1Artifact(
405
+ buildPublishArtifact({
335
406
  egressLineage: lineage,
336
- encryptedPayload: encrypted.encryptedPayload,
407
+ notes: [note],
337
408
  routeSlug,
338
- secretToken: encrypted.secretToken,
339
409
  sourceType: "note",
340
- });
341
- }
342
-
343
- return buildPublishArtifact({
344
- egressLineage: lineage,
345
- notes: [
346
- {
347
- markdown,
348
- metadata: buildExportedMetadata(doc, frontmatter, tags),
349
- slug,
350
- summary,
351
- title,
352
- },
353
- ],
354
- routeSlug,
355
- sourceType: "note",
356
- summary,
357
- title,
358
- visibility,
359
- });
410
+ summary,
411
+ title,
412
+ visibility,
413
+ }),
414
+ acc
415
+ );
360
416
  }
361
417
 
362
418
  export interface ExportPublishArtifactResult {
363
419
  artifact: PublishArtifact;
420
+ assetSummary: PublishAssetEgressSummary;
364
421
  warnings: SanitizeWarning[];
365
422
  }
366
423
 
@@ -371,14 +428,15 @@ export async function exportPublishArtifact(input: {
371
428
  target: string;
372
429
  }): Promise<ExportPublishArtifactResult> {
373
430
  const warnings: SanitizeWarning[] = [];
374
- const artifact =
375
- (await exportCollectionArtifact(
376
- input.store,
377
- input.collections,
378
- input.target,
379
- input.options,
380
- warnings
381
- )) ??
431
+ const collectionExport = await exportCollectionArtifact(
432
+ input.store,
433
+ input.collections,
434
+ input.target,
435
+ input.options,
436
+ warnings
437
+ );
438
+ const result =
439
+ collectionExport ??
382
440
  (await exportDocumentArtifact(
383
441
  input.store,
384
442
  input.collections,
@@ -386,5 +444,9 @@ export async function exportPublishArtifact(input: {
386
444
  input.options,
387
445
  warnings
388
446
  ));
389
- return { artifact, warnings };
447
+ return {
448
+ artifact: result.artifact,
449
+ assetSummary: result.assetSummary,
450
+ warnings,
451
+ };
390
452
  }