+32
-1
index.html
+32
-1
index.html
···
49
49
50
50
<li><a href="#" data-action="login">Log in</a></li>
51
51
<li><a href="#" data-action="logout">Log out</a></li>
52
+
53
+
<li class="link"><a href="?">Home</a></li>
54
+
<li class="link"><a href="?page=posting_stats">Posting stats</a></li>
55
+
<li class="link"><a href="?page=like_stats">Like stats</a></li>
56
+
<li class="link"><a href="?page=search">Timeline search</a></li>
57
+
<li class="link"><a href="?page=search&mode=likes">Archive search</a></li>
52
58
</ul>
53
59
</div>
54
60
···
164
170
</div>
165
171
166
172
<div id="private_search_page">
167
-
<h2>Archive search *Beta*</h2>
173
+
<h2>Archive search</h2>
168
174
169
175
<div class="timeline-search">
170
176
<form>
···
192
198
<input type="radio" name="collection" value="pins" id="collection-pins"> <label for="collection-pins">Pins</label>
193
199
</div>
194
200
</form>
201
+
202
+
<div class="lycan-import">
203
+
<form>
204
+
<h4>Data not imported yet</h4>
205
+
206
+
<p>
207
+
In order to search within your likes and bookmarks, the posts you've liked or saved need to be imported into a database.
208
+
This is a one-time process, but it can take several minutes or more, depending on the age of your account.
209
+
</p>
210
+
<p>
211
+
To start the import, press the button below. You can then wait until it finishes, or close this tab and come back a bit later.
212
+
After the import is complete, the database will be kept up to date automatically going forward.
213
+
</p>
214
+
<p>
215
+
<input type="submit" value="Start import">
216
+
</p>
217
+
</form>
218
+
219
+
<div class="import-progress">
220
+
<h4>Import in progress</h4>
221
+
222
+
<p class="import-status"></p>
223
+
<p><progress></progress> <output></output></p>
224
+
</div>
225
+
</div>
195
226
196
227
<div class="results">
197
228
</div>
+1
-1
minisky.js
+1
-1
minisky.js
···
301
301
let text = await response.text();
302
302
let json = text.trim().length > 0 ? JSON.parse(text) : undefined;
303
303
304
-
if (response.status == 200) {
304
+
if (response.status >= 200 && response.status < 300) {
305
305
return json;
306
306
} else {
307
307
throw new APIError(response.status, json);
+17
-14
post_component.js
+17
-14
post_component.js
···
171
171
}
172
172
173
173
if (this.post.originalFediURL) {
174
-
wrapper.appendChild(this.buildFediSourceLink());
174
+
let link = this.buildFediSourceLink(this.post.originalFediURL);
175
+
if (link) {
176
+
wrapper.appendChild(link);
177
+
}
175
178
}
176
179
177
180
if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
···
316
319
highlightSearchResults(terms) {
317
320
let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi');
318
321
319
-
let body = this._rootElement.querySelector(':scope > .content > .body, :scope > .content > details .body');
322
+
let root = this.rootElement;
323
+
let body = $(root.querySelector(':scope > .content > .body, :scope > .content > details .body'));
320
324
let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
321
325
let textNodes = [];
322
326
···
325
329
}
326
330
327
331
for (let node of textNodes) {
332
+
if (!node.textContent) { continue; }
333
+
328
334
let markedText = document.createDocumentFragment();
329
335
let currentPosition = 0;
330
336
···
348
354
markedText.appendChild(document.createTextNode(remainingText));
349
355
}
350
356
351
-
node.parentNode.replaceChild(markedText, node);
357
+
$(node.parentNode).replaceChild(markedText, node);
352
358
}
353
359
}
354
360
···
486
492
this.loadHiddenSubtree(this.post, this.rootElement);
487
493
}
488
494
489
-
/** @returns {HTMLElement | undefined} */
495
+
/** @param {string} url, @returns {HTMLElement | undefined} */
490
496
491
-
buildFediSourceLink() {
492
-
let url = this.post.originalFediURL;
493
-
let hostname;
497
+
buildFediSourceLink(url) {
498
+
try {
499
+
let hostname = new URL(url).hostname;
500
+
let a = $tag('a.fedi-link', { href: url, target: '_blank' });
494
501
495
-
try {
496
-
hostname = new URL(url).hostname;
502
+
let box = $tag('div', { html: `<i class="fa-solid fa-arrow-up-right-from-square fa-sm"></i> View on ${hostname}` });
503
+
a.append(box);
504
+
return a;
497
505
} catch (error) {
498
506
console.log("Invalid Fedi URL:" + error);
499
507
return undefined;
500
508
}
501
-
502
-
let a = $tag('a.fedi-link', { href: url, target: '_blank' });
503
-
let box = $tag('div', { html: `<i class="fa-solid fa-arrow-up-right-from-square fa-sm"></i> View on ${hostname}` });
504
-
a.append(box);
505
-
return a;
506
509
}
507
510
508
511
/** @param {HTMLLinkElement} authorLink */
+200
-18
private_search_page.js
+200
-18
private_search_page.js
···
3
3
/** @type {number | undefined} */
4
4
fetchStartTime;
5
5
6
+
/** @type {number | undefined} */
7
+
importTimer;
8
+
9
+
/** @type {string | undefined} */
10
+
lycanImportStatus;
11
+
6
12
constructor() {
7
13
this.pageElement = $id('private_search_page');
8
14
15
+
this.header = $(this.pageElement.querySelector('h2'));
16
+
9
17
this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
10
18
this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
11
19
this.progressBar = $(this.pageElement.querySelector('input[type="submit"] + progress'), HTMLProgressElement);
···
16
24
this.searchForm = $(this.pageElement.querySelector('.search-form'), HTMLFormElement);
17
25
this.results = $(this.pageElement.querySelector('.results'));
18
26
27
+
this.timelineSearch = $(this.pageElement.querySelector('.timeline-search'));
28
+
this.timelineSearchForm = $(this.pageElement.querySelector('.timeline-search form'), HTMLFormElement);
29
+
this.searchCollections = $(this.pageElement.querySelector('.search-collections'));
30
+
31
+
this.lycanImportSection = $(this.pageElement.querySelector('.lycan-import'));
32
+
this.lycanImportForm = $(this.pageElement.querySelector('.lycan-import form'), HTMLFormElement);
33
+
this.importProgress = $(this.pageElement.querySelector('.import-progress'));
34
+
this.importProgressBar = $(this.pageElement.querySelector('.import-progress progress'), HTMLProgressElement);
35
+
this.importStatusLabel = $(this.pageElement.querySelector('.import-status'));
36
+
this.importStatusPosition = $(this.pageElement.querySelector('.import-progress output'));
37
+
38
+
this.isCheckingStatus = false;
19
39
this.timelinePosts = [];
20
40
21
41
this.setupEvents();
22
42
23
43
let params = new URLSearchParams(location.search);
24
44
this.mode = params.get('mode');
25
-
this.lycanMode = params.get('lycan');
45
+
let lycan = params.get('lycan');
26
46
27
-
if (this.lycanMode == 'local') {
28
-
this.lycan = new BlueskyAPI('http://localhost:3000', false);
47
+
if (lycan == 'local') {
48
+
this.localLycan = new BlueskyAPI('http://localhost:3000', false);
49
+
} else if (lycan) {
50
+
this.lycanAddress = `did:web:${lycan}#lycan`;
51
+
} else {
52
+
this.lycanAddress = 'did:web:lycan.feeds.blue#lycan';
29
53
}
30
54
}
31
55
32
56
setupEvents() {
33
-
$(this.pageElement.querySelector('form')).addEventListener('submit', (e) => {
57
+
this.timelineSearchForm.addEventListener('submit', (e) => {
34
58
e.preventDefault();
35
59
36
60
if (!this.fetchStartTime) {
···
59
83
}
60
84
}
61
85
});
86
+
87
+
this.lycanImportForm.addEventListener('submit', (e) => {
88
+
e.preventDefault();
89
+
this.startLycanImport();
90
+
});
62
91
}
63
92
64
93
/** @returns {number} */
···
71
100
this.pageElement.style.display = 'block';
72
101
73
102
if (this.mode == 'likes') {
74
-
this.pageElement.querySelector('.timeline-search').style.display = 'none';
75
-
this.pageElement.querySelector('.search-collections').style.display = 'block';
103
+
this.header.innerText = 'Archive search';
104
+
this.timelineSearch.style.display = 'none';
105
+
this.searchCollections.style.display = 'block';
76
106
this.searchLine.style.display = 'block';
107
+
this.lycanImportSection.style.display = 'none';
108
+
this.checkLycanImportStatus();
77
109
} else {
78
-
this.pageElement.querySelector('.timeline-search').style.display = 'block';
79
-
this.pageElement.querySelector('.search-collections').style.display = 'none';
110
+
this.header.innerText = 'Timeline search';
111
+
this.timelineSearch.style.display = 'block';
112
+
this.searchCollections.style.display = 'none';
113
+
this.lycanImportSection.style.display = 'none';
114
+
}
115
+
}
116
+
117
+
/** @returns {Promise<void>} */
118
+
119
+
async checkLycanImportStatus() {
120
+
if (this.isCheckingStatus) {
121
+
return;
122
+
}
123
+
124
+
this.isCheckingStatus = true;
125
+
126
+
try {
127
+
let response = await this.getImportStatus();
128
+
this.showImportStatus(response);
129
+
} catch (error) {
130
+
this.showImportError(`Couldn't check import status: ${error}`);
131
+
} finally {
132
+
this.isCheckingStatus = false;
133
+
}
134
+
}
135
+
136
+
/** @returns {Promise<json>} */
137
+
138
+
async getImportStatus() {
139
+
if (this.localLycan) {
140
+
return await this.localLycan.getRequest('blue.feeds.lycan.getImportStatus', { user: accountAPI.user.did });
141
+
} else {
142
+
return await accountAPI.getRequest('blue.feeds.lycan.getImportStatus', null, {
143
+
headers: { 'atproto-proxy': this.lycanAddress }
144
+
});
145
+
}
146
+
}
147
+
148
+
/** @param {json} info */
149
+
150
+
showImportStatus(info) {
151
+
console.log(info);
152
+
153
+
if (!info.status) {
154
+
this.showImportError("Error checking import status");
155
+
return;
156
+
}
157
+
158
+
this.lycanImportStatus = info.status;
159
+
160
+
if (info.status == 'not_started') {
161
+
this.lycanImportSection.style.display = 'block';
162
+
this.lycanImportForm.style.display = 'block';
163
+
this.importProgress.style.display = 'none';
164
+
this.searchField.disabled = true;
165
+
166
+
this.stopImportTimer();
167
+
} else if (info.status == 'in_progress' || info.status == 'scheduled' || info.status == 'requested') {
168
+
this.lycanImportSection.style.display = 'block';
169
+
this.lycanImportForm.style.display = 'none';
170
+
this.importProgress.style.display = 'block';
171
+
this.searchField.disabled = true;
172
+
173
+
this.showImportProgress(info);
174
+
this.startImportTimer();
175
+
} else if (info.status == 'finished') {
176
+
this.lycanImportForm.style.display = 'none';
177
+
this.importProgress.style.display = 'block';
178
+
this.searchField.disabled = false;
179
+
180
+
this.showImportProgress({ status: 'finished', progress: 1.0 });
181
+
this.stopImportTimer();
182
+
} else {
183
+
this.showImportError("Error checking import status");
184
+
this.stopImportTimer();
185
+
}
186
+
}
187
+
188
+
/** @param {json} info */
189
+
190
+
showImportProgress(info) {
191
+
let progress = Math.max(0, Math.min(info.progress || 0));
192
+
this.importProgressBar.value = progress;
193
+
this.importProgressBar.style.display = 'inline';
194
+
195
+
let percent = Math.round(progress * 100);
196
+
this.importStatusPosition.innerText = `${percent}%`;
197
+
198
+
if (info.progress == 1.0) {
199
+
this.importStatusLabel.innerText = `Import complete โ`;
200
+
} else if (info.position) {
201
+
let date = new Date(info.position).toLocaleString(window.dateLocale, { day: 'numeric', month: 'short', year: 'numeric' });
202
+
this.importStatusLabel.innerText = `Downloaded data until: ${date}`;
203
+
} else if (info.status == 'requested') {
204
+
this.importStatusLabel.innerText = 'Requesting importโฆ';
205
+
} else {
206
+
this.importStatusLabel.innerText = 'Import startedโฆ';
207
+
}
208
+
}
209
+
210
+
/** @param {string} message */
211
+
212
+
showImportError(message) {
213
+
this.lycanImportSection.style.display = 'block';
214
+
this.lycanImportForm.style.display = 'none';
215
+
this.importProgress.style.display = 'block';
216
+
this.searchField.disabled = true;
217
+
218
+
this.importStatusLabel.innerText = message;
219
+
this.stopImportTimer();
220
+
}
221
+
222
+
startImportTimer() {
223
+
if (this.importTimer) {
224
+
return;
225
+
}
226
+
227
+
this.importTimer = setInterval(() => {
228
+
this.checkLycanImportStatus();
229
+
}, 3000);
230
+
}
231
+
232
+
stopImportTimer() {
233
+
if (this.importTimer) {
234
+
clearInterval(this.importTimer);
235
+
this.importTimer = undefined;
236
+
}
237
+
}
238
+
239
+
/** @returns {Promise<void>} */
240
+
241
+
async startLycanImport() {
242
+
this.showImportStatus({ status: 'requested' });
243
+
244
+
try {
245
+
if (this.localLycan) {
246
+
await this.localLycan.postRequest('blue.feeds.lycan.startImport', {
247
+
user: accountAPI.user.did
248
+
});
249
+
} else {
250
+
await accountAPI.postRequest('blue.feeds.lycan.startImport', null, {
251
+
headers: { 'atproto-proxy': this.lycanAddress }
252
+
});
253
+
}
254
+
255
+
this.startImportTimer();
256
+
} catch (err) {
257
+
console.error('Failed to start Lycan import', err);
258
+
this.showImportError(`Import failed: ${err}`);
80
259
}
81
260
}
82
261
···
118
297
daysBack = 0;
119
298
}
120
299
121
-
this.timelinePosts = timeline.map(x => Post.parseFeedPost(x));
300
+
this.timelinePosts = timeline;
122
301
123
302
this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`);
124
303
this.searchLine.style.display = 'block';
···
137
316
return;
138
317
}
139
318
140
-
let matching = this.timelinePosts.filter(x => x.lowercaseText.includes(query));
319
+
let matching = this.timelinePosts
320
+
.filter(x => x.post.record.text.toLowerCase().includes(query))
321
+
.map(x => Post.parseFeedPost(x));
141
322
142
323
for (let post of matching) {
143
324
let postView = new PostComponent(post, 'feed').buildElement();
···
148
329
/** @param {string} query */
149
330
150
331
searchInLycan(query) {
151
-
this.results.innerHTML = '';
152
-
153
-
if (query.length == 0) {
332
+
if (query.length == 0 || this.lycanImportStatus != 'finished') {
154
333
return;
155
334
}
156
335
336
+
this.results.innerHTML = '';
337
+
this.lycanImportSection.style.display = 'none';
338
+
157
339
let collection = this.searchForm.elements['collection'].value;
158
340
159
341
let loading = $tag('p', { text: "..." });
···
170
352
171
353
let response;
172
354
173
-
if (this.lycanMode == 'local') {
174
-
let params = { collection, query, user: window.accountAPI.user.did };
355
+
if (this.localLycan) {
356
+
let params = { collection, query, user: accountAPI.user.did };
175
357
if (cursor) params.cursor = cursor;
176
358
177
-
response = await this.lycan.getRequest('blue.feeds.lycan.searchPosts', params);
359
+
response = await this.localLycan.getRequest('blue.feeds.lycan.searchPosts', params);
178
360
} else {
179
361
let params = { collection, query };
180
362
if (cursor) params.cursor = cursor;
181
363
182
364
response = await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, {
183
-
headers: { 'atproto-proxy': 'did:web:lycan.feeds.blue#lycan' }
365
+
headers: { 'atproto-proxy': this.lycanAddress }
184
366
});
185
367
}
186
368
···
194
376
return;
195
377
}
196
378
197
-
let records = await window.accountAPI.loadPosts(response.posts);
379
+
let records = await accountAPI.loadPosts(response.posts);
198
380
let posts = records.map(x => new Post(x));
199
381
200
382
if (!firstPageLoaded) {
+49
-4
style.css
+49
-4
style.css
···
127
127
padding: 6px 11px;
128
128
}
129
129
130
-
#account_menu li a {
130
+
#account_menu li a[data-action] {
131
131
display: inline-block;
132
132
color: #333;
133
133
font-size: 11pt;
···
138
138
background-color: hsla(210, 100%, 4%, 0.12);
139
139
}
140
140
141
-
#account_menu li a:hover {
141
+
#account_menu li a[data-action]:hover {
142
142
background-color: hsla(210, 100%, 4%, 0.2);
143
143
text-decoration: none;
144
+
}
145
+
146
+
#account_menu li:not(.link) + li.link {
147
+
margin-top: 16px;
148
+
padding-top: 10px;
149
+
border-top: 1px solid #ccc;
150
+
}
151
+
152
+
#account_menu li.link {
153
+
margin-top: 8px;
154
+
margin-left: 2px;
155
+
}
156
+
157
+
#account_menu li.link a {
158
+
font-size: 11pt;
159
+
color: #333;
144
160
}
145
161
146
162
#account_menu li .check {
···
1074
1090
vertical-align: middle;
1075
1091
}
1076
1092
1093
+
#private_search_page .lycan-import {
1094
+
display: none;
1095
+
1096
+
margin-top: 30px;
1097
+
border-top: 1px solid #ccc;
1098
+
padding-top: 5px;
1099
+
}
1100
+
1101
+
#private_search_page .lycan-import form p {
1102
+
line-height: 135%;
1103
+
}
1104
+
1105
+
#private_search_page .lycan-import .import-progress progress {
1106
+
margin-left: 0;
1107
+
margin-right: 6px;
1108
+
}
1109
+
1110
+
#private_search_page .lycan-import .import-progress progress + output {
1111
+
font-size: 11pt;
1112
+
}
1113
+
1077
1114
#private_search_page .results {
1078
1115
margin-top: 30px;
1079
1116
}
···
1121
1158
background-color: transparent;
1122
1159
}
1123
1160
1161
+
#account.active {
1162
+
color: #333;
1163
+
}
1164
+
1124
1165
#account_menu {
1125
1166
background: hsl(210, 33.33%, 94.0%);
1126
1167
border-color: #ccc;
1127
1168
}
1128
1169
1129
-
#account_menu li a {
1170
+
#account_menu li a[data-action] {
1130
1171
color: #333;
1131
1172
border-color: #bbb;
1132
1173
background-color: hsla(210, 100%, 4%, 0.12);
1133
1174
}
1134
1175
1135
-
#account_menu li a:hover {
1176
+
#account_menu li a[data-action]:hover {
1136
1177
background-color: hsla(210, 100%, 4%, 0.2);
1137
1178
}
1138
1179
···
1363
1404
1364
1405
#private_search_page .search-query {
1365
1406
border: 1px solid #666;
1407
+
}
1408
+
1409
+
#private_search_page .lycan-import {
1410
+
border-top-color: #888;
1366
1411
}
1367
1412
1368
1413
#private_search_page .results-end {
+2
types.d.ts
+2
types.d.ts
+7
-2
utils.js
+7
-2
utils.js
···
17
17
}
18
18
}
19
19
20
-
window.Paginator = {
21
-
/** @param {Function} callback */
20
+
/**
21
+
* @typedef {object} PaginatorType
22
+
* @property {(callback: (boolean) => void) => void} loadInPages
23
+
* @property {(() => void)=} scrollHandler
24
+
* @property {ResizeObserver=} resizeObserver
25
+
*/
22
26
27
+
window.Paginator = {
23
28
loadInPages(callback) {
24
29
if (this.scrollHandler) {
25
30
document.removeEventListener('scroll', this.scrollHandler);