-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
958 lines (907 loc) · 55.3 KB
/
Copy pathindex.html
File metadata and controls
958 lines (907 loc) · 55.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="CodeCart — a portable memory wallet for AI. Carry your memory to any AI chat with one copy-paste. 100% offline, no account, no server.">
<link rel="canonical" href="https://codecart.tech/">
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg">
<meta property="og:type" content="website">
<meta property="og:url" content="https://codecart.tech/">
<meta property="og:title" content="CodeCart — One wallet. Every AI knows you.">
<meta property="og:description" content="A portable, 100% offline memory you carry into any AI chat with one copy-paste. No account. No server. Yours.">
<meta property="og:image" content="https://codecart.tech/assets/og.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="CodeCart — One wallet. Every AI knows you.">
<meta name="twitter:description" content="A portable, 100% offline memory you carry into any AI chat with one copy-paste. No account. No server. Yours.">
<meta name="twitter:image" content="https://codecart.tech/assets/og.png">
<title>CodeCart</title>
<style>
:root {
--bg: #f5f5f4; --card: #ffffff; --panel: #fafaf9;
--accent: #4f46e5; --accent-soft: #eef2ff;
--border: #e7e5e4; --border-strong: #d6d3d1;
--text: #1c1917; --text-2: #78716c; --text-3: #a8a29e;
--danger: #dc2626; --danger-soft: #fee2e2;
--t-pref-bg: #e1f5ee; --t-pref-fg: #085041;
--t-fact-bg: #eeedfe; --t-fact-fg: #3c3489;
--t-status-bg: #faeeda; --t-status-fg: #854f0b;
--t-bound-bg: #faece7; --t-bound-fg: #712b13;
--shadow: 0 1px 3px rgba(0,0,0,0.06);
}
body.dark {
--bg: #0c0a09; --card: #1c1917; --panel: #171412;
--accent: #818cf8; --accent-soft: #26215c;
--border: #292524; --border-strong: #44403c;
--text: #fafaf9; --text-2: #a8a29e; --text-3: #78716c;
--danger: #f87171; --danger-soft: #451a1a;
--t-pref-bg: #085041; --t-pref-fg: #9fe1cb;
--t-fact-bg: #3c3489; --t-fact-fg: #cecbf6;
--t-status-bg: #633806; --t-status-fg: #fac775;
--t-bound-bg: #712b13; --t-bound-fg: #f5c4b3;
--shadow: none;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh; display: flex; flex-direction: column;
font-family: "Inter", "PingFang SC", "Hiragino Sans", -apple-system, "Segoe UI", sans-serif;
background: var(--bg); color: var(--text);
transition: background 0.25s, color 0.25s;
}
button { font-family: inherit; cursor: pointer; border: none; border-radius: 10px; font-size: 13px; font-weight: 600; padding: 9px 14px; transition: 0.15s; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { filter: brightness(1.08); }
.btn-ghost { background: transparent; color: var(--text-2); border: 1px solid var(--border-strong); }
.btn-ghost:hover { background: var(--panel); color: var(--text); }
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); }
header {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px clamp(14px, 4vw, 32px); border-bottom: 1px solid var(--border); background: var(--card);
position: sticky; top: 0; z-index: 50; flex-wrap: wrap;
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 0; }
.brand svg { height: 30px; width: auto; flex-shrink: 0; }
.brand-name { font-size: 19px; font-weight: 800; letter-spacing: -0.02em; white-space: nowrap; }
.brand-name .b1 { color: var(--accent); }
.brand-sub { font-size: 12px; color: var(--text-2); white-space: nowrap; }
.header-right { display: flex; align-items: center; gap: 8px; }
.icon-btn { background: transparent; border: 1px solid var(--border); color: var(--text-2); width: 34px; height: 34px; padding: 0; border-radius: 9px; font-size: 15px; }
.icon-btn:hover { border-color: var(--border-strong); color: var(--text); }
select {
padding: 7px 10px; border-radius: 9px; border: 1px solid var(--border); background: var(--card);
color: var(--text); font-size: 12px; font-family: inherit; outline: none; cursor: pointer;
}
.stats { padding: 14px clamp(14px, 4vw, 32px) 0; display: flex; gap: 18px; flex-wrap: wrap; font-size: 12px; color: var(--text-2); }
.stats b { color: var(--text); font-weight: 700; }
.filters { display: flex; gap: 6px; padding: 12px clamp(14px, 4vw, 32px) 0; flex-wrap: wrap; }
.chip { padding: 5px 12px; border-radius: 999px; border: 1px solid var(--border); background: var(--card); color: var(--text-2); font-size: 12px; font-weight: 600; }
.chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
main { flex: 1; padding: 16px clamp(14px, 4vw, 32px) 140px; }
#grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: 14px; align-content: start; }
.card {
background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px; box-shadow: var(--shadow); position: relative; transition: 0.2s;
}
.card:hover { border-color: var(--border-strong); }
.card.archived { opacity: 0.45; }
.card-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.badge { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 999px; }
.badge.preference { background: var(--t-pref-bg); color: var(--t-pref-fg); }
.badge.fact { background: var(--t-fact-bg); color: var(--t-fact-fg); }
.badge.status { background: var(--t-status-bg); color: var(--t-status-fg); }
.badge.boundary { background: var(--t-bound-bg); color: var(--t-bound-fg); }
.badge.gray { background: var(--panel); color: var(--text-3); border: 1px solid var(--border); }
.card-date { font-size: 11px; color: var(--text-3); margin-left: auto; }
.card-text { font-size: 14px; line-height: 1.65; word-break: break-word; white-space: pre-wrap; }
.card-acts { display: flex; gap: 4px; opacity: 0; transition: 0.15s; justify-content: flex-end; }
.card:hover .card-acts, .card:focus-within .card-acts { opacity: 1; }
.card-acts button { padding: 4px 9px; font-size: 11px; font-weight: 600; background: transparent; color: var(--text-3); border-radius: 6px; }
.card-acts button:hover { background: var(--panel); color: var(--text); }
.card-acts button.danger:hover { background: var(--danger-soft); color: var(--danger); }
.empty { text-align: center; padding: 56px 20px; color: var(--text-2); }
.empty h3 { color: var(--text); font-size: 17px; margin: 0 0 8px; }
.empty p { font-size: 13px; line-height: 1.7; margin: 0 auto; max-width: 420px; }
.actionbar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 60;
background: var(--card); border-top: 1px solid var(--border);
padding: 12px clamp(14px, 4vw, 32px) calc(12px + env(safe-area-inset-bottom));
display: flex; gap: 10px; justify-content: center;
}
.actionbar button { flex: 1; max-width: 320px; padding: 14px 16px; font-size: 14px; border-radius: 12px; }
.actionbar .btn-secondary { background: var(--panel); color: var(--text); border: 1px solid var(--border-strong); }
.actionbar .btn-secondary:hover { border-color: var(--accent); }
.overlay {
display: none; position: fixed; inset: 0; z-index: 100;
background: rgba(0,0,0,0.55); align-items: center; justify-content: center; padding: 18px;
}
.overlay.open { display: flex; }
.modal {
background: var(--card); border: 1px solid var(--border); border-radius: 18px;
width: 560px; max-width: 100%; max-height: 88vh; overflow-y: auto; padding: 26px 26px 22px;
}
.modal h2 { margin: 0 0 6px; font-size: 18px; }
.modal .sub { font-size: 13px; color: var(--text-2); line-height: 1.7; margin: 0 0 16px; }
.modal textarea, .modal input[type=text] {
width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 11px 13px; color: var(--text); font-family: inherit; font-size: 14px; outline: none; resize: vertical;
}
.modal textarea:focus, .modal input:focus { border-color: var(--accent); }
.modal-acts { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; flex-wrap: wrap; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; font-weight: 700; color: var(--text-2); margin-bottom: 6px; }
.preview { margin-top: 14px; display: flex; flex-direction: column; gap: 8px; }
.pv { display: flex; gap: 10px; align-items: flex-start; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 9px 12px; font-size: 13px; line-height: 1.55; }
.pv .op { font-weight: 800; flex-shrink: 0; }
.pv .op.add { color: #16a34a; } .pv .op.upd { color: #d97706; } .pv .op.arc { color: var(--text-3); }
.pv .old { color: var(--text-3); text-decoration: line-through; display: block; font-size: 12px; }
.steps { display: flex; flex-direction: column; gap: 12px; margin: 4px 0 6px; }
.step { display: flex; gap: 12px; align-items: flex-start; }
.step .num { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-soft); color: var(--accent); font-weight: 800; font-size: 13px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.step .tx { font-size: 13.5px; line-height: 1.65; }
.step .tx b { display: block; }
.set-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid var(--border); font-size: 13.5px; flex-wrap: wrap; }
.set-row:last-of-type { border-bottom: none; }
.privacy { background: var(--accent-soft); color: var(--accent); border-radius: 10px; padding: 11px 13px; font-size: 12.5px; line-height: 1.65; margin-top: 14px; }
body.dark .privacy { color: #cecbf6; }
#toast {
position: fixed; bottom: 96px; left: 50%; transform: translateX(-50%) translateY(16px);
background: var(--text); color: var(--bg); padding: 11px 20px; border-radius: 999px;
font-size: 13px; font-weight: 600; opacity: 0; pointer-events: none; transition: 0.25s; z-index: 200;
max-width: 88vw; text-align: center;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 560px) {
.brand-sub { display: none; }
.actionbar { flex-direction: column; align-items: stretch; }
.actionbar button { max-width: none; }
main { padding-bottom: 190px; }
}
</style>
</head>
<body>
<header>
<div class="brand">
<svg viewBox="0 0 104 58" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M 29 12 L 19 1 L 45 1" stroke="currentColor" stroke-width="6" stroke-linecap="round" fill="none"/>
<rect x="20" y="8" width="78" height="33" rx="6" fill="#6366F1"/>
<rect x="29" y="16" width="56" height="4" rx="1" fill="#ffffff" opacity="0.9"/>
<rect x="29" y="24" width="45" height="4" rx="1" fill="#ffffff" opacity="0.9"/>
<rect x="29" y="32" width="52" height="4" rx="1" fill="#ffffff" opacity="0.9"/>
<circle cx="37" cy="47" r="9" fill="currentColor"/>
<circle cx="83" cy="47" r="9" fill="currentColor"/>
</svg>
<div>
<div class="brand-name"><span class="b1">Code</span>Cart <span id="brand-cn"></span></div>
<div class="brand-sub" id="ui-tagline"></div>
</div>
</div>
<div class="header-right">
<select id="lang-select" aria-label="Language">
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
<option value="zh-TW">繁體中文</option>
<option value="ja">日本語</option>
</select>
<button class="icon-btn" id="btn-theme" title=""></button>
<button class="icon-btn" id="btn-guide" title="">?</button>
<button class="icon-btn" id="btn-settings" title="">⚙</button>
</div>
</header>
<div class="stats">
<span id="stat-cards"></span>
<span id="stat-tokens"></span>
</div>
<div class="filters" id="filters"></div>
<main>
<div id="grid"></div>
<div class="empty" id="empty" style="display:none">
<h3 id="empty-title"></h3>
<p id="empty-desc"></p>
</div>
</main>
<div class="actionbar">
<button class="btn-primary" id="btn-carry"></button>
<button class="btn-secondary" id="btn-paste"></button>
<button class="btn-secondary" id="btn-add" style="flex:0 0 auto">+</button>
</div>
<div class="overlay" id="ov-paste">
<div class="modal">
<h2 id="paste-title"></h2>
<p class="sub" id="paste-desc"></p>
<textarea id="paste-input" rows="7"></textarea>
<div class="preview" id="paste-preview"></div>
<div class="modal-acts">
<button class="btn-ghost" data-close></button>
<button class="btn-primary" id="btn-apply" style="display:none"></button>
<button class="btn-primary" id="btn-parse"></button>
</div>
</div>
</div>
<div class="overlay" id="ov-edit">
<div class="modal" style="width:460px">
<h2 id="edit-title"></h2>
<div class="field">
<label id="edit-lbl-type"></label>
<select id="edit-type" style="width:100%">
<option value="preference"></option>
<option value="fact"></option>
<option value="status"></option>
<option value="boundary"></option>
</select>
</div>
<div class="field">
<label id="edit-lbl-text"></label>
<textarea id="edit-text" rows="3"></textarea>
</div>
<div class="modal-acts">
<button class="btn-ghost" data-close></button>
<button class="btn-primary" id="btn-edit-save"></button>
</div>
</div>
</div>
<div class="overlay" id="ov-guide">
<div class="modal" style="width:500px">
<div style="display:flex;justify-content:flex-end;margin:-8px 0 4px">
<select class="lang-mini" aria-label="Language">
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
<option value="zh-TW">繁體中文</option>
<option value="ja">日本語</option>
</select>
</div>
<h2 id="guide-title"></h2>
<p class="sub" id="guide-sub"></p>
<div class="steps" id="guide-steps"></div>
<div class="modal-acts"><button class="btn-primary" data-close id="guide-close"></button></div>
</div>
</div>
<div class="overlay" id="ov-wizard">
<div class="modal" style="width:520px">
<div style="display:flex;justify-content:flex-end;margin:-8px 0 4px">
<select class="lang-mini" aria-label="Language">
<option value="en">English</option>
<option value="zh-CN">简体中文</option>
<option value="zh-TW">繁體中文</option>
<option value="ja">日本語</option>
</select>
</div>
<h2 id="wiz-title"></h2>
<p class="sub" id="wiz-intro"></p>
<div class="field"><label id="wiz-l1"></label><input type="text" id="wiz-q1"></div>
<div class="field"><label id="wiz-l2"></label><input type="text" id="wiz-q2"></div>
<div class="field"><label id="wiz-l3"></label><input type="text" id="wiz-q3"></div>
<div class="field"><label id="wiz-l4"></label><input type="text" id="wiz-q4"></div>
<div class="modal-acts">
<button class="btn-ghost" id="wiz-skip"></button>
<button class="btn-primary" id="wiz-done"></button>
</div>
</div>
</div>
<div class="overlay" id="ov-settings">
<div class="modal" style="width:480px">
<h2 id="set-title"></h2>
<div class="set-row"><span id="set-lbl-export"></span><button class="btn-ghost" id="btn-export"></button></div>
<div class="set-row"><span id="set-lbl-import"></span><button class="btn-ghost" id="btn-import"></button><input type="file" id="import-file" accept=".json,application/json" style="display:none"></div>
<div class="set-row"><span id="set-lbl-share"></span><button class="btn-ghost" id="btn-share"></button></div>
<div class="set-row"><span id="set-lbl-clear"></span><button class="btn-danger" id="btn-clear"></button></div>
<div class="privacy" id="set-privacy"></div>
<p class="sub" style="margin:14px 0 0; font-size:12px" id="set-about"></p>
<div class="modal-acts"><button class="btn-ghost" data-close></button></div>
</div>
</div>
<div id="toast" role="status"></div>
<script>
"use strict";
const I18N = {
en: {
langTag: "en", brandCN: "", docTitle: "CodeCart — Portable AI memory wallet",
tagline: "Your memory, for every AI",
statCards: n => `${n} active cards`, statTokens: t => `≈ ${t} tokens per carry`,
filterAll: "All", filterArchived: "Archived",
tPreference: "Preference", tFact: "About me", tStatus: "Right now", tBoundary: "Hard rule",
btnCarry: "Carry memory · start chatting", btnPaste: "Paste AI reply · save",
toastCarried: "Copied! Paste it into any AI chat, then talk as usual.",
toastSaved: n => `Saved — ${n} change(s) added to your wallet.`,
toastNothing: "No memory block found. Ask the AI to end with a codecart block, or add a card manually with +.",
toastImported: n => `Imported ${n} new card(s).`, toastLink: "Link copied — open it on another device to transfer.",
toastExported: "Wallet file downloaded. Keep it anywhere — it is your backup.",
emptyTitle: "Your wallet is empty",
emptyDesc: "Tap + to add your first card, or press ? for a 30-second tour. Cards are little things AI should remember about you: who you are, how you like answers, what you're working on.",
badgeUpdated: "updated", badgeArchived: "archived",
actEdit: "Edit", actArchive: "Archive", actRestore: "Restore", actDelete: "Delete",
confirmDelete: "Delete this card permanently?", confirmClear: "Erase ALL cards on this device? Export a backup first if unsure.",
pasteTitle: "Save what the AI learned",
pasteDesc: "Paste the AI's whole reply below — no need to trim anything. CodeCart finds the memory block, adds new cards and updates outdated ones. You'll see a preview before anything is saved.",
pastePh: "Paste the AI's full reply here…",
btnParse: "Find memories", btnApply: "Save to wallet", btnCancel: "Cancel",
editTitleAdd: "New card", editTitleEdit: "Edit card", lblType: "Type", lblText: "What should AI remember?",
btnSave: "Save",
guideTitle: "How CodeCart works", guideSub: "One wallet, every AI. All it takes is copy and paste.",
guideSteps: [
["Before you chat, tap “Carry memory”.", "It copies your cards plus a small instruction for the AI. Paste it as your first message in ChatGPT, Claude, Gemini — any AI."],
["Chat as usual.", "The AI now knows you and follows your rules. When it learns something new about you, it ends its reply with a small “codecart” block."],
["Paste the reply back.", "Tap “Paste AI reply”, paste the whole answer. New cards appear; outdated ones get archived automatically."],
["Everything stays on your device.", "No account, no server. Export the wallet file or a transfer link to move devices."]
],
guideClose: "Got it",
wizTitle: "Let's build your starter cards", wizIntro: "Answer anything you like — skip the rest. This takes one minute and never leaves your device.",
wizL1: "How should AI address you, and what do you do?", wizP1: "e.g. Call me Sam. I run a small online store.",
wizL2: "What are you focused on these days?", wizP2: "e.g. Learning video editing for my shop.",
wizL3: "How do you like answers?", wizP3: "e.g. Short and plain, conclusion first, no jargon.",
wizL4: "Anything AI must never do?", wizP4: "e.g. Never suggest options over my $200 budget.",
wizSkip: "Skip for now", wizDone: "Create my wallet",
setTitle: "Data & settings",
setExport: "Back up wallet to a file", setImport: "Import a wallet file", setShare: "Copy transfer link", setClear: "Erase everything",
btnExport: "Export", btnImport: "Import", btnShare: "Copy link", btnClear: "Erase",
setPrivacy: "Privacy: every card lives only in this browser, on this device. Nothing is uploaded, ever. No account exists. The exported file and the transfer link are the only copies — you own them.",
setAbout: "CodeCart v2.0 · open source · github.com/AgentLex/CodeCart",
ttTheme: "Theme", ttGuide: "How it works", ttSettings: "Data & settings",
promptHeader: d => `[My personal memory — CodeCart wallet, ${d}. Use it to personalize every answer.]`,
promptInstr: `---
Note for the AI assistant: after reading the memory above, follow it in all answers.
IMPORTANT: whenever this conversation reveals a lasting NEW fact, preference, decision or correction about me, end your reply with a fenced code block tagged "codecart", one line per item, using exactly this syntax:
+ fact | <new thing to remember> (types: preference / fact / status / boundary)
~ <few words copied from an outdated memory line above> => <corrected version>
Rules: max 5 lines; write card contents in my language; only durable personal info (never trivia or one-off details); omit the block entirely if nothing qualifies.`
},
"zh-CN": {
langTag: "zh-CN", brandCN: "思维卡", docTitle: "CodeCart 思维卡 — 给 AI 的随身记忆卡包",
tagline: "一副卡包,让每个 AI 都认识你",
statCards: n => `${n} 张有效卡片`, statTokens: t => `每次携带约 ${t} tokens`,
filterAll: "全部", filterArchived: "已归档",
tPreference: "偏好", tFact: "关于我", tStatus: "近况", tBoundary: "底线",
btnCarry: "带上记忆,开始聊", btnPaste: "粘贴 AI 回复,存收获",
toastCarried: "已复制!去粘贴给任何 AI(ChatGPT、Claude、Kimi 都行),然后正常聊天。",
toastSaved: n => `已存入 ${n} 条变化。`,
toastNothing: "没有发现记忆块。可以让 AI 在回复末尾附上 codecart 块,或点 + 手动添加。",
toastImported: n => `已导入 ${n} 张新卡片。`, toastLink: "迁移链接已复制——在另一台设备打开即可转移。",
toastExported: "卡包文件已下载,存哪儿都行,它就是你的备份。",
emptyTitle: "卡包还是空的",
emptyDesc: "点 + 添加第一张卡,或点 ? 看 30 秒新手引导。卡片就是希望 AI 记住你的小事:你是谁、喜欢什么样的回答、最近在忙什么。",
badgeUpdated: "已更新", badgeArchived: "已归档",
actEdit: "编辑", actArchive: "归档", actRestore: "恢复", actDelete: "删除",
confirmDelete: "永久删除这张卡片?", confirmClear: "清空本设备上的全部卡片?不确定的话请先导出备份。",
pasteTitle: "把 AI 学到的东西存下来",
pasteDesc: "把 AI 的整段回复原样粘贴到下面——不用挑不用删。CodeCart 会自动找出记忆块,新增卡片、更新过时的旧卡。保存前会先给你预览。",
pastePh: "把 AI 的完整回复粘贴到这里…",
btnParse: "找出记忆", btnApply: "存入卡包", btnCancel: "取消",
editTitleAdd: "新建卡片", editTitleEdit: "编辑卡片", lblType: "类型", lblText: "想让 AI 记住什么?",
btnSave: "保存",
guideTitle: "思维卡怎么用", guideSub: "一副卡包,通用于所有 AI。你只需要会复制粘贴。",
guideSteps: [
["聊天前,点「带上记忆」。", "它会复制你的卡片和一小段给 AI 的说明。把它作为第一条消息粘贴给任何 AI——ChatGPT、Claude、Kimi、豆包都行。"],
["正常聊天。", "AI 现在认识你、遵守你的规矩。当它了解到关于你的新信息,会在回复末尾附一个小小的 codecart 块。"],
["把回复粘回来。", "点「粘贴 AI 回复」,整段粘进去。新卡片自动出现,过时的旧卡自动归档。"],
["一切只存在你的设备上。", "无账号、无服务器。换设备用导出文件或迁移链接。"]
],
guideClose: "明白了",
wizTitle: "一分钟做好你的起始卡组", wizIntro: "想答哪题答哪题,其余留空。这些内容不会离开你的设备。",
wizL1: "希望 AI 怎么称呼你?你是做什么的?", wizP1: "例:叫我老李,我开了家淘宝小店。",
wizL2: "最近主要在忙什么?", wizP2: "例:在学短视频剪辑,想给店里引流。",
wizL3: "喜欢什么样的回答?", wizP3: "例:说人话,简短点,先给结论。",
wizL4: "有什么 AI 绝对不能做的?", wizP4: "例:别推荐超过每月 500 元预算的方案。",
wizSkip: "先跳过", wizDone: "生成我的卡包",
setTitle: "数据与设置",
setExport: "把卡包备份成文件", setImport: "导入卡包文件", setShare: "复制迁移链接", setClear: "清空全部数据",
btnExport: "导出", btnImport: "导入", btnShare: "复制链接", btnClear: "清空",
setPrivacy: "隐私说明:所有卡片只保存在这台设备的浏览器里,永远不会上传任何服务器,也不存在账号。导出的文件和迁移链接是仅有的副本——它们完全属于你。",
setAbout: "CodeCart 思维卡 v2.0 · 开源 · github.com/AgentLex/CodeCart",
ttTheme: "主题", ttGuide: "使用引导", ttSettings: "数据与设置",
promptHeader: d => `[这是我的个人记忆(CodeCart 思维卡卡包,${d})。请据此个性化你的所有回答。]`,
promptInstr: `---
给 AI 助手的说明:阅读上方记忆后,在所有回答中遵循它。
重要:当对话中出现关于我的、值得长期记住的新事实、偏好、决定或对上方记忆的修正时,请在回复末尾附加一个标记为 codecart 的围栏代码块,每条一行,严格使用以下语法:
+ fact | <要记住的新内容> (类型:preference / fact / status / boundary)
~ <照抄上方某条过时记忆中的几个字> => <修正后的内容>
规则:最多 5 行;卡片内容用我的语言书写;只记录持久的个人信息(不记琐事和一次性细节);没有可记的就完全省略该块。`
},
"zh-TW": {
langTag: "zh-TW", brandCN: "思維卡", docTitle: "CodeCart 思維卡 — 給 AI 的隨身記憶卡包",
tagline: "一副卡包,讓每個 AI 都認識你",
statCards: n => `${n} 張有效卡片`, statTokens: t => `每次攜帶約 ${t} tokens`,
filterAll: "全部", filterArchived: "已封存",
tPreference: "偏好", tFact: "關於我", tStatus: "近況", tBoundary: "底線",
btnCarry: "帶上記憶,開始聊", btnPaste: "貼上 AI 回覆,存收穫",
toastCarried: "已複製!去貼給任何 AI(ChatGPT、Claude、Gemini 都行),然後正常聊天。",
toastSaved: n => `已存入 ${n} 條變化。`,
toastNothing: "沒有發現記憶區塊。可以請 AI 在回覆結尾附上 codecart 區塊,或點 + 手動新增。",
toastImported: n => `已匯入 ${n} 張新卡片。`, toastLink: "遷移連結已複製——在另一台裝置開啟即可轉移。",
toastExported: "卡包檔案已下載,存哪裡都行,它就是你的備份。",
emptyTitle: "卡包還是空的",
emptyDesc: "點 + 新增第一張卡,或點 ? 看 30 秒新手引導。卡片就是希望 AI 記住你的小事:你是誰、喜歡什麼樣的回答、最近在忙什麼。",
badgeUpdated: "已更新", badgeArchived: "已封存",
actEdit: "編輯", actArchive: "封存", actRestore: "還原", actDelete: "刪除",
confirmDelete: "永久刪除這張卡片?", confirmClear: "清空本裝置上的全部卡片?不確定的話請先匯出備份。",
pasteTitle: "把 AI 學到的東西存下來",
pasteDesc: "把 AI 的整段回覆原樣貼到下面——不用挑不用刪。CodeCart 會自動找出記憶區塊,新增卡片、更新過時的舊卡。儲存前會先給你預覽。",
pastePh: "把 AI 的完整回覆貼到這裡…",
btnParse: "找出記憶", btnApply: "存入卡包", btnCancel: "取消",
editTitleAdd: "新增卡片", editTitleEdit: "編輯卡片", lblType: "類型", lblText: "想讓 AI 記住什麼?",
btnSave: "儲存",
guideTitle: "思維卡怎麼用", guideSub: "一副卡包,通用於所有 AI。你只需要會複製貼上。",
guideSteps: [
["聊天前,點「帶上記憶」。", "它會複製你的卡片和一小段給 AI 的說明。把它作為第一則訊息貼給任何 AI——ChatGPT、Claude、Gemini 都行。"],
["正常聊天。", "AI 現在認識你、遵守你的規矩。當它了解到關於你的新資訊,會在回覆結尾附一個小小的 codecart 區塊。"],
["把回覆貼回來。", "點「貼上 AI 回覆」,整段貼進去。新卡片自動出現,過時的舊卡自動封存。"],
["一切只存在你的裝置上。", "無帳號、無伺服器。換裝置用匯出檔案或遷移連結。"]
],
guideClose: "明白了",
wizTitle: "一分鐘做好你的起始卡組", wizIntro: "想答哪題答哪題,其餘留空。這些內容不會離開你的裝置。",
wizL1: "希望 AI 怎麼稱呼你?你是做什麼的?", wizP1: "例:叫我小陳,我是自由接案設計師。",
wizL2: "最近主要在忙什麼?", wizP2: "例:在準備作品集,想接更多品牌案。",
wizL3: "喜歡什麼樣的回答?", wizP3: "例:說白話,簡短一點,先給結論。",
wizL4: "有什麼 AI 絕對不能做的?", wizP4: "例:別推薦超過每月 5000 元預算的方案。",
wizSkip: "先跳過", wizDone: "產生我的卡包",
setTitle: "資料與設定",
setExport: "把卡包備份成檔案", setImport: "匯入卡包檔案", setShare: "複製遷移連結", setClear: "清空全部資料",
btnExport: "匯出", btnImport: "匯入", btnShare: "複製連結", btnClear: "清空",
setPrivacy: "隱私說明:所有卡片只保存在這台裝置的瀏覽器裡,永遠不會上傳任何伺服器,也不存在帳號。匯出的檔案和遷移連結是僅有的副本——它們完全屬於你。",
setAbout: "CodeCart 思維卡 v2.0 · 開源 · github.com/AgentLex/CodeCart",
ttTheme: "主題", ttGuide: "使用引導", ttSettings: "資料與設定",
promptHeader: d => `[這是我的個人記憶(CodeCart 思維卡卡包,${d})。請據此個人化你的所有回答。]`,
promptInstr: `---
給 AI 助手的說明:閱讀上方記憶後,在所有回答中遵循它。
重要:當對話中出現關於我的、值得長期記住的新事實、偏好、決定或對上方記憶的修正時,請在回覆結尾附加一個標記為 codecart 的圍欄程式碼區塊,每條一行,嚴格使用以下語法:
+ fact | <要記住的新內容> (類型:preference / fact / status / boundary)
~ <照抄上方某條過時記憶中的幾個字> => <修正後的內容>
規則:最多 5 行;卡片內容用我的語言書寫;只記錄持久的個人資訊(不記瑣事和一次性細節);沒有可記的就完全省略該區塊。`
},
ja: {
langTag: "ja", brandCN: "", docTitle: "CodeCart — AI のための持ち歩けるメモリーウォレット",
tagline: "ひとつのウォレットを、すべての AI に",
statCards: n => `有効なカード ${n} 枚`, statTokens: t => `1回の持ち込み ≈ ${t} tokens`,
filterAll: "すべて", filterArchived: "アーカイブ",
tPreference: "好み", tFact: "わたしについて", tStatus: "いま", tBoundary: "絶対ルール",
btnCarry: "記憶を持ってチャットへ", btnPaste: "AI の返信を貼って保存",
toastCarried: "コピーしました!ChatGPT・Claude・Gemini など、どの AI にも貼り付けて、あとは普通に会話するだけ。",
toastSaved: n => `${n} 件の変化を保存しました。`,
toastNothing: "メモリーブロックが見つかりません。AI に返信の最後へ codecart ブロックを付けてもらうか、+ で手動追加してください。",
toastImported: n => `${n} 枚の新しいカードを取り込みました。`, toastLink: "引っ越しリンクをコピーしました。別の端末で開くと移行できます。",
toastExported: "ウォレットファイルを保存しました。どこに置いてもOK、それがバックアップです。",
emptyTitle: "ウォレットはまだ空です",
emptyDesc: "+ で最初のカードを追加するか、? で 30 秒ガイドをどうぞ。カードとは、AI に覚えていてほしい小さなこと——あなたが誰か、どんな答え方が好きか、いま何をしているか。",
badgeUpdated: "更新済み", badgeArchived: "アーカイブ",
actEdit: "編集", actArchive: "アーカイブ", actRestore: "戻す", actDelete: "削除",
confirmDelete: "このカードを完全に削除しますか?", confirmClear: "この端末の全カードを消去しますか?不安ならまずエクスポートを。",
pasteTitle: "AI が学んだことを保存",
pasteDesc: "AI の返信をまるごと下に貼り付けてください——選んだり削ったりは不要です。CodeCart がメモリーブロックを見つけ、新しいカードを追加し、古いカードを更新します。保存前にプレビューが出ます。",
pastePh: "AI の返信全文をここに貼り付け…",
btnParse: "メモリーを探す", btnApply: "ウォレットに保存", btnCancel: "キャンセル",
editTitleAdd: "新しいカード", editTitleEdit: "カードを編集", lblType: "タイプ", lblText: "AI に何を覚えてほしい?",
btnSave: "保存",
guideTitle: "CodeCart の使い方", guideSub: "ひとつのウォレットが、すべての AI で使えます。必要なのはコピペだけ。",
guideSteps: [
["チャットの前に「記憶を持って」をタップ。", "カードと AI への短い指示がコピーされます。最初のメッセージとして、どの AI にも貼り付けてください。"],
["ふつうに会話する。", "AI はもうあなたを知っていて、ルールも守ります。新しく学んだことがあれば、返信の最後に小さな codecart ブロックを付けます。"],
["返信を貼り戻す。", "「AI の返信を貼って保存」を押して全文ペースト。新カードが増え、古いカードは自動でアーカイブされます。"],
["すべてはあなたの端末の中だけに。", "アカウントもサーバーもなし。端末の引っ越しはファイル書き出しかリンクで。"]
],
guideClose: "わかった",
wizTitle: "1分でスターターカードを作る", wizIntro: "答えたい質問だけでOK。内容が端末の外に出ることはありません。",
wizL1: "AI にどう呼ばれたい?お仕事は?", wizP1: "例:ユキと呼んで。ネットショップを運営しています。",
wizL2: "最近の関心事は?", wizP2: "例:動画編集を勉強中。",
wizL3: "どんな答え方が好き?", wizP3: "例:短く平易に、結論から。",
wizL4: "AI が絶対にしてはいけないことは?", wizP4: "例:月1万円の予算を超える提案はしない。",
wizSkip: "あとで", wizDone: "ウォレットを作成",
setTitle: "データと設定",
setExport: "ウォレットをファイルに保存", setImport: "ウォレットファイルを取り込む", setShare: "引っ越しリンクをコピー", setClear: "すべて消去",
btnExport: "書き出す", btnImport: "取り込む", btnShare: "コピー", btnClear: "消去",
setPrivacy: "プライバシー:カードはこの端末のブラウザーの中だけに保存され、どこにもアップロードされません。アカウントも存在しません。書き出したファイルとリンクだけが複製で、それはあなたのものです。",
setAbout: "CodeCart v2.0 · オープンソース · github.com/AgentLex/CodeCart",
ttTheme: "テーマ", ttGuide: "使い方", ttSettings: "データと設定",
promptHeader: d => `[これは私の個人メモリーです(CodeCart ウォレット、${d})。すべての回答をこれに基づいてパーソナライズしてください。]`,
promptInstr: `---
AI アシスタントへ:上記のメモリーを読み、すべての回答で従ってください。
重要:この会話で、私に関する長期的に覚えるべき新しい事実・好み・決定・上記メモリーの修正が生じた場合、返信の最後に codecart とタグ付けしたフェンス付きコードブロックを追加し、1行1項目で厳密に次の構文を使ってください:
+ fact | <覚えるべき新しい内容> (タイプ:preference / fact / status / boundary)
~ <上記の古いメモリー行から数語をそのまま引用> => <修正後の内容>
ルール:最大5行;カード内容は私の言語で;持続的な個人情報のみ(雑学や一回限りの詳細は不可);該当がなければブロック自体を省略。`
}
};
const TYPES = ["preference", "fact", "status", "boundary"];
const TYPE_ALIASES = {
preference: "preference", pref: "preference", "偏好": "preference", "好み": "preference",
fact: "fact", about: "fact", "关于我": "fact", "關於我": "fact", "事实": "fact", "事實": "fact",
status: "status", now: "status", "近况": "status", "近況": "status", "いま": "status",
boundary: "boundary", rule: "boundary", anchor: "boundary", "底线": "boundary", "底線": "boundary", "絶対ルール": "boundary"
};
const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
const uid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
const b64encode = obj => btoa(String.fromCharCode(...new TextEncoder().encode(JSON.stringify(obj)))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const b64decode = s => JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0))));
const estTokens = s => { let cjk = 0, other = 0; for (const ch of s) { if (/[ -鿿豈--ヿ]/.test(ch)) cjk++; else other++; } return Math.round(cjk + other / 4); };
class App {
constructor() {
this.lang = this.detectLang();
this.theme = localStorage.getItem("cc2_theme") || "auto";
this.filter = "all";
this.editingId = null;
this.pendingOps = null;
this.data = { version: 2, cards: [] };
this.load();
this.bind();
this.applyTheme();
this.updateUI();
this.render();
this.handleHashImport();
if (!localStorage.getItem("cc2_seen") && this.data.cards.length === 0) this.openWizard();
}
t() { return I18N[this.lang]; }
detectLang() {
const saved = localStorage.getItem("cc2_lang");
if (saved && I18N[saved]) return saved;
const nav = (navigator.language || "en").toLowerCase();
if (nav.startsWith("zh")) {
if (/tw|hk|mo|hant/.test(nav)) return "zh-TW";
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone || "");
return /Taipei|Hong_Kong|Macau/.test(tz) ? "zh-TW" : "zh-CN";
}
if (nav.startsWith("ja")) return "ja";
return "en";
}
load() {
try {
const d = localStorage.getItem("cc2_data");
if (d) { this.data = JSON.parse(d); return; }
const old = localStorage.getItem("cc_v13");
if (old) {
const legacy = JSON.parse(old);
this.data.cards = (legacy.nodes || []).map(n => ({
id: uid(), type: n.type === "anchor" ? "boundary" : "fact", text: n.content,
status: n.isSuperseded ? "archived" : "active", created: Date.now(), updated: Date.now()
}));
this.save();
}
} catch (e) { console.warn("load failed", e); }
}
save() { localStorage.setItem("cc2_data", JSON.stringify(this.data)); }
applyTheme() {
const dark = this.theme === "dark" || (this.theme === "auto" && matchMedia("(prefers-color-scheme: dark)").matches);
document.body.classList.toggle("dark", dark);
document.getElementById("btn-theme").textContent = this.theme === "auto" ? "◐" : (this.theme === "dark" ? "☾" : "☀");
}
cycleTheme() {
this.theme = { auto: "light", light: "dark", dark: "auto" }[this.theme];
localStorage.setItem("cc2_theme", this.theme);
this.applyTheme();
}
setLang(l) { this.lang = l; localStorage.setItem("cc2_lang", l); this.updateUI(); this.render(); }
updateUI() {
const t = this.t();
document.documentElement.lang = t.langTag;
document.title = t.docTitle;
const set = (id, v) => { document.getElementById(id).textContent = v; };
set("brand-cn", t.brandCN);
set("ui-tagline", t.tagline);
document.getElementById("lang-select").value = this.lang;
document.querySelectorAll(".lang-mini").forEach(s => { s.value = this.lang; });
document.getElementById("btn-theme").title = t.ttTheme;
document.getElementById("btn-guide").title = t.ttGuide;
document.getElementById("btn-settings").title = t.ttSettings;
set("btn-carry", t.btnCarry);
set("btn-paste", t.btnPaste);
set("empty-title", t.emptyTitle);
set("empty-desc", t.emptyDesc);
set("paste-title", t.pasteTitle);
set("paste-desc", t.pasteDesc);
document.getElementById("paste-input").placeholder = t.pastePh;
set("btn-parse", t.btnParse);
set("btn-apply", t.btnApply);
set("edit-lbl-type", t.lblType);
set("edit-lbl-text", t.lblText);
set("btn-edit-save", t.btnSave);
const typeSel = document.getElementById("edit-type");
[...typeSel.options].forEach(o => { o.textContent = t["t" + o.value[0].toUpperCase() + o.value.slice(1)]; });
set("guide-title", t.guideTitle);
set("guide-sub", t.guideSub);
document.getElementById("guide-steps").innerHTML = t.guideSteps.map((s, i) =>
`<div class="step"><div class="num">${i + 1}</div><div class="tx"><b>${esc(s[0])}</b>${esc(s[1])}</div></div>`).join("");
set("guide-close", t.guideClose);
set("wiz-title", t.wizTitle);
set("wiz-intro", t.wizIntro);
for (let i = 1; i <= 4; i++) {
set("wiz-l" + i, t["wizL" + i]);
document.getElementById("wiz-q" + i).placeholder = t["wizP" + i];
}
set("wiz-skip", t.wizSkip);
set("wiz-done", t.wizDone);
set("set-title", t.setTitle);
set("set-lbl-export", t.setExport); set("btn-export", t.btnExport);
set("set-lbl-import", t.setImport); set("btn-import", t.btnImport);
set("set-lbl-share", t.setShare); set("btn-share", t.btnShare);
set("set-lbl-clear", t.setClear); set("btn-clear", t.btnClear);
set("set-privacy", t.setPrivacy);
set("set-about", t.setAbout);
document.querySelectorAll("[data-close]").forEach(b => { if (!b.id) b.textContent = t.btnCancel; });
this.renderFilters();
}
renderFilters() {
const t = this.t();
const defs = [["all", t.filterAll], ["preference", t.tPreference], ["fact", t.tFact], ["status", t.tStatus], ["boundary", t.tBoundary], ["archived", t.filterArchived]];
document.getElementById("filters").innerHTML = defs.map(([k, label]) =>
`<button class="chip${this.filter === k ? " on" : ""}" data-filter="${k}">${esc(label)}</button>`).join("");
}
render() {
const t = this.t();
const grid = document.getElementById("grid");
let cards = this.data.cards.slice().sort((a, b) => b.updated - a.updated);
if (this.filter === "archived") cards = cards.filter(c => c.status === "archived");
else {
cards = cards.filter(c => c.status === "active");
if (this.filter !== "all") cards = cards.filter(c => c.type === this.filter);
}
grid.innerHTML = cards.map(c => {
const typeLabel = t["t" + c.type[0].toUpperCase() + c.type.slice(1)];
const recent = c.updatedByProtocol && (Date.now() - c.updated < 86400000 * 3);
const arch = c.status === "archived";
return `<div class="card${arch ? " archived" : ""}">
<div class="card-top">
<span class="badge ${c.type}">${esc(typeLabel)}</span>
${recent && !arch ? `<span class="badge gray">${esc(t.badgeUpdated)}</span>` : ""}
${arch ? `<span class="badge gray">${esc(t.badgeArchived)}</span>` : ""}
<span class="card-date">${new Date(c.updated).toLocaleDateString(t.langTag)}</span>
</div>
<div class="card-text">${esc(c.text)}</div>
<div class="card-acts">
${arch
? `<button data-act="restore" data-id="${c.id}">${esc(t.actRestore)}</button>`
: `<button data-act="edit" data-id="${c.id}">${esc(t.actEdit)}</button>
<button data-act="archive" data-id="${c.id}">${esc(t.actArchive)}</button>`}
<button data-act="delete" data-id="${c.id}" class="danger">${esc(t.actDelete)}</button>
</div>
</div>`;
}).join("");
document.getElementById("empty").style.display = this.data.cards.length ? "none" : "block";
const active = this.data.cards.filter(c => c.status === "active");
document.getElementById("stat-cards").textContent = t.statCards(active.length);
document.getElementById("stat-tokens").textContent = active.length ? t.statTokens(estTokens(this.buildCarryText())) : "";
this.renderFilters();
}
buildCarryText() {
const t = this.t();
const active = this.data.cards.filter(c => c.status === "active");
const lines = active.map(c => `- (${c.type}) ${c.text}`);
return [t.promptHeader(new Date().toLocaleDateString(t.langTag)), ...lines, "", t.promptInstr].join("\n");
}
async copy(text) {
try { await navigator.clipboard.writeText(text); return true; }
catch (e) {
const ta = document.createElement("textarea");
ta.value = text; document.body.appendChild(ta); ta.select();
const ok = document.execCommand("copy"); ta.remove(); return ok;
}
}
async carry() {
if (!this.data.cards.some(c => c.status === "active")) { this.openGuide(); return; }
await this.copy(this.buildCarryText());
this.toast(this.t().toastCarried);
}
parseReply(text) {
const ops = [];
const fences = [...text.matchAll(/```\s*codecart\s*\n([\s\S]*?)```/gi)].map(m => m[1]);
const source = fences.length ? fences.join("\n") : text;
const lineRe = /^\s*([+~\-])\s*(.+)$/;
for (const raw of source.split("\n")) {
const m = raw.match(lineRe);
if (!m) continue;
const [, op, rest] = m;
if (op === "+") {
let type = "fact", body = rest.trim();
const tm = body.match(/^([^|]{1,20})\|\s*(.+)$/);
if (tm && TYPE_ALIASES[tm[1].trim().toLowerCase()]) { type = TYPE_ALIASES[tm[1].trim().toLowerCase()]; body = tm[2].trim(); }
if (body && !fences.length && !tm) continue;
if (body) ops.push({ op: "add", type, text: body });
} else if (op === "~") {
const um = rest.match(/^(.+?)\s*=>\s*(.+)$/);
if (!um) continue;
let frag = um[1].trim(), body = um[2].trim(), type = null;
const tm = body.match(/^([^|]{1,20})\|\s*(.+)$/);
if (tm && TYPE_ALIASES[tm[1].trim().toLowerCase()]) { type = TYPE_ALIASES[tm[1].trim().toLowerCase()]; body = tm[2].trim(); }
const target = this.findByFragment(frag);
if (body) ops.push({ op: "update", frag, target, type: type || (target ? target.type : "fact"), text: body });
} else if (op === "-" && fences.length) {
const target = this.findByFragment(rest.trim());
if (target) ops.push({ op: "archive", target });
}
}
const activeTexts = new Set(this.data.cards.filter(c => c.status === "active").map(c => c.text.trim().toLowerCase()));
return ops.filter(o => o.op === "archive" || !activeTexts.has(o.text.trim().toLowerCase()));
}
findByFragment(frag) {
const f = frag.toLowerCase();
return this.data.cards.find(c => c.status === "active" && c.text.toLowerCase().includes(f)) || null;
}
parseAndPreview() {
const t = this.t();
const text = document.getElementById("paste-input").value;
const ops = this.parseReply(text);
const pv = document.getElementById("paste-preview");
if (!ops.length) {
this.pendingOps = null;
pv.innerHTML = "";
document.getElementById("btn-apply").style.display = "none";
this.toast(t.toastNothing);
return;
}
this.pendingOps = ops;
pv.innerHTML = ops.map(o => {
const typeLabel = o.type ? t["t" + o.type[0].toUpperCase() + o.type.slice(1)] : "";
if (o.op === "add") return `<div class="pv"><span class="op add">+</span><span><span class="badge ${o.type}">${esc(typeLabel)}</span> ${esc(o.text)}</span></div>`;
if (o.op === "update") return `<div class="pv"><span class="op upd">↻</span><span>${o.target ? `<span class="old">${esc(o.target.text)}</span>` : ""}<span class="badge ${o.type}">${esc(typeLabel)}</span> ${esc(o.text)}</span></div>`;
return `<div class="pv"><span class="op arc">−</span><span class="old" style="text-decoration:line-through">${esc(o.target.text)}</span></div>`;
}).join("");
document.getElementById("btn-apply").style.display = "";
}
applyOps() {
if (!this.pendingOps) return;
const now = Date.now();
let n = 0;
for (const o of this.pendingOps) {
if (o.op === "add") {
this.data.cards.push({ id: uid(), type: o.type, text: o.text, status: "active", created: now, updated: now, updatedByProtocol: true });
n++;
} else if (o.op === "update") {
if (o.target) { o.target.status = "archived"; o.target.updated = now; }
this.data.cards.push({ id: uid(), type: o.type, text: o.text, status: "active", created: now, updated: now, updatedByProtocol: true });
n++;
} else if (o.op === "archive" && o.target) {
o.target.status = "archived"; o.target.updated = now; n++;
}
}
this.pendingOps = null;
this.save(); this.render();
this.closeAll();
document.getElementById("paste-input").value = "";
document.getElementById("paste-preview").innerHTML = "";
document.getElementById("btn-apply").style.display = "none";
this.toast(this.t().toastSaved(n));
}
openEdit(id) {
const t = this.t();
this.editingId = id;
const card = id ? this.data.cards.find(c => c.id === id) : null;
document.getElementById("edit-title").textContent = card ? t.editTitleEdit : t.editTitleAdd;
document.getElementById("edit-type").value = card ? card.type : "fact";
document.getElementById("edit-text").value = card ? card.text : "";
this.open("ov-edit");
document.getElementById("edit-text").focus();
}
saveEdit() {
const text = document.getElementById("edit-text").value.trim();
if (!text) return;
const type = document.getElementById("edit-type").value;
const now = Date.now();
if (this.editingId) {
const c = this.data.cards.find(c => c.id === this.editingId);
if (c) { c.text = text; c.type = type; c.updated = now; }
} else {
this.data.cards.push({ id: uid(), type, text, status: "active", created: now, updated: now });
}
this.save(); this.render(); this.closeAll();
}
cardAction(act, id) {
const c = this.data.cards.find(c => c.id === id);
if (!c) return;
if (act === "edit") { this.openEdit(id); return; }
if (act === "archive") { c.status = "archived"; c.updated = Date.now(); }
if (act === "restore") { c.status = "active"; c.updated = Date.now(); }
if (act === "delete") { if (!confirm(this.t().confirmDelete)) return; this.data.cards = this.data.cards.filter(x => x.id !== id); }
this.save(); this.render();
}
openWizard() { this.open("ov-wizard"); }
finishWizard(skip) {
if (!skip) {
const now = Date.now();
const map = [["wiz-q1", "fact"], ["wiz-q2", "status"], ["wiz-q3", "preference"], ["wiz-q4", "boundary"]];
for (const [id, type] of map) {
const v = document.getElementById(id).value.trim();
if (v) this.data.cards.push({ id: uid(), type, text: v, status: "active", created: now, updated: now });
}
this.save(); this.render();
}
localStorage.setItem("cc2_seen", "1");
this.closeAll();
this.openGuide();
}
openGuide() { this.open("ov-guide"); }
exportFile() {
const blob = new Blob([JSON.stringify(this.data, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `codecart-wallet-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
this.toast(this.t().toastExported);
}
importFile(file) {
const reader = new FileReader();
reader.onload = () => {
try {
const incoming = JSON.parse(reader.result);
const n = this.mergeCards(incoming.cards || []);
this.toast(this.t().toastImported(n));
} catch (e) { this.toast("⚠ " + e.message); }
};
reader.readAsText(file);
}
mergeCards(cards) {
const existing = new Set(this.data.cards.map(c => (c.text || "").trim().toLowerCase()));
let n = 0;
for (const c of cards) {
if (!c.text || existing.has(c.text.trim().toLowerCase())) continue;
this.data.cards.push({
id: uid(), type: TYPES.includes(c.type) ? c.type : "fact", text: c.text,
status: c.status === "archived" ? "archived" : "active",
created: c.created || Date.now(), updated: c.updated || Date.now()
});
existing.add(c.text.trim().toLowerCase()); n++;
}
if (n) { this.save(); this.render(); }
return n;
}
async shareLink() {
const url = location.origin + location.pathname + "#cc2=" + b64encode(this.data);
await this.copy(url);
this.toast(this.t().toastLink);
}
handleHashImport() {
if (!location.hash.startsWith("#cc2=")) return;
try {
const incoming = b64decode(location.hash.slice(5));
const n = this.mergeCards(incoming.cards || []);
if (n) this.toast(this.t().toastImported(n));
} catch (e) { console.warn("hash import failed", e); }
history.replaceState(null, "", location.pathname + location.search);
}
clearAll() {
if (!confirm(this.t().confirmClear)) return;
this.data = { version: 2, cards: [] };
this.save(); this.render(); this.closeAll();
}
open(id) { document.getElementById(id).classList.add("open"); }
closeAll() { document.querySelectorAll(".overlay").forEach(o => o.classList.remove("open")); }
toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg;
el.classList.add("show");
clearTimeout(this._tt);
this._tt = setTimeout(() => el.classList.remove("show"), 3200);
}
bind() {
document.getElementById("lang-select").addEventListener("change", e => this.setLang(e.target.value));
document.querySelectorAll(".lang-mini").forEach(s => s.addEventListener("change", e => this.setLang(e.target.value)));
document.getElementById("btn-theme").addEventListener("click", () => this.cycleTheme());
document.getElementById("btn-guide").addEventListener("click", () => this.openGuide());
document.getElementById("btn-settings").addEventListener("click", () => this.open("ov-settings"));
document.getElementById("btn-carry").addEventListener("click", () => this.carry());
document.getElementById("btn-paste").addEventListener("click", () => this.open("ov-paste"));
document.getElementById("btn-add").addEventListener("click", () => this.openEdit(null));
document.getElementById("btn-parse").addEventListener("click", () => this.parseAndPreview());
document.getElementById("btn-apply").addEventListener("click", () => this.applyOps());
document.getElementById("btn-edit-save").addEventListener("click", () => this.saveEdit());
document.getElementById("wiz-skip").addEventListener("click", () => this.finishWizard(true));
document.getElementById("wiz-done").addEventListener("click", () => this.finishWizard(false));
document.getElementById("btn-export").addEventListener("click", () => this.exportFile());
document.getElementById("btn-import").addEventListener("click", () => document.getElementById("import-file").click());
document.getElementById("import-file").addEventListener("change", e => { if (e.target.files[0]) this.importFile(e.target.files[0]); e.target.value = ""; });
document.getElementById("btn-share").addEventListener("click", () => this.shareLink());
document.getElementById("btn-clear").addEventListener("click", () => this.clearAll());
document.querySelectorAll("[data-close]").forEach(b => b.addEventListener("click", () => this.closeAll()));
document.querySelectorAll(".overlay").forEach(o => o.addEventListener("click", e => { if (e.target === o && o.id !== "ov-wizard") this.closeAll(); }));
document.getElementById("filters").addEventListener("click", e => {
const chip = e.target.closest("[data-filter]");
if (chip) { this.filter = chip.dataset.filter; this.render(); }
});
document.getElementById("grid").addEventListener("click", e => {
const btn = e.target.closest("[data-act]");
if (btn) this.cardAction(btn.dataset.act, btn.dataset.id);
});
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if (this.theme === "auto") this.applyTheme(); });
document.addEventListener("keydown", e => { if (e.key === "Escape") this.closeAll(); });
}
}
const app = new App();
</script>
</body>
</html>