claude-plan-viewer 1.1.1 → 1.4.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/index.ts CHANGED
@@ -1,23 +1,148 @@
1
1
  #!/usr/bin/env bun
2
- import { readdir, stat } from "node:fs/promises";
2
+ import { readdir, stat, watch } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { homedir } from "node:os";
5
- import index from "./index.html";
6
- import prismBundlePath from "./prism.bundle.js" with { type: "file" };
7
-
8
- const PLANS_DIR = join(homedir(), ".claude", "plans");
9
- const PROJECTS_DIR = join(homedir(), ".claude", "projects");
10
-
11
- // Parse --port from command line arguments (undefined = auto-assign)
12
- function getRequestedPort(): number | undefined {
13
- const args = process.argv;
14
- const portIndex = args.indexOf("--port");
15
- const portArg = args[portIndex + 1];
16
- if (portIndex !== -1 && portArg) {
17
- const port = parseInt(portArg, 10);
18
- if (!isNaN(port)) return port;
5
+ import index from "./src/index.html";
6
+ import apiDocs from "./src/api-docs.html";
7
+ import pkg from "./package.json";
8
+ import openapi from "./openapi.json";
9
+
10
+ // Resolved at startup based on --claude-dir flag or CLAUDE_DIR env var
11
+ let PLANS_DIR: string;
12
+ let PROJECTS_DIR: string;
13
+
14
+ function resolveClaudeDir(cliArg?: string): string {
15
+ return cliArg || process.env.CLAUDE_DIR || join(homedir(), ".claude");
16
+ }
17
+
18
+ function initializeDirectories(claudeDir: string): void {
19
+ PLANS_DIR = join(claudeDir, "plans");
20
+ PROJECTS_DIR = join(claudeDir, "projects");
21
+ }
22
+
23
+ interface CliArgs {
24
+ port?: number;
25
+ json?: boolean;
26
+ output?: string;
27
+ fromFile?: string;
28
+ claudeDir?: string;
29
+ version?: boolean;
30
+ help?: boolean;
31
+ }
32
+
33
+ function parseCliArgs(): CliArgs {
34
+ const args: CliArgs = {};
35
+ const argv = process.argv.slice(2);
36
+
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const arg = argv[i];
39
+ const nextArg = argv[i + 1];
40
+
41
+ if (arg === "--port" || arg === "-p") {
42
+ if (nextArg && !nextArg.startsWith("-")) {
43
+ args.port = parseInt(nextArg, 10);
44
+ i++;
45
+ }
46
+ } else if (arg === "--json" || arg === "-j") {
47
+ args.json = true;
48
+ } else if (arg === "--output" || arg === "-o") {
49
+ if (nextArg && !nextArg.startsWith("-")) {
50
+ args.output = nextArg;
51
+ i++;
52
+ }
53
+ } else if (arg === "--from-file" || arg === "-f") {
54
+ if (nextArg && !nextArg.startsWith("-")) {
55
+ args.fromFile = nextArg;
56
+ i++;
57
+ }
58
+ } else if (arg === "--claude-dir" || arg === "-c") {
59
+ if (nextArg && !nextArg.startsWith("-")) {
60
+ args.claudeDir = nextArg;
61
+ i++;
62
+ }
63
+ } else if (arg === "--version" || arg === "-v") {
64
+ args.version = true;
65
+ } else if (arg === "--help" || arg === "-h") {
66
+ args.help = true;
67
+ }
68
+ }
69
+
70
+ return args;
71
+ }
72
+
73
+ function printHelp(): void {
74
+ console.log(`
75
+ claude-plan-viewer - Browse and search Claude Code plans
76
+
77
+ Usage: claude-plan-viewer [options]
78
+
79
+ Options:
80
+ -p, --port <number> Port to start server on (default: 3000)
81
+ -c, --claude-dir <path> Path to .claude directory (default: ~/.claude)
82
+ Can also be set via CLAUDE_DIR environment variable
83
+ -j, --json Export all plans as JSON and exit
84
+ -o, --output <file> Output file for JSON export (stdout if omitted)
85
+ -f, --from-file <file> Load plans from JSON file instead of ~/.claude/plans
86
+ -v, --version Show version number
87
+ -h, --help Show this help message
88
+
89
+ Examples:
90
+ claude-plan-viewer Start viewer on default port
91
+ claude-plan-viewer -p 8080 Start on port 8080
92
+ claude-plan-viewer -c /path/to/.claude Use custom .claude directory
93
+ claude-plan-viewer -j -o plans.json Export plans to file
94
+ claude-plan-viewer -f plans.json Load plans from exported file
95
+ `);
96
+ }
97
+
98
+ async function exportPlansAsJson(outputPath?: string): Promise<void> {
99
+ const plans = await loadPlans();
100
+
101
+ const plansWithContent = plans.map((plan) => ({
102
+ ...plan,
103
+ content: contentCache.get(plan.filename) || "",
104
+ }));
105
+
106
+ const jsonOutput = JSON.stringify(plansWithContent, null, 2);
107
+
108
+ if (outputPath) {
109
+ await Bun.write(outputPath, jsonOutput);
110
+ console.log(`Exported ${plans.length} plans to ${outputPath}`);
111
+ } else {
112
+ console.log(jsonOutput);
113
+ }
114
+ }
115
+
116
+ async function loadPlansFromFile(filepath: string): Promise<PlanMetadata[]> {
117
+ const file = Bun.file(filepath);
118
+ const exists = await file.exists();
119
+
120
+ if (!exists) {
121
+ console.error(`File not found: ${filepath}`);
122
+ process.exit(1);
123
+ }
124
+
125
+ const data = await file.json();
126
+ const plans: PlanMetadata[] = [];
127
+
128
+ for (const plan of data) {
129
+ contentCache.set(plan.filename, plan.content || "");
130
+ plans.push({
131
+ filename: plan.filename,
132
+ filepath: plan.filepath,
133
+ title: plan.title,
134
+ size: plan.size,
135
+ modified: plan.modified,
136
+ created: plan.created,
137
+ lineCount: plan.lineCount,
138
+ wordCount: plan.wordCount,
139
+ project: plan.project,
140
+ sessionId: plan.sessionId,
141
+ });
19
142
  }
20
- return undefined;
143
+
144
+ cachedPlans = plans;
145
+ return plans;
21
146
  }
22
147
 
23
148
  // Find an available port starting from the requested port
@@ -37,7 +162,9 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
37
162
  port++;
38
163
  }
39
164
  }
40
- throw new Error(`No available port found in range ${startPort}-${startPort + maxAttempts}`);
165
+ throw new Error(
166
+ `No available port found in range ${startPort}-${startPort + maxAttempts}`,
167
+ );
41
168
  }
42
169
 
43
170
  // Cross-platform open file in default editor
@@ -52,17 +179,21 @@ async function openInEditor(filepath: string): Promise<void> {
52
179
  }
53
180
  }
54
181
 
55
- interface Plan {
182
+ interface PlanMetadata {
56
183
  filename: string;
57
184
  filepath: string;
58
185
  title: string;
59
- content: string;
60
186
  size: number;
61
187
  modified: string;
62
188
  created: string;
63
189
  lineCount: number;
64
190
  wordCount: number;
65
191
  project: string | null;
192
+ sessionId: string | null;
193
+ }
194
+
195
+ interface Plan extends PlanMetadata {
196
+ content: string;
66
197
  }
67
198
 
68
199
  // Extract project name from a full path (cross-platform)
@@ -73,7 +204,9 @@ function extractProjectName(cwd: string): string {
73
204
  // Normalize: handle both / and \ separators
74
205
  const normalized = cwd.replace(/\\/g, "/");
75
206
  // Remove trailing slash
76
- const trimmed = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
207
+ const trimmed = normalized.endsWith("/")
208
+ ? normalized.slice(0, -1)
209
+ : normalized;
77
210
  // Get last segment
78
211
  const lastSlash = trimmed.lastIndexOf("/");
79
212
  return lastSlash === -1 ? trimmed : trimmed.slice(lastSlash + 1);
@@ -83,70 +216,123 @@ function extractProjectName(cwd: string): string {
83
216
  function extractCwdFromJsonl(content: string): string | null {
84
217
  if (!content) return null;
85
218
  const match = content.match(/"cwd":"([^"]+)"/);
86
- if (!match) return null;
219
+ if (!match || !match[1]) return null;
87
220
  // Unescape JSON string (convert \\\\ to \\)
88
221
  return match[1].replace(/\\\\/g, "\\");
89
222
  }
90
223
 
91
- // Extract unique slugs from JSONL content
224
+ // Extract slug -> sessionId mapping from JSONL content
225
+ // Each line in JSONL may contain both "slug" and "sessionId" fields
226
+ function extractSlugSessionMap(content: string): Map<string, string> {
227
+ if (!content) return new Map();
228
+ const slugSessionMap = new Map<string, string>();
229
+
230
+ // Process each line to find slug and sessionId pairs
231
+ const lines = content.split("\n");
232
+ for (const line of lines) {
233
+ if (!line.trim()) continue;
234
+
235
+ const slugMatch = line.match(/"slug":"([\w-]+)"/);
236
+ const sessionMatch = line.match(/"sessionId":"([^"]+)"/);
237
+
238
+ if (slugMatch && slugMatch[1] && sessionMatch && sessionMatch[1]) {
239
+ slugSessionMap.set(slugMatch[1], sessionMatch[1]);
240
+ }
241
+ }
242
+
243
+ return slugSessionMap;
244
+ }
245
+
246
+ // Extract unique slugs from JSONL content (for backwards compatibility)
92
247
  function extractSlugsFromJsonl(content: string): string[] {
93
248
  if (!content) return [];
94
249
  const slugs = new Set<string>();
95
250
  const matches = content.matchAll(/"slug":"([\w-]+)"/g);
96
251
  for (const match of matches) {
97
- slugs.add(match[1]);
252
+ if (match[1]) {
253
+ slugs.add(match[1]);
254
+ }
98
255
  }
99
256
  return Array.from(slugs);
100
257
  }
101
258
 
259
+ interface SlugMetadata {
260
+ project: string;
261
+ sessionId: string | null;
262
+ }
263
+
102
264
  interface ProjectMapping {
103
- [slug: string]: string; // plan slug -> project name
265
+ [slug: string]: SlugMetadata;
104
266
  }
105
267
 
106
- // Build a mapping of plan slugs to project names by scanning Claude Code's project metadata
268
+ // Build a mapping of plan slugs to project names and session IDs by scanning Claude Code's project metadata
107
269
  async function buildProjectMapping(): Promise<ProjectMapping> {
108
270
  const mapping: ProjectMapping = {};
109
271
 
110
272
  try {
111
273
  const projectDirs = await readdir(PROJECTS_DIR);
112
274
 
113
- for (const dir of projectDirs) {
114
- const dirPath = join(PROJECTS_DIR, dir);
115
- const dirStats = await stat(dirPath);
116
- if (!dirStats.isDirectory()) continue;
117
-
118
- // Find JSONL files and extract cwd + slugs
119
- const files = await readdir(dirPath);
120
- const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
121
-
122
- let projectName: string | null = null;
123
- const allSlugs: string[] = [];
124
-
125
- for (const file of jsonlFiles) {
275
+ // Process all project directories in parallel
276
+ const results = await Promise.all(
277
+ projectDirs.map(async (dir) => {
278
+ const dirPath = join(PROJECTS_DIR, dir);
126
279
  try {
127
- const content = await Bun.file(join(dirPath, file)).text();
280
+ const dirStats = await stat(dirPath);
281
+ if (!dirStats.isDirectory()) return null;
282
+
283
+ // Find JSONL files
284
+ const files = await readdir(dirPath);
285
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
286
+ if (jsonlFiles.length === 0) return null;
287
+
288
+ // Read all JSONL files in parallel
289
+ const fileContents = await Promise.all(
290
+ jsonlFiles.map(async (file) => {
291
+ try {
292
+ return await Bun.file(join(dirPath, file)).text();
293
+ } catch {
294
+ return null;
295
+ }
296
+ })
297
+ );
298
+
299
+ let projectName: string | null = null;
300
+ const slugSessionMap = new Map<string, string>();
301
+
302
+ // Process file contents
303
+ for (const content of fileContents) {
304
+ if (!content) continue;
305
+
306
+ // Get project name from cwd (only need to find it once)
307
+ if (!projectName) {
308
+ const cwd = extractCwdFromJsonl(content);
309
+ if (cwd) {
310
+ projectName = extractProjectName(cwd);
311
+ }
312
+ }
128
313
 
129
- // Get project name from cwd (only need to find it once)
130
- if (!projectName) {
131
- const cwd = extractCwdFromJsonl(content);
132
- if (cwd) {
133
- projectName = extractProjectName(cwd);
314
+ // Collect slug -> sessionId mappings
315
+ const fileSlugSessions = extractSlugSessionMap(content);
316
+ for (const [slug, sessionId] of fileSlugSessions) {
317
+ slugSessionMap.set(slug, sessionId);
134
318
  }
135
319
  }
136
320
 
137
- // Collect all slugs
138
- const slugs = extractSlugsFromJsonl(content);
139
- allSlugs.push(...slugs);
321
+ return { projectName, slugSessionMap };
140
322
  } catch {
141
- // Skip files that can't be read
142
- }
143
- }
144
-
145
- // Map all slugs to this project
146
- if (projectName) {
147
- for (const slug of allSlugs) {
148
- mapping[slug] = projectName;
323
+ return null;
149
324
  }
325
+ })
326
+ );
327
+
328
+ // Merge results into mapping
329
+ for (const result of results) {
330
+ if (!result?.projectName) continue;
331
+ for (const [slug, sessionId] of result.slugSessionMap) {
332
+ mapping[slug] = {
333
+ project: result.projectName,
334
+ sessionId: sessionId,
335
+ };
150
336
  }
151
337
  }
152
338
  } catch {
@@ -156,9 +342,19 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
156
342
  return mapping;
157
343
  }
158
344
 
159
- async function loadPlans(): Promise<Plan[]> {
160
- // Build project mapping from Claude Code metadata
161
- const projectMapping = await buildProjectMapping();
345
+ let cachedPlans: PlanMetadata[] | null = null;
346
+ let cachedProjectMapping: ProjectMapping | null = null;
347
+ const contentCache = new Map<string, string>();
348
+
349
+ async function loadPlans(): Promise<PlanMetadata[]> {
350
+ // Build or use cached project mapping
351
+ let projectMapping: ProjectMapping;
352
+ if (!cachedProjectMapping) {
353
+ projectMapping = await buildProjectMapping();
354
+ cachedProjectMapping = projectMapping;
355
+ } else {
356
+ projectMapping = cachedProjectMapping;
357
+ }
162
358
 
163
359
  const files = await readdir(PLANS_DIR);
164
360
  const mdFiles = files.filter((f) => f.endsWith(".md"));
@@ -167,10 +363,8 @@ async function loadPlans(): Promise<Plan[]> {
167
363
  mdFiles.map(async (filename) => {
168
364
  const filepath = join(PLANS_DIR, filename);
169
365
  const file = Bun.file(filepath);
170
- const [content, stats] = await Promise.all([
171
- file.text(),
172
- stat(filepath),
173
- ]);
366
+
367
+ const [content, stats] = await Promise.all([file.text(), stat(filepath)]);
174
368
 
175
369
  const titleMatch = content.match(/^#\s+(.+)$/m);
176
370
  const title = titleMatch?.[1]
@@ -179,42 +373,147 @@ async function loadPlans(): Promise<Plan[]> {
179
373
 
180
374
  // Look up project from metadata using plan slug (filename without .md)
181
375
  const slug = filename.replace(".md", "");
376
+ const lineCount = content.split("\n").length;
377
+ const wordCount = content.split(/\s+/).filter(Boolean).length;
378
+
379
+ const metadata = projectMapping[slug];
380
+ // Cache content separately for search and lazy loading
381
+ contentCache.set(filename, content);
182
382
 
183
383
  return {
184
384
  filename,
185
385
  filepath,
186
386
  title,
187
- content,
188
387
  size: stats.size,
189
388
  modified: stats.mtime.toISOString(),
190
389
  created: stats.birthtime.toISOString(),
191
- lineCount: content.split("\n").length,
192
- wordCount: content.split(/\s+/).filter(Boolean).length,
193
- project: projectMapping[slug] || null,
390
+ lineCount,
391
+ wordCount,
392
+ project: metadata?.project || null,
393
+ sessionId: metadata?.sessionId || null,
194
394
  };
195
- })
395
+ }),
196
396
  );
197
397
 
198
- return plans.sort(
199
- (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
200
- );
398
+ cachedPlans = plans;
399
+ return plans;
400
+ }
401
+
402
+ // Granular cache invalidation
403
+ function invalidatePlansCache() {
404
+ cachedPlans = null;
405
+ }
406
+
407
+ function invalidateProjectMapping() {
408
+ cachedProjectMapping = null;
409
+ }
410
+
411
+ function invalidateContentCache(filename?: string) {
412
+ if (filename) {
413
+ contentCache.delete(filename);
414
+ } else {
415
+ contentCache.clear();
416
+ }
417
+ }
418
+
419
+ function invalidateAllCaches() {
420
+ invalidatePlansCache();
421
+ invalidateProjectMapping();
422
+ invalidateContentCache();
423
+ }
424
+
425
+ // Watch plans directory for changes and invalidate cache
426
+ async function watchPlansDirectory() {
427
+ try {
428
+ const watcher = watch(PLANS_DIR);
429
+ for await (const event of watcher) {
430
+ if (event.filename?.endsWith(".md")) {
431
+ // Only invalidate plans metadata and the specific file's content
432
+ // Project mapping rarely changes, keep it cached
433
+ invalidatePlansCache();
434
+ invalidateContentCache(event.filename);
435
+ }
436
+ }
437
+ } catch {
438
+ // Directory may not exist or watching may not be supported
439
+ }
201
440
  }
202
441
 
203
442
  // Main server startup
204
443
  async function startServer() {
205
- const requestedPort = getRequestedPort();
206
- const port = await findAvailablePort(requestedPort ?? 3000);
444
+ const args = parseCliArgs();
445
+ const port = await findAvailablePort(args.port ?? 3000);
207
446
 
208
447
  const server = Bun.serve({
209
448
  port,
210
- static: {
211
- "/prism.bundle.js": Bun.file(prismBundlePath),
212
- },
213
449
  routes: {
214
450
  "/": index,
215
- "/api/plans": async () => {
216
- const plans = await loadPlans();
217
- return Response.json(plans);
451
+ "/api": () => Response.redirect("/api/", 301),
452
+ "/api/": apiDocs,
453
+ "/api/openapi.json": () => Response.json(openapi),
454
+ "/api/projects": async () => {
455
+ // Lazy load cache on first request
456
+ if (!cachedPlans) {
457
+ await loadPlans();
458
+ }
459
+
460
+ const plans = cachedPlans || [];
461
+ const projects = [
462
+ ...new Set(plans.map((p) => p.project).filter(Boolean)),
463
+ ] as string[];
464
+ projects.sort((a, b) => a.localeCompare(b));
465
+
466
+ return Response.json({ projects });
467
+ },
468
+ "/api/plans": async (req) => {
469
+ // Lazy load cache on first request
470
+ if (!cachedPlans) {
471
+ await loadPlans();
472
+ }
473
+
474
+ const plans = cachedPlans || [];
475
+
476
+ // Strip content from response - will be fetched separately via /api/plans/{id}/content
477
+ const plansWithoutContent = plans.map((p) => ({
478
+ filename: p.filename,
479
+ filepath: p.filepath,
480
+ title: p.title,
481
+ size: p.size,
482
+ modified: p.modified,
483
+ created: p.created,
484
+ lineCount: p.lineCount,
485
+ wordCount: p.wordCount,
486
+ project: p.project,
487
+ sessionId: p.sessionId,
488
+ }));
489
+
490
+ return Response.json({
491
+ plans: plansWithoutContent,
492
+ });
493
+ },
494
+ "/api/plans/:filename/content": async (req) => {
495
+ // Lazy load cache on first request
496
+ if (!cachedPlans) {
497
+ await loadPlans();
498
+ }
499
+
500
+ const filename = req.params.filename as string;
501
+ const content = contentCache.get(filename);
502
+
503
+ if (content === undefined) {
504
+ return new Response("Plan not found", { status: 404 });
505
+ }
506
+
507
+ return Response.json({ content });
508
+ },
509
+ "/api/refresh": {
510
+ POST: async () => {
511
+ const before = cachedPlans?.length ?? 0;
512
+ invalidateAllCaches();
513
+ await loadPlans();
514
+ const after = cachedPlans?.length ?? 0;
515
+ return Response.json({ success: true, before, after });
516
+ },
218
517
  },
219
518
  "/api/open": {
220
519
  POST: async (req) => {
@@ -231,10 +530,13 @@ async function startServer() {
231
530
  },
232
531
  },
233
532
  },
234
- development: process.env.NODE_ENV !== "production" ? {
235
- hmr: true,
236
- console: true,
237
- } : undefined,
533
+ development:
534
+ process.env.NODE_ENV !== "production"
535
+ ? {
536
+ hmr: true,
537
+ console: true,
538
+ }
539
+ : undefined,
238
540
  });
239
541
 
240
542
  return server;
@@ -253,14 +555,62 @@ const c = {
253
555
 
254
556
  // Main entry point
255
557
  (async () => {
558
+ const args = parseCliArgs();
559
+
560
+ if (args.version) {
561
+ console.log(`claude-plan-viewer v${pkg.version}`);
562
+ process.exit(0);
563
+ }
564
+
565
+ if (args.help) {
566
+ printHelp();
567
+ process.exit(0);
568
+ }
569
+
570
+ // Initialize directory paths based on --claude-dir flag or CLAUDE_DIR env var
571
+ const claudeDir = resolveClaudeDir(args.claudeDir);
572
+ initializeDirectories(claudeDir);
573
+
574
+ if (args.json) {
575
+ await exportPlansAsJson(args.output);
576
+ process.exit(0);
577
+ }
578
+
579
+ // Pre-load plans from file if --from-file is provided
580
+ let planCount: number;
581
+ let sourceDisplay: string;
582
+
583
+ if (args.fromFile) {
584
+ const plans = await loadPlansFromFile(args.fromFile);
585
+ planCount = plans.length;
586
+ sourceDisplay = args.fromFile;
587
+ } else {
588
+ planCount = (await readdir(PLANS_DIR)).filter((f) =>
589
+ f.endsWith(".md"),
590
+ ).length;
591
+ sourceDisplay = PLANS_DIR;
592
+ // Only watch for file changes when not using --from-file
593
+ watchPlansDirectory();
594
+ }
595
+
256
596
  const server = await startServer();
257
- const planCount = (await readdir(PLANS_DIR)).filter(f => f.endsWith('.md')).length;
597
+
258
598
  console.log();
259
- console.log(`${c.bold}${c.magenta} 📋 Plans Viewer${c.reset}`);
599
+ console.log(`${c.bold}${c.magenta} 📋 Claude Plan Viewer${c.reset}`);
260
600
  console.log(`${c.dim} ─────────────────────────────${c.reset}`);
261
601
  console.log(`${c.green} ✓${c.reset} Server running`);
602
+ if (!args.fromFile) {
603
+ console.log(`${c.green} ✓${c.reset} Watching for file changes`);
604
+ }
262
605
  console.log();
263
- console.log(`${c.dim} Local:${c.reset} ${c.cyan}${c.bold}http://localhost:${server.port}${c.reset}`);
264
- console.log(`${c.dim} Plans:${c.reset} ${c.yellow}${planCount} plans${c.reset} in ${c.dim}${PLANS_DIR}${c.reset}`);
606
+ console.log(
607
+ `${c.dim} Local:${c.reset} ${c.cyan}${c.bold}http://localhost:${server.port}${c.reset}`,
608
+ );
609
+ console.log(
610
+ `${c.dim} API:${c.reset} ${c.cyan}http://localhost:${server.port}/api/${c.reset}`,
611
+ );
612
+ console.log(
613
+ `${c.dim} Plans:${c.reset} ${c.yellow}${planCount} plans${c.reset} in ${c.dim}${sourceDisplay}${c.reset}`,
614
+ );
265
615
  console.log();
266
616
  })();