-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrefgrab.html
More file actions
1385 lines (1251 loc) · 54.4 KB
/
Copy pathrefgrab.html
File metadata and controls
1385 lines (1251 loc) · 54.4 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
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Cross-Platform Reference Studio</title>
<style>
/* ============================================================
CSS CUSTOM PROPERTIES (Design Tokens)
These variables define the visual style – brushed metal,
aqua blue gradients, mac‑style text – making the UI
cohesive and easy to maintain.
============================================================ */
:root {
--brushed-aluminum: #d1d5da;
--window-bg: #e3e6ea;
--panel-dark: #3a3f44;
--panel-light: #f0f2f5;
--border-metal: #a3a8af;
--border-inset: #8e949c;
--aqua-blue: #3876e7;
--aqua-blue-grad: linear-gradient(to bottom, #5cb4ff 0%, #2b70f0 50%, #1050d0 100%);
--mac-text: #1c1c1c;
--mac-text-muted: #555555;
}
/* ============================================================
RESET & BASE
Box‑sizing border‑box everywhere, full‑height layout,
system font stack for a native‑looking studio.
============================================================ */
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; /* prevent body scroll – app handles all scrolling */
font-family: "Lucida Grande", "Segoe UI", Helvetica, Arial, sans-serif;
background-color: var(--window-bg);
color: var(--mac-text);
font-size: 12px;
}
/* Main app container – vertical flex: header, toolbar, content area */
.app { width:100vw; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
/* ============================================================
HEADER (merged title bar + timer + controls)
Combines the macOS‑style gradient, a centred title,
a timer on the left, and compact control buttons on the right.
Saves vertical space and keeps transport always visible.
============================================================ */
.header {
height: 36px; min-height: 36px;
display: flex; align-items: center; justify-content: space-between;
background: linear-gradient(to bottom, #ebebeb 0%, #d1d1d1 50%, #b4b4b4 100%);
border-bottom: 1px solid #7c7c7c;
padding: 0 12px;
font-size: 13px; font-weight: 700; color: #3f3f3f;
text-shadow: 0 1px 0 rgba(255,255,255,0.6);
}
.header-left { display: flex; align-items: center; gap: 6px; }
.header-title { font-size: 13px; font-weight: 700; text-align: center; flex: 1; }
.header-right { display: flex; align-items: center; gap: 4px; }
/* Timer – uses a monospace font inside an inset‑looking pill */
.timer {
font-family: Monaco, Menlo, monospace;
font-size: 13px; font-weight: 700;
background: linear-gradient(to bottom, #fff 0%, #e0e0e0 100%);
padding: 1px 6px;
border: 1px solid #999;
border-radius: 3px;
}
/* Timer preset dropdown + custom time input, sit next to the timer pill */
.timer-preset {
height:22px; font-size:11px; padding:0 3px; border-radius:4px;
border:1px solid #7c7c7c;
background: linear-gradient(to bottom, #ffffff 0%, #ececec 40%, #e0e0e0 50%, #dfdfdf 100%);
color:#222; cursor:pointer; outline:none;
}
.timer-custom-input {
height:22px; width:52px; font-size:11px; padding:0 4px; border-radius:4px;
border:1px solid #7c7c7c; text-align:center; outline:none;
background: linear-gradient(to bottom, #ffffff 0%, #f1f1f1 100%);
}
.timer-custom-wrap { display:inline-flex; align-items:center; gap:3px; }
.timer-unit-label { font-size:10px; font-weight:700; color:#5a5a5a; }
/* Small toolbar‑style buttons for transport (prev, next, focus, play/pause) */
.ctrl-btn {
height: 23px; padding: 0 6px; font-size: 11px; font-weight: 600;
border: 1px solid #7c7c7c; border-radius: 4px;
cursor: pointer;
background: linear-gradient(to bottom, #ffffff 0%, #ececec 40%, #e0e0e0 50%, #dfdfdf 100%);
text-shadow: 0 1px 0 #fff;
color: #222;
}
.ctrl-btn.aqua { /* highlight for primary actions */
color: white; border: 1px solid #1a4ba0;
background: var(--aqua-blue-grad);
text-shadow: 0 -1px 0 rgba(0,0,0,0.4);
}
/* ============================================================
TOOLBAR
Contains filters, token inputs, and the generate button.
Styled like a classic macOS toolbar.
============================================================ */
.toolbar {
padding: 6px 12px;
display: flex; flex-wrap: wrap; gap: 8px;
background: linear-gradient(to bottom, #f3f3f3 0%, #dddddd 100%);
border-bottom: 1px solid var(--border-metal);
align-items: center;
}
.toolbar-group { display:flex; align-items:center; gap:4px; }
.toolbar label { font-weight:bold; color:var(--mac-text-muted); }
/* Standard select & number inputs – small, subtle gradient */
select, input[type="number"] {
height:22px; padding:0 4px; font-size:11px; border-radius:4px;
border:1px solid var(--border-inset);
background: linear-gradient(to bottom, #ffffff 0%, #f1f1f1 100%);
outline:none;
}
/* Token container – acts like a tokenised search field (macOS‑inspired) */
.prompt-container-box {
flex:1; min-width:180px; display:flex; align-items:center; gap:4px;
background:#fcfcfc; border:1px solid var(--border-inset); border-radius:5px;
padding:2px 6px; box-shadow:inset 0 1px 2px rgba(0,0,0,0.15); overflow:hidden;
}
.prompt-container-box.negative-box { background:#fcfcfc; border-color:var(--border-inset); }
/* Scrollable row of tokens */
.token-bay {
display:flex; gap:4px; overflow-x:auto; white-space:nowrap; max-width:65%;
}
.token-bay::-webkit-scrollbar { height:3px; }
.token-bay::-webkit-scrollbar-thumb { background:#bbb; border-radius:2px; }
/* Individual token pill */
.tag-token {
background: linear-gradient(to bottom, #e2e8f0 0%, #cbd5e1 100%);
border:1px solid #94a3b8; padding:1px 6px; border-radius:10px;
display:inline-flex; align-items:center; gap:4px; font-size:11px; font-weight:600;
}
.tag-token span { cursor:pointer; color:#ef4444; }
/* Negative tokens have a slightly different treatment (same style for simplicity) */
.negative-box .tag-token {
background: linear-gradient(to bottom, #e2e8f0 0%, #cbd5e1 100%);
border-color:#94a3b8;
}
/* Input inside token field – looks seamless */
.prompt-routing-input {
border:none!important; outline:none!important; background:transparent!important;
font-size:12px; flex:1; min-width:60px;
}
/* Generic buttons – subtle gradient, small border, good for secondary actions */
button {
height:23px; border:1px solid #7c7c7c; border-radius:4px; padding:0 10px;
font-size:11px; font-weight:600; cursor:pointer;
background: linear-gradient(to bottom, #ffffff 0%, #ececec 40%, #e0e0e0 50%, #dfdfdf 100%);
text-shadow:0 1px 0 #fff;
}
button.primary-aqua { /* primary action (Generate Pool) – aqua gradient */
color:white; border:1px solid #1a4ba0;
background:var(--aqua-blue-grad); text-shadow:0 -1px 0 rgba(0,0,0,0.4);
}
/* ============================================================
MAIN LAYOUT – Sidebar + Viewer
============================================================ */
.main {
flex:1; display:flex; overflow:hidden; background-color:#555;
}
/* Inspector sidebar – narrow panel for metadata and tags */
.sidebar {
width:300px; min-width:300px; max-width:300px; overflow-y:auto; padding:10px;
background:#e8ebef; border-right:1px solid #9aa0a6;
display:flex; flex-direction:column; gap:10px; height:100%;
transition: margin-left 0.22s ease-in-out;
}
.sidebar.collapsed { margin-left:-300px; } /* hidden during focus mode */
/* Card component inside sidebar */
.card {
background:#f5f6f8; border:1px solid #b8bfc7; border-radius:5px; padding:10px;
}
.card h3 {
margin:0 0 8px 0; font-size:11px; text-transform:uppercase; color:var(--mac-text-muted);
border-bottom:1px dashed #b8bfc7; padding-bottom:4px;
}
.meta-row { margin-bottom:6px; }
.meta-label { font-weight:bold; color:#444; font-size:11px; }
.meta-val {
word-break:break-word; background:#ffffff; padding:4px;
border:1px solid #d1d5db; border-radius:3px; margin-top:2px;
}
.meta-val.source-badge {
font-weight:700; color:#1e3a8a; background:#eff6ff; border-color:#bfdbfe;
}
/* Tag cloud for reverse prompts */
.debug-pill-cloud {
display:flex; flex-wrap:wrap; gap:4px; background:#ffffff; padding:6px;
border:1px solid #d1d5db; border-radius:3px; margin-top:2px; max-height:220px; overflow-y:auto;
}
.debug-tag {
background:linear-gradient(to bottom, #f8fafc 0%, #e2e8f0 100%);
color:#334155; border:1px solid #cbd5e1; padding:1px 5px; border-radius:3px;
font-family:monospace; font-size:10px; cursor:pointer;
}
.debug-tag:hover { background:#3876e7; color:white; border-color:#1d4ed8; }
/* ============================================================
VIEWER – Image display and filmstrip
============================================================ */
.viewer {
flex:1; display:flex; flex-direction:column; background:#1e1e1e; overflow:hidden; height:100%;
}
/* Image area – centres the artwork, provides dark background */
.image-area {
flex:1; display:flex; justify-content:center; align-items:center;
overflow:hidden; position:relative; padding:15px;
}
#loading { font-size:13px; font-weight:bold; color:#aaaaaa; text-align:center; line-height:1.5; }
.image-area img {
width:100%; height:100%; max-width:100%; max-height:100%;
object-fit:contain; background:#111; outline:3px solid #333;
box-shadow:0 10px 30px rgba(0,0,0,0.6); cursor:pointer;
}
/* Loading bar for the main image (aqua, slides across the top) */
.image-loading-bar {
position:absolute; top:0; left:0; height:4px;
background:var(--aqua-blue-grad); box-shadow:0 0 8px rgba(56,118,231,0.7);
transition: width 0.3s ease; width:0%; z-index:10; border-radius:0 2px 2px 0;
}
/* Filmstrip container */
.filmstrip {
height:90px; min-height:90px; display:flex; gap:6px; overflow-x:auto;
padding:6px; background:#2d3033; border-top:1px solid #1c1d1f;
transition: height 0.22s ease-in-out, min-height 0.22s ease-in-out, padding 0.22s ease-in-out;
}
.filmstrip.collapsed { height:0px; min-height:0px; padding:0px; border-top:none; overflow:hidden; }
/* Thumbnail wrapper – needed to position the loading indicator */
.thumb-wrapper {
position: relative;
width:74px; height:74px; flex-shrink:0;
}
.thumb {
width:74px; height:74px; object-fit:cover; cursor:pointer; border-radius:3px;
border:2px solid #444; opacity:0.5; display:block;
}
.thumb.active { border-color: var(--aqua-blue); opacity:1; }
/* Small aqua bar at the bottom of each thumbnail to show loading progress */
.thumb-loading-bar {
position: absolute; bottom:0; left:0; height:3px;
background: var(--aqua-blue-grad); box-shadow:0 0 6px rgba(56,118,231,0.5);
width:0%; transition: width 0.3s ease;
border-radius:0 0 3px 3px;
}
.thumb-wrapper.loaded .thumb-loading-bar { display:none; } /* hide once fully loaded */
/* Pool counter badge – bright blue, visible next to Calculate button */
.pool-counter-badge {
background:#3b82f6; color:white; font-size:11px; font-weight:bold;
padding:2px 7px; border-radius:10px; text-shadow:0 -1px 0 rgba(0,0,0,0.2);
}
/* Focus mode indicator */
.focus-indicator-pill {
position:absolute; top:12px; right:12px;
background:rgba(0,0,0,0.75); color:#fff; padding:4px 10px;
border-radius:12px; font-size:11px; pointer-events:none; font-weight:bold;
}
</style>
</head>
<body>
<div class="app">
<!-- HEADER: timer, title, transport buttons merged -->
<div class="header">
<div class="header-left">
<span class="timer" id="timer">02:00</span>
<select class="timer-preset" id="timerPreset" onchange="applyTimerPreset()">
<option value="30">30 sec</option>
<option value="60">1 min</option>
<option value="120" selected>2 min</option>
<option value="300">5 min</option>
<option value="600">10 min</option>
<option value="900">15 min</option>
<option value="custom">Custom…</option>
</select>
<span class="timer-custom-wrap" id="timerCustomWrap" style="display:none">
<input type="number" class="timer-custom-input" id="timerCustomInput" placeholder="e.g. 7" min="0.1" step="0.5">
<span class="timer-unit-label">min</span>
</span>
</div>
<div class="header-title">Cross-Platform Reference Studio</div>
<div class="header-right">
<button class="ctrl-btn" onclick="toggleFocusMode()" id="btnFocus">Focus</button>
<button class="ctrl-btn aqua" onclick="toggleTimer()" id="btnPlay">Start</button>
<button class="ctrl-btn" onclick="previousImage()">◀</button>
<button class="ctrl-btn" onclick="nextImage()">▶</button>
</div>
</div>
<!-- TOOLBAR: search settings, token inputs, generate button -->
<div class="toolbar">
<div class="toolbar-group">
<label for="providerEngine">Library:</label>
<select id="providerEngine" onchange="runLiveCountEstimator()">
<option value="all">Combined (Wiki + Archive)</option>
<option value="wikimedia">Wikimedia Commons Only</option>
<option value="internetarchive">Internet Archive Only</option>
</select>
</div>
<div class="toolbar-group">
<label for="matchStrategy">Matching:</label>
<select id="matchStrategy" onchange="runLiveCountEstimator()">
<option value="and">Filter Overlap (AND)</option>
<option value="or">Individual Terms (OR)</option>
</select>
</div>
<div class="toolbar-group">
<label for="medium">Medium:</label>
<select id="medium" onchange="runLiveCountEstimator()">
<option value="">Any Medium</option>
<option value="photograph">Photography</option>
<option value="oil painting">Oil Painting</option>
<option value="watercolor">Watercolor</option>
<option value="charcoal drawing">Charcoal</option>
</select>
</div>
<div class="toolbar-group">
<label for="layoutSorting">Order:</label>
<select id="layoutSorting" onchange="applyLiveSequenceSorting()">
<option value="random">Randomize (Shuffle Pool)</option>
<option value="az">Title Sequence (A‑Z)</option>
<option value="za">Reverse Sequence (Z‑A)</option>
</select>
</div>
<!-- Positive token input -->
<div class="prompt-container-box">
<div class="token-bay" id="tokenBay"></div>
<input class="prompt-routing-input" id="bulkPromptInput" type="text" placeholder="Include tags...">
</div>
<!-- Negative token input -->
<div class="prompt-container-box negative-box">
<div class="token-bay" id="negativeTokenBay"></div>
<input class="prompt-routing-input" id="negativePromptInput" type="text" placeholder="Exclude tags...">
</div>
<div class="toolbar-group">
<button onclick="runLiveCountEstimator()">Calculate Matches</button>
<span id="livePoolCounter" class="pool-counter-badge" style="display:none">0 Hits</span>
</div>
<div class="toolbar-group">
<label for="sessionSize">Size:</label>
<input id="sessionSize" type="number" value="20" min="5" max="100" style="width: 44px;">
</div>
<button class="primary-aqua" onclick="buildSession()">Generate Pool</button>
</div>
<!-- MAIN CONTENT: sidebar + viewer -->
<div class="main">
<!-- Sidebar: metadata inspector -->
<div class="sidebar" id="sidebarDrawer">
<div class="card">
<h3>Current File Inspector</h3>
<div class="meta-row">
<div class="meta-label">Catalog Provider Source</div>
<div class="meta-val source-badge" id="metaSource">—</div>
</div>
<div class="meta-row">
<div class="meta-label">Title/Item ID</div>
<div class="meta-val" id="metaTitle">—</div>
</div>
<div class="meta-row">
<div class="meta-label">Creator / Source</div>
<div class="meta-val" id="metaCreator">—</div>
</div>
<div class="meta-row">
<div class="meta-label">Date Group</div>
<div class="meta-val" id="metaDate">—</div>
</div>
<div class="meta-row">
<div class="meta-label">Technical Metadata</div>
<div class="meta-val" id="metaInteresting" style="max-height: 120px; overflow-y: auto;">—</div>
</div>
<div class="meta-row">
<div class="meta-label">Source Asset Link</div>
<div style="margin-top:3px;"><a id="sourceAssetLink" target="_blank">Open Source Record</a></div>
</div>
</div>
<div class="card" id="debugPromptCard">
<h3>Reverse Tag Inspector</h3>
<div class="meta-row">
<div class="meta-label">All Mapped Potential Prompts</div>
<div class="debug-pill-cloud" id="debugPillCloud"></div>
</div>
</div>
</div>
<!-- Viewer: main image + filmstrip -->
<div class="viewer">
<div class="image-area" id="imageArea">
<div class="image-loading-bar" id="loadingBar"></div>
<div class="focus-indicator-pill" id="focusPill">Focus Mode: Click Image or Press 'F' to Reset</div>
<div id="loading">Add search tags above to build a dynamic art stream layout.</div>
<img id="mainImage" onclick="toggleFocusMode()" style="display:none" alt="Studio Canvas View">
</div>
<div id="filmstrip" class="filmstrip"></div>
</div>
</div>
</div>
<script>
// ============================================================
// GLOBAL STATE
// These variables hold the current session, tokens, timer, and
// focus mode. They are accessed by most functions.
// ============================================================
let promptTokens = []; // user‑added search keywords
let negativeTokens = []; // exclusion keywords (defaults: text, book)
let sessionImages = []; // the current pool of images
let currentIndex = 0; // which image is currently displayed
let DEFAULT_TIME = 120; // slideshow timer length in seconds (default 2 min, user-adjustable)
let remaining = DEFAULT_TIME; // current countdown value
let timerHandle = null; // interval ID for the timer
let running = false; // is the slideshow running?
let focusModeActive = false; // is focus mode on?
// DOM references for frequently used elements
const bulkInput = document.getElementById("bulkPromptInput");
const negInput = document.getElementById("negativePromptInput");
const tokenBay = document.getElementById("tokenBay");
const negTokenBay = document.getElementById("negativeTokenBay");
const loadingBar = document.getElementById("loadingBar");
// ============================================================
// TOKEN MANAGEMENT
// Handles adding/removing visual tokens and feeding them
// into the search queries.
// ============================================================
/** Renders all positive tokens as pill‑shaped elements */
function renderTokens() {
tokenBay.innerHTML = "";
promptTokens.forEach((tok, index) => {
const div = document.createElement("div");
div.className = "tag-token";
div.innerText = tok + " ";
const rm = document.createElement("span");
rm.innerText = "×";
rm.onclick = () => { promptTokens.splice(index, 1); renderTokens(); runLiveCountEstimator(); };
div.appendChild(rm);
tokenBay.appendChild(div);
});
}
/** Renders all negative tokens (same style as positive) */
function renderNegativeTokens() {
negTokenBay.innerHTML = "";
negativeTokens.forEach((tok, index) => {
const div = document.createElement("div");
div.className = "tag-token";
div.innerText = tok + " ";
const rm = document.createElement("span");
rm.innerText = "×";
rm.onclick = () => { negativeTokens.splice(index, 1); renderNegativeTokens(); runLiveCountEstimator(); };
div.appendChild(rm);
negTokenBay.appendChild(div);
});
}
// Convert comma/enter into tokens
bulkInput.addEventListener("keydown", (e) => {
if (e.key === "," || e.key === "Enter") {
e.preventDefault();
const val = bulkInput.value.replace(/,/g, "").trim();
if (val && !promptTokens.includes(val)) {
promptTokens.push(val);
renderTokens();
runLiveCountEstimator();
}
bulkInput.value = "";
}
});
negInput.addEventListener("keydown", (e) => {
if (e.key === "," || e.key === "Enter") {
e.preventDefault();
const val = negInput.value.replace(/,/g, "").trim();
if (val && !negativeTokens.includes(val)) {
negativeTokens.push(val);
renderNegativeTokens();
runLiveCountEstimator();
}
negInput.value = "";
}
});
/** Allows clicking a tag in the reverse inspector to add it as a prompt */
function injectDebugTag(tagText) {
const cleanTag = tagText.trim();
if (cleanTag && !promptTokens.includes(cleanTag)) {
promptTokens.push(cleanTag);
renderTokens();
runLiveCountEstimator();
}
}
// ============================================================
// QUERY ASSEMBLY
// Each provider has its own search syntax. These functions
// translate the token arrays into a string the API understands.
// ============================================================
/**
* Maps the "Medium" dropdown to provider-specific, high-precision
* filters instead of relying on free-text matching against
* whatever happens to be in a description field.
* - wikiCategory: a real Commons category, matched with deepcat:
* rather than incategory:. incategory: only matches files filed
* directly in that exact category, and almost nothing on Commons
* is filed directly under a broad category like "Photographs" —
* real photos live in specific subcategories (by subject, place,
* photographer, etc). deepcat: recurses into those subcategories,
* which is why switching to it fixes the "photography basically
* returns nothing" problem.
* - iaTerms: several synonym phrases ORed together for Internet
* Archive, since IA has no structured medium field to match
* against and a single exact phrase misses too many valid items.
*/
const MEDIUM_MAP = {
"photograph": { wikiCategory: "Photographs", iaTerms: ["photograph", "photography", "photo"] },
"oil painting": { wikiCategory: "Oil paintings", iaTerms: ["oil painting", "oil on canvas"] },
"watercolor": { wikiCategory: "Watercolor paintings", iaTerms: ["watercolor", "watercolour"] },
"charcoal drawing": { wikiCategory: "Charcoal drawings", iaTerms: ["charcoal drawing", "charcoal sketch", "charcoal"] }
};
/**
* Builds a Wikimedia Commons search string.
* Tokens are wrapped in quotes and joined with AND/OR.
* Medium is matched against a real Commons category tree (deepcat:)
* when a mapping exists, which recurses into subcategories instead
* of requiring files to be filed directly under the top category.
* Negative tokens are prefixed with a minus sign.
* Namespace restriction to File: is handled by the srnamespace/
* gsrnamespace API param elsewhere, not by query text.
*/
function assembleWikimediaQuery() {
const medium = document.getElementById("medium").value;
const strat = document.getElementById("matchStrategy").value;
const joiner = (strat === "and") ? " AND " : " OR ";
let q = promptTokens.length > 0
? promptTokens.map(t => `"${t}"`).join(joiner)
: "art";
if (medium && MEDIUM_MAP[medium]) {
q = `(${q}) AND deepcat:"${MEDIUM_MAP[medium].wikiCategory}"`;
} else if (medium) {
q = `(${q}) AND "${medium}"`;
}
if (negativeTokens.length) q += " " + negativeTokens.map(t => `-"${t}"`).join(" ");
return q;
}
/**
* Builds an Internet Archive search string.
* Two modes: general (mediatype:image) and Flickr (collection:flickrcommons).
* Medium is honored here too (previously silently dropped, so
* choosing e.g. "Oil Painting" had no effect on Archive.org results),
* and matched against a small OR'd group of synonym phrases rather
* than one exact phrase, since IA has no structured medium field.
* Negative tokens become NOT "token".
*/
function assembleInternetArchiveQuery(includeFlickr = false) {
const strat = document.getElementById("matchStrategy").value;
const medium = document.getElementById("medium").value;
const tokens = promptTokens.length > 0 ? promptTokens : ["art"];
const keywordPart = tokens.map(t => `"${t}"`).join(strat === "and" ? " AND " : " OR ");
let mediumPart = "";
if (medium) {
const terms = MEDIUM_MAP[medium]?.iaTerms || [medium];
const orGroup = terms.map(t => `"${t}"`).join(" OR ");
mediumPart = ` AND (${orGroup})`;
}
let negativePart = "";
if (negativeTokens.length > 0) {
negativePart = " AND " + negativeTokens.map(t => `NOT "${t}"`).join(" AND ");
}
if (includeFlickr) {
return `(${keywordPart})${mediumPart} AND collection:flickrcommons${negativePart}`;
}
return `(${keywordPart})${mediumPart} AND mediatype:image${negativePart}`;
}
// ============================================================
// TAG EXTRACTION ENGINE
// Produces a ranked, de-noised set of "reverse prompt" tags from
// raw catalog metadata (title, description, categories/subjects,
// creator, etc). Two problems in the old approach are fixed here:
// 1. No stopword filtering at all – common words like "the",
// "file", "unknown", "jpg" flooded the tag cloud.
// 2. Category/subject phrases were shredded into single words,
// losing meaning (e.g. "Oil paintings" became two unrelated
// tags "oil" and "paintings" instead of one useful tag).
// This version keeps meaningful multi-word phrases intact, scores
// tags by how many (and how important) fields they came from, and
// returns the most relevant tags first instead of an arbitrary set.
// ============================================================
const TAG_STOPWORDS = new Set([
"the","and","with","from","for","this","that","was","were","are","has","have",
"its","into","via","page","pages","vol","volume","no","untitled","copy","copies",
"digital","digitized","scan","scanned","scans","upload","uploaded","uploads","user",
"users","public","domain","license","licensed","rights","reserved","reproduction",
"reproductions","file","files","image","images","photo","photos","photograph",
"photographs","picture","pictures","item","items","collection","collections",
"archive","archives","wikimedia","commons","wikipedia","org","com","http","https",
"www","jpg","jpeg","png","tif","tiff","gif","pdf","author","authors","unknown",
"unidentified","anonymous","source","sources","description","title","titled",
"original","originals","record","records","catalog","catalogue","identifier",
"various","misc","miscellaneous","other","others","new","old","see","also",
"part","parts","series","edition","editions","print","prints","printed","printing",
"not","und","der","die","das","von"
]);
/** Splits text into clean lowercase word tokens, dropping stopwords,
* fragments under 3 characters, and bare numbers (except plausible
* 4-digit years, which are useful reference context). */
function splitToWords(phrase) {
return String(phrase)
.toLowerCase()
.split(/[^a-z0-9]+/i)
.map(w => w.trim())
.filter(w => {
if (w.length < 3) return false;
if (/^[0-9]+$/.test(w)) return /^(1[4-9][0-9]{2}|20[0-9]{2})$/.test(w);
return !TAG_STOPWORDS.has(w);
});
}
/** Normalizes a category/subject string into a clean phrase
* ("Category:Oil_paintings" -> "oil paintings"). */
function normalizePhrase(phrase) {
return String(phrase)
.replace(/^Category:/i, "")
.replace(/[_|]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.toLowerCase();
}
/**
* Builds a ranked tag list from weighted source fields.
* `sources` is an array of { text, weight, asPhrase } entries.
* `asPhrase:true` keeps short multi-word values (e.g. a category
* name) intact as a single tag, in addition to scoring its
* component words individually. Higher-signal fields (title,
* subject/category) should use a higher weight than noisy ones
* (free-text description).
* Returns up to `limit` tags, most relevant first.
*/
function buildTagProfile(sources, limit = 30) {
const scores = new Map();
const bump = (tag, weight) => {
if (!tag || tag.length < 3) return;
scores.set(tag, (scores.get(tag) || 0) + weight);
};
sources.forEach(({ text, weight, asPhrase }) => {
if (!text) return;
const values = Array.isArray(text) ? text : [text];
values.forEach(val => {
if (!val) return;
if (asPhrase) {
const phrase = normalizePhrase(val);
if (phrase && phrase.split(" ").length <= 4 && !TAG_STOPWORDS.has(phrase)) {
bump(phrase, weight + 1); // whole phrase scores slightly above its parts
}
splitToWords(phrase).forEach(w => bump(w, weight));
} else {
splitToWords(val).forEach(w => bump(w, weight));
}
});
});
return [...scores.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, limit)
.map(([tag]) => tag);
}
/**
* Hard client-side exclusion check, run on every fetched item AFTER
* it comes back from the API. This exists because "-token"/"NOT"
* in the provider search only excludes matches against that
* provider's indexed search text — which does NOT necessarily cover
* every metadata field (e.g. Wikimedia's Artist/Credit fields and
* Internet Archive's raw identifier aren't part of the full-text
* search index, and stemming differences like "book" vs "books" can
* let things slip through). Checking the same tag/title/creator data
* the user sees in the Reverse Tag Inspector guarantees that if a
* negative tag is visible on an item, that item never gets shown.
* Returns true if the item should be EXCLUDED.
*/
function violatesNegativeTokens(item) {
if (!negativeTokens.length || !item) return false;
const haystack = new Set();
splitToWords(item.title).forEach(w => haystack.add(w));
// Deliberately NOT checking item.creator here: for old public-domain
// photographs the "creator" field is very often a publishing house
// (e.g. "American Book Company", "Ginn & Co., Booksellers"), and
// that incidentally containing "book" says nothing about whether
// the photograph itself depicts a book. Checking it was silently
// wiping out large numbers of legitimate photographs on publisher
// name alone.
// Only the strongest tags (title/subject/category derived, which
// score highest) are checked, not the full noisy tail of the tag
// list — a single incidental word from weak free-text description
// shouldn't be enough to drop an otherwise-good match.
(item.possiblePrompts || []).slice(0, 15).forEach(tag => splitToWords(tag).forEach(w => haystack.add(w)));
return negativeTokens.some(neg => {
const words = splitToWords(neg);
if (!words.length) return false; // negative token was pure stopword/number, ignore it
return words.every(w => haystack.has(w));
});
}
// ============================================================
// LIVE COUNTER ESTIMATOR
// Queries each selected provider for the total number of hits
// and displays a combined count. Helps the user understand
// how broad their search is before generating a pool.
// ============================================================
async function runLiveCountEstimator() {
const badge = document.getElementById("livePoolCounter");
const provider = document.getElementById("providerEngine").value;
badge.style.display = "inline-block";
badge.style.background = "#888888";
badge.textContent = "Estimating...";
let total = 0;
// Wikimedia count
if (provider === "wikimedia" || provider === "all") {
try {
const url = "https://commons.wikimedia.org/w/api.php?" + new URLSearchParams({
action:"query", format:"json", origin:"*", list:"search",
srsearch: assembleWikimediaQuery(), srnamespace:"6", srlimit:"1", srprop:""
});
const r = await fetch(url); const d = await r.json();
total += d.query?.searchinfo?.totalhits || 0;
} catch {}
}
// Internet Archive count – sum of general + Flickr
if (provider === "internetarchive" || provider === "all") {
try {
const [resGeneral, resFlickr] = await Promise.all([
fetch("https://archive.org/advancedsearch.php?" + new URLSearchParams({
q: assembleInternetArchiveQuery(false), output:"json", rows:"1", page:"1"
})),
fetch("https://archive.org/advancedsearch.php?" + new URLSearchParams({
q: assembleInternetArchiveQuery(true), output:"json", rows:"1", page:"1"
}))
]);
const d1 = await resGeneral.json();
const d2 = await resFlickr.json();
total += (d1.response?.numFound || 0) + (d2.response?.numFound || 0);
} catch {}
}
badge.style.background = total > 0 ? "#27c93f" : "#ff5f56";
// deepcat: (used for the Medium filter) can report a substantially
// inflated totalHits versus the number of results actually
// retrievable, since recursive category expansion can count the
// same file more than once before dedup. Flag the number as
// approximate in that case so it doesn't read as a hard guarantee.
const medium = document.getElementById("medium").value;
const isDeepCatSearch = !!(medium && MEDIUM_MAP[medium] && (provider === "wikimedia" || provider === "all"));
const prefix = isDeepCatSearch ? "~" : "";
badge.title = isDeepCatSearch
? "Category-based counts for a selected Medium are approximate and may be higher than what's actually retrievable."
: "";
badge.textContent = `${prefix}${total.toLocaleString()} Available`;
}
// ============================================================
// FETCH FUNCTIONS
// Each fetcher reaches out to its respective API, parses the
// response, and returns an array of uniform image objects.
// ============================================================
/**
* Wikimedia Commons fetcher.
* First obtains total hits to pick a random offset, ensuring
* we don't always get the same first page of results.
* Then requests 50 images with full metadata.
*
* Special handling for deepcat: (used by the Medium filter) — its
* reported totalHits count can be substantially inflated relative to
* how many results CirrusSearch can actually page through, because
* the recursive category expansion can count the same file multiple
* times across different category paths before it's deduplicated.
* Picking a random offset scaled to that inflated total was landing
* past the real result set and coming back almost empty (e.g. 5
* images despite a "high" reported count). So for deepcat searches
* the offset range is capped much more conservatively, and if a
* chosen offset still comes back sparse, we fall back to the start
* of the result set, which is guaranteed to contain real matches.
*/
async function fetchStableWikimedia() {
const medium = document.getElementById("medium").value;
const isDeepCatSearch = !!(medium && MEDIUM_MAP[medium]);
let totalHits = 0;
try {
const countUrl = "https://commons.wikimedia.org/w/api.php?" + new URLSearchParams({
action:"query", format:"json", origin:"*", list:"search",
srsearch: assembleWikimediaQuery(), srnamespace:"6", srlimit:"1", srprop:""
});
const cr = await fetch(countUrl); const cd = await cr.json();
totalHits = cd.query?.searchinfo?.totalhits || 0;
} catch { totalHits = 5000; }
const offsetCeiling = isDeepCatSearch ? Math.min(totalHits, 400) : Math.min(totalHits, 10000);
const maxOff = offsetCeiling - 50;
const randOff = maxOff > 0 ? Math.floor(Math.random() * maxOff) : 0;
const results = await fetchWikimediaPage(randOff);
// If the random page came back too sparse, the offset likely
// overshot the real (deduplicated) result set. Retry at the start,
// which will always reflect genuinely matching files if any exist.
if (results.length < 15 && randOff > 0) {
const fallback = await fetchWikimediaPage(0);
if (fallback.length > results.length) return fallback;
}
return results;
}
/** Fetches a single page of Wikimedia results at a given offset and maps them into the app's uniform image format. */
async function fetchWikimediaPage(offset) {
const url = "https://commons.wikimedia.org/w/api.php?" + new URLSearchParams({
action:"query", format:"json", origin:"*", generator:"search",
gsrsearch: assembleWikimediaQuery(), gsrnamespace:"6", gsrlimit:"50",
gsroffset: String(offset),
prop:"imageinfo|globalusage|categories", iiprop:"url|extmetadata", iiurlwidth:"1200",
cllimit:"50"
});
try {
const r = await fetch(url); const data = await r.json();
return Object.values(data.query?.pages || {}).map(item => {
if (!item.imageinfo?.[0]?.thumburl) return null;
const titleClean = item.title.replace("File:", "").replace(/\.[^/.]+$/, "");
const meta = item.imageinfo[0].extmetadata;
const categoryTitles = (item.categories || []).map(c => c.title).filter(Boolean);
const usageTitles = (item.globalusage || []).map(u => u.title).filter(Boolean);
// Title and structured category/subject data are the strongest
// signal for what's actually depicted, so they're weighted
// highest and kept as intact phrases. Free-text description and
// creator/credit fields are noisier, so they contribute at a
// lower weight and only as individual words.
const tags = buildTagProfile([
{ text: titleClean, weight: 3 },
{ text: meta?.ObjectName?.value, weight: 3 },
{ text: meta?.Categories?.value?.split("|"), weight: 3, asPhrase: true },
{ text: categoryTitles, weight: 3, asPhrase: true },
{ text: meta?.ImageDescription?.value?.replace(/<[^>]*>/g, ""), weight: 1 },
{ text: meta?.Artist?.value?.replace(/<[^>]*>/g, ""), weight: 1 },
{ text: meta?.Credit?.value?.replace(/<[^>]*>/g, ""), weight: 1 },
{ text: usageTitles, weight: 1, asPhrase: true }
]);
return {
sourceRepo: "Wikimedia Commons Digital Library",
title: titleClean,
image: item.imageinfo[0].thumburl,
creator: meta?.Artist?.value || "Wikimedia Contributor",
date: meta?.DateTime?.value || "Unknown",
tech: `License: ${meta?.LicenseShortName?.value||'Public Domain'}`,
link: "https://commons.wikimedia.org/wiki/" + encodeURIComponent(item.title),
possiblePrompts: tags
};
}).filter(Boolean);
} catch { return []; }
}
/**
* Generic Internet Archive fetcher.
* Uses the advancedsearch API with random sort.
* Returns objects with both a high‑res IIIF URL and a small preview.
*/
async function fetchIA(query, rows) {
const url = "https://archive.org/advancedsearch.php?" + new URLSearchParams({
q: query, output:"json", rows: String(Math.max(rows, 100)), page:"1",
"fl[]":"identifier,title,creator,date,description,subject,format",
sort:"random"
});
try {
const r = await fetch(url); const d = await r.json();
return (d.response?.docs || []).map(doc => {
if (!doc.identifier) return null;
// Subject headings are curated, phrase-level metadata (e.g.
// "Portrait photography") and are the most reliable indicator
// of content, so they're weighted highest and kept intact.
// Title is next-best; description/creator/identifier are
// noisier free text and contribute at a lower weight.
const tags = buildTagProfile([
{ text: doc.subject, weight: 3, asPhrase: true },
{ text: doc.title, weight: 3 },
{ text: doc.description?.replace(/<[^>]*>/g, ""), weight: 1 },
{ text: doc.creator, weight: 1 },
{ text: doc.identifier, weight: 1 }
]);
return {
sourceRepo: "Internet Archive (Archive.org)",
title: doc.title || doc.identifier,
image: `https://iiif.archive.org/iiif/${doc.identifier}/full/max/0/default.jpg`, // high‑res
preview: `https://iiif.archive.org/iiif/${doc.identifier}/full/!500,500/0/default.jpg`, // low‑res placeholder
identifier: doc.identifier,
miniThumb: `https://archive.org/services/img/${doc.identifier}?scale=2`,
creator: doc.creator || "Internet Archive Digital Preservation Library",
date: doc.date || "Unknown Historical Era",
tech: `Catalog ID: ${doc.identifier}`,
link: `https://archive.org/details/${doc.identifier}`,
possiblePrompts: tags
};
}).filter(Boolean);
} catch { return []; }
}
/**
* Top‑level Internet Archive fetcher that merges two queries:
* general images + Flickr Commons.
*/
async function fetchStableInternetArchive(maxReq) {
const [general, flickr] = await Promise.all([
fetchIA(assembleInternetArchiveQuery(false), maxReq),
fetchIA(assembleInternetArchiveQuery(true), maxReq)
]);
const seen = new Set();
const merged = [];
for (const item of [...general, ...flickr]) {
if (!seen.has(item.identifier)) {
seen.add(item.identifier);
merged.push(item);
}
}
return merged;
}
// ============================================================
// SESSION BUILDER
// Combines results from the selected provider(s), deduplicates,
// trims to the desired session size, and triggers preloading.
// ============================================================
async function buildSession() {
// Stop any running slideshow
if (running) toggleTimer();