diff --git a/src/app.js b/src/app.js deleted file mode 100644 index 3d5a874..0000000 --- a/src/app.js +++ /dev/null @@ -1,150 +0,0 @@ -// Global variables -let archiveData = []; -let sortedData = []; -let currentSort = { column: 'created_at', order: 'desc' }; -const rowsPerPage = 100; -let currentPage = 1; - -// Event Listeners -document.addEventListener('DOMContentLoaded', function() { - document.getElementById('fileInput').addEventListener('change', handleFileLoad); - - // Add click listeners to sortable headers - document.querySelectorAll('#dataTable th[data-sort]').forEach(th => { - th.addEventListener('click', () => { - sortTable(th.dataset.sort); - }); - }); -}); - -// File handling -function handleFileLoad(event) { - const file = event.target.files[0]; - if (!file) return; - - const reader = new FileReader(); - reader.onload = (e) => { - try { - const data = JSON.parse(e.target.result); - if (!Array.isArray(data)) throw new Error('Invalid archive format: Expected an array.'); - archiveData = data; - sortedData = [...archiveData]; - updateStats(); - sortTable(currentSort.column); // Initial sort - } catch (err) { - alert(`Failed to load archive: ${err.message}`); - } - }; - reader.readAsText(file); -} - -// Statistics -function updateStats() { - const statsDiv = document.getElementById('stats'); - const kindCounts = archiveData.reduce((acc, event) => { - acc[event.kind] = (acc[event.kind] || 0) + 1; - return acc; - }, {}); - - const totalEvents = archiveData.length; - let statsText = `Total Events: ${totalEvents}
`; - statsText += 'Most Common Types: '; - - const topKinds = Object.entries(kindCounts) - .sort(([,a], [,b]) => b - a) - .slice(0, 3) - .map(([kind, count]) => `${getKindLabel(parseInt(kind))}: ${count}`); - - statsText += topKinds.join(' | '); - - statsDiv.innerHTML = statsText; -} - -// Table rendering -function renderTable() { - const tbody = document.querySelector('#dataTable tbody'); - tbody.innerHTML = ''; - - const start = (currentPage - 1) * rowsPerPage; - const end = start + rowsPerPage; - const pageData = sortedData.slice(start, end); - - if (pageData.length === 0) { - tbody.innerHTML = ` - No data loaded. Please load a JSON archive file. - `; - return; - } - - pageData.forEach((event, index) => { - const row = document.createElement('tr'); - row.innerHTML = ` - ${start + index + 1} - ${new Date(event.created_at * 1000).toLocaleString()} - ${getKindLabel(event.kind)} - ${getContentDisplay(event)} - `; - tbody.appendChild(row); - }); - - updatePagination(end); -} - -// Pagination -function updatePagination(end) { - document.getElementById('prevPage').disabled = currentPage === 1; - document.getElementById('nextPage').disabled = end >= sortedData.length; - document.getElementById('pageInfo').innerText = `Page ${currentPage} of ${Math.ceil(sortedData.length / rowsPerPage)}`; -} - -function changePage(offset) { - currentPage += offset; - renderTable(); -} - -// Sorting -function sortTable(column) { - if (currentSort.column === column) { - currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc'; - } else { - currentSort.column = column; - currentSort.order = 'desc'; - } - - const order = currentSort.order === 'asc' ? 1 : -1; - sortedData.sort((a, b) => { - if (a[column] < b[column]) return -1 * order; - if (a[column] > b[column]) return 1 * order; - return 0; - }); - - currentPage = 1; - renderTable(); -} - -// Helper functions -function getKindLabel(kind) { - const kinds = { - 0: 'Profile Metadata', - 1: 'Short Text Note', - 2: 'Recommend Relay', - 3: 'Contacts', - 4: 'Encrypted DM', - 6: 'Repost', - 7: 'Reaction', - 40: 'Channel Creation', - 41: 'Channel Metadata', - 42: 'Channel Message', - 30023: 'Long-form Content', - // Add more as needed - }; - return `${kind} (${kinds[kind] || 'Unknown'})`; -} - -function getContentDisplay(event) { - if (event.kind === 4) { - const recipient = event.tags.find(tag => tag[0] === 'p'); - return `Encrypted Message: ${event.content || 'N/A'}
Recipient: ${recipient ? recipient[1] : 'Unknown'}`; - } - return event.content || 'No content available'; -} \ No newline at end of file diff --git a/src/collector.html b/src/collector.html deleted file mode 100644 index 89416e8..0000000 --- a/src/collector.html +++ /dev/null @@ -1,107 +0,0 @@ -< - - - - - - - Nostr Archive Collector - - - -
-

Nostr Archive Collector

-
- - - - - - - - -
- -
-
-
- -
- -
- - - - - - diff --git a/src/index.html b/src/index.html index 88e2a9c..0b4ab2f 100644 --- a/src/index.html +++ b/src/index.html @@ -8,67 +8,169 @@ Archivestr - - + +

Archivestr

A Nostr Archive Creation, Browser, and Broadcaster Tool

- Collector - View Archive - GitHub Repository + + + + + +
+

Choose an option above to get started!

+
+ + +
+

Nostr Archive Collector

+
+ + + + + + + +
+ +
+
+
+ + + +
+ +
+ + +
+

Broadcast Archive

+

Select a JSON archive file to broadcast its events to relays.

+ +
+
+ + + +
+ + +
+ + +
+

Archive Viewer

+
No data loaded
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + +
#Time Kind ContentActions
No data loaded. Please load a JSON archive file.
+ +
+ + + + + + + diff --git a/src/script.js b/src/script.js index dc0fce3..8392e88 100644 --- a/src/script.js +++ b/src/script.js @@ -1,38 +1,357 @@ +// Navigation handling +function showSection(sectionId) { + // Hide all sections + document.querySelectorAll('.section').forEach(section => { + section.classList.remove('active'); + }); + // Show selected section + document.getElementById(sectionId).classList.add('active'); +} + +// Initialize on page load document.addEventListener('DOMContentLoaded', () => { + // Check for hash in URL + const hash = window.location.hash.slice(1) || 'home'; + showSection(hash); + + // Viewer initialization + initializeViewer(); + // Collector initialization + initializeCollector(); + // Broadcast initialization + initializeBroadcast(); +}); + +// =============== VIEWER FUNCTIONALITY =============== +function initializeViewer() { + // Global variables for viewer + let archiveData = []; + let sortedData = []; + let currentSort = { column: 'created_at', order: 'desc' }; + const rowsPerPage = 100; + let currentPage = 1; + + // Search configuration + let searchConfig = { + term: '', + useRegex: false, + caseSensitive: false, + searchFields: { + content: true, + kind: true, + date: true + }, + selectedKinds: new Set(), + dateRange: { + from: null, + to: null + } + }; + + // Event Listeners + document.getElementById('viewerFileInput').addEventListener('change', handleViewerFileLoad); + document.querySelectorAll('#dataTable th[data-sort]').forEach(th => { + th.addEventListener('click', () => { + sortTable(th.dataset.sort); + }); + }); + + // Advanced search listeners + document.getElementById('searchInput').addEventListener('input', (e) => { + searchConfig.term = e.target.value; + currentPage = 1; + filterAndRenderTable(); + }); + + document.getElementById('advancedSearchToggle').addEventListener('click', () => { + const advancedSearch = document.getElementById('advancedSearch'); + advancedSearch.style.display = advancedSearch.style.display === 'none' ? 'block' : 'none'; + }); + + document.getElementById('useRegex').addEventListener('change', (e) => { + searchConfig.useRegex = e.target.checked; + filterAndRenderTable(); + }); + + document.getElementById('caseSensitive').addEventListener('change', (e) => { + searchConfig.caseSensitive = e.target.checked; + filterAndRenderTable(); + }); + + ['Content', 'Kind', 'Date'].forEach(field => { + document.getElementById(`search${field}`).addEventListener('change', (e) => { + searchConfig.searchFields[field.toLowerCase()] = e.target.checked; + filterAndRenderTable(); + }); + }); + + document.getElementById('dateFrom').addEventListener('change', (e) => { + searchConfig.dateRange.from = e.target.value ? new Date(e.target.value).getTime() / 1000 : null; + filterAndRenderTable(); + }); + + document.getElementById('dateTo').addEventListener('change', (e) => { + searchConfig.dateRange.to = e.target.value ? new Date(e.target.value).getTime() / 1000 : null; + filterAndRenderTable(); + }); + + function handleViewerFileLoad(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target.result); + if (!Array.isArray(data)) throw new Error('Invalid archive format: Expected an array.'); + archiveData = data; + sortedData = [...archiveData]; + initializeKindFilter(); + updateStats(); + sortTable(currentSort.column); // Initial sort + } catch (err) { + alert(`Failed to load archive: ${err.message}`); + } + }; + reader.readAsText(file); + } + + function initializeKindFilter() { + const kindSelect = document.getElementById('kindFilter'); + const uniqueKinds = new Set(archiveData.map(event => event.kind)); + kindSelect.innerHTML = Array.from(uniqueKinds) + .sort((a, b) => a - b) + .map(kind => ``) + .join(''); + + kindSelect.addEventListener('change', (e) => { + searchConfig.selectedKinds = new Set( + Array.from(e.target.selectedOptions).map(option => parseInt(option.value)) + ); + filterAndRenderTable(); + }); + } + + function filterAndRenderTable() { + if (!searchConfig.term && !searchConfig.selectedKinds.size && + !searchConfig.dateRange.from && !searchConfig.dateRange.to) { + sortedData = [...archiveData]; + } else { + sortedData = archiveData.filter(event => { + // Kind filter + if (searchConfig.selectedKinds.size && + !searchConfig.selectedKinds.has(event.kind)) { + return false; + } + + // Date range filter + if (searchConfig.dateRange.from && + event.created_at < searchConfig.dateRange.from) { + return false; + } + if (searchConfig.dateRange.to && + event.created_at > searchConfig.dateRange.to) { + return false; + } + + // Text search + if (!searchConfig.term) return true; + + let searchPattern = searchConfig.term; + if (!searchConfig.useRegex) { + searchPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + let regex; + try { + const flags = searchConfig.caseSensitive ? 'g' : 'gi'; + regex = new RegExp(searchPattern, flags); + } catch (err) { + // Invalid regex + return false; + } + + if (searchConfig.searchFields.content) { + const content = getContentDisplay(event, false); + if (regex.test(content)) return true; + } + + if (searchConfig.searchFields.kind) { + const kindStr = getKindLabel(event.kind); + if (regex.test(kindStr)) return true; + } + + if (searchConfig.searchFields.date) { + const dateStr = new Date(event.created_at * 1000).toLocaleString(); + if (regex.test(dateStr)) return true; + } + + return false; + }); + } + + applySorting(); + renderTable(); + updateStats(); + } + + function applySorting() { + const order = currentSort.order === 'asc' ? 1 : -1; + sortedData.sort((a, b) => { + let valA = a[currentSort.column]; + let valB = b[currentSort.column]; + + if (currentSort.column === 'created_at') { + valA = parseInt(valA); + valB = parseInt(valB); + } + + if (valA < valB) return -1 * order; + if (valA > valB) return 1 * order; + return 0; + }); + } + + function updateStats() { + const statsDiv = document.getElementById('viewerStats'); + const totalEvents = archiveData.length; + const filteredEvents = sortedData.length; + + let statsText = `Total Events: ${totalEvents}`; + if (searchConfig.term || searchConfig.selectedKinds.size || + searchConfig.dateRange.from || searchConfig.dateRange.to) { + statsText += ` (Showing ${filteredEvents} matches)`; + } + statsText += '
Most Common Types: '; + + const kindCounts = sortedData.reduce((acc, event) => { + acc[event.kind] = (acc[event.kind] || 0) + 1; + return acc; + }, {}); + + const topKinds = Object.entries(kindCounts) + .sort(([,a], [,b]) => b - a) + .slice(0, 3) + .map(([kind, count]) => `${getKindLabel(parseInt(kind))}: ${count}`); + + statsText += topKinds.join(' | '); + + statsDiv.innerHTML = statsText; + } + + function renderTable() { + const tbody = document.querySelector('#dataTable tbody'); + tbody.innerHTML = ''; + + const start = (currentPage - 1) * rowsPerPage; + const end = start + rowsPerPage; + const pageData = sortedData.slice(start, end); + + if (pageData.length === 0) { + tbody.innerHTML = ` + No data loaded. Please load a JSON archive file. + `; + return; + } + + pageData.forEach((event, index) => { + const row = document.createElement('tr'); + // In the renderTable function, update the row.innerHTML line + row.innerHTML = ` + ${start + index + 1} + ${new Date(event.created_at * 1000).toLocaleString()} + ${getKindLabel(event.kind)} + ${getContentDisplay(event, true)} + + `; + tbody.appendChild(row); + }); + + // Attach event listeners to the new broadcast buttons + document.querySelectorAll('.broadcast-btn').forEach(button => { + button.addEventListener('click', (e) => { + const eventId = e.target.getAttribute('data-event-id'); + const event = archiveData.find(ev => ev.id === eventId); + if (event) { + broadcastSingleEvent(event, e.target); + } else { + alert('Event not found.'); + } + }); + }); + + updatePagination(end); + } + + function updatePagination(end) { + document.getElementById('prevPage').disabled = currentPage === 1; + document.getElementById('nextPage').disabled = end >= sortedData.length; + document.getElementById('pageInfo').innerText = + `Page ${currentPage} of ${Math.ceil(sortedData.length / rowsPerPage)}`; + } + + window.changePage = function(offset) { + currentPage += offset; + renderTable(); + }; + + function sortTable(column) { + if (currentSort.column === column) { + currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc'; + } else { + currentSort.column = column; + currentSort.order = 'desc'; + } + + currentPage = 1; + filterAndRenderTable(); + } +} + +// =============== COLLECTOR FUNCTIONALITY =============== +function initializeCollector() { const form = document.getElementById('archiveForm'); const statusDiv = document.getElementById('status'); const downloadBtn = document.getElementById('downloadBtn'); const spinner = document.getElementById('spinner'); - const broadcastBtn = document.createElement('button'); - const fileInput = document.createElement('input'); - - // Configure file input for archive selection - fileInput.type = 'file'; - fileInput.accept = '.json'; - fileInput.style.display = 'none'; - form.appendChild(fileInput); - - broadcastBtn.textContent = 'Broadcast from Archive'; - broadcastBtn.style.display = 'none'; // Initially hidden - form.appendChild(broadcastBtn); + const progressContainer = document.getElementById('progressContainer'); let collectedEvents = []; - let loadedEvents = []; // Events loaded from a file - let subscriptions = []; // Track active subscriptions + let subscriptions = []; + let totalRelays = 0; + let completedRelays = 0; + + function updateProgress() { + const progress = (completedRelays / totalRelays) * 100; + const progressBar = progressContainer.querySelector('.progress-bar'); + const progressText = progressContainer.querySelector('.progress-text'); + if (progressBar && progressText) { + progressBar.style.width = `${progress}%`; + progressText.textContent = `${Math.round(progress)}%`; + } + } form.addEventListener('submit', async (e) => { e.preventDefault(); - collectedEvents = []; // Reset previous data - subscriptions = []; // Clear previous subscriptions + collectedEvents = []; + subscriptions = []; downloadBtn.style.display = 'none'; - broadcastBtn.style.display = 'none'; statusDiv.innerHTML = 'Starting collection...'; spinner.style.display = 'block'; + progressContainer.style.display = 'block'; + updateProgress(); const npub = document.getElementById('npub').value.trim(); const relayInput = document.getElementById('relays').value.trim(); const relayUrls = relayInput.split('\n').map(url => url.trim()).filter(url => url); + if (relayUrls.length === 0) { + statusDiv.innerHTML = 'Please enter at least one relay URL.'; + spinner.style.display = 'none'; + progressContainer.style.display = 'none'; + return; + } + let pubkey; try { const decoded = NostrTools.nip19.decode(npub); @@ -41,25 +360,27 @@ document.addEventListener('DOMContentLoaded', () => { } catch (error) { statusDiv.innerHTML = `Invalid NPub: ${error.message}`; spinner.style.display = 'none'; + progressContainer.style.display = 'none'; return; } statusDiv.innerHTML = `Connecting to ${relayUrls.length} relay(s)...`; + totalRelays = relayUrls.length; + completedRelays = 0; + updateProgress(); const pool = new NostrTools.SimplePool(); const eoseTracker = relayUrls.reduce((acc, url) => ({ ...acc, [url]: false }), {}); const eventIds = new Set(); - // Update the filter to include additional kinds const filter = { - kinds: [0, 1, 2, 3, 4, 6, 7, 10002, 30023, 10509], // Include all relevant kinds - authors: [pubkey], // Fetch events from the specified pubkey + kinds: [0, 1, 2, 3, 4, 6, 7, 10002, 30023, 10509], + authors: [pubkey], }; - + relayUrls.forEach((url) => { try { const sub = pool.sub([url], [filter]); - subscriptions.push(sub); sub.on('event', (event) => { @@ -86,14 +407,15 @@ document.addEventListener('DOMContentLoaded', () => { default: console.log('Other event captured:', event); } - - collectedEvents.push(event); // Store the raw event data + collectedEvents.push(event); eventIds.add(event.id); } }); sub.on('eose', () => { eoseTracker[url] = true; + completedRelays++; + updateProgress(); console.log(`EOSE received from ${url}`); statusDiv.innerHTML += `
EOSE received from ${url}`; checkCompletion(); @@ -102,15 +424,23 @@ document.addEventListener('DOMContentLoaded', () => { sub.on('error', (err) => { console.error(`Error on ${url}:`, err); statusDiv.innerHTML += `
Error on ${url}: ${err.message}`; + eoseTracker[url] = true; + completedRelays++; + updateProgress(); + checkCompletion(); }); } catch (err) { console.error(`Subscription failed for ${url}:`, err); statusDiv.innerHTML += `
Subscription failed for ${url}: ${err.message}`; + eoseTracker[url] = true; + completedRelays++; + updateProgress(); + checkCompletion(); } }); function checkCompletion() { - if (Object.values(eoseTracker).every((status) => status)) { + if (Object.values(eoseTracker).every(status => status)) { finishCollection(); } } @@ -119,7 +449,6 @@ document.addEventListener('DOMContentLoaded', () => { spinner.style.display = 'none'; statusDiv.innerHTML += `
Collection complete. ${collectedEvents.length} event(s) collected.`; downloadBtn.style.display = 'block'; - broadcastBtn.style.display = 'block'; subscriptions.forEach(sub => sub.unsub()); pool.close(); @@ -127,20 +456,75 @@ document.addEventListener('DOMContentLoaded', () => { }); downloadBtn.addEventListener('click', () => { - const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(collectedEvents, null, 2)); + const dataStr = "data:text/json;charset=utf-8," + + encodeURIComponent(JSON.stringify(collectedEvents, null, 2)); const downloadAnchor = document.createElement('a'); downloadAnchor.setAttribute("href", dataStr); - const filename = `nostr_archive_${new Date().toISOString()}.json`; - downloadAnchor.setAttribute("download", filename); + downloadAnchor.setAttribute("download", + `nostr_archive_${new Date().toISOString()}.json`); document.body.appendChild(downloadAnchor); downloadAnchor.click(); downloadAnchor.remove(); }); +} - // Handle file selection for broadcasting - broadcastBtn.addEventListener('click', () => { - fileInput.click(); // Trigger file input +// =============== SHARED HELPER FUNCTIONS =============== +const POPULAR_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band', + 'wss://nostr.wine' +]; + +function validateRelayUrls(urls) { + const validUrlPattern = /^wss?:\/\/[^\s/$.?#].[^\s]*$/i; + return urls.every(url => { + // Check if URL matches basic websocket pattern + if (!validUrlPattern.test(url)) { + return false; + } + try { + // Try to create URL object to validate further + new URL(url); + return true; + } catch { + return false; + } }); +} + +// =============== VERIFICATION HELPER FUNCTION =============== +async function verifyEventOnRelay(eventId, relayUrl, existingPool = null) { + const pool = existingPool || new NostrTools.SimplePool(); + try { + const filter = { + ids: [eventId] + }; + + const events = await pool.list([relayUrl], [filter]); + return { + found: events.length > 0, + relay: relayUrl + }; + } catch (err) { + console.error(`Error verifying event on ${relayUrl}:`, err); + return { + found: false, + relay: relayUrl, + error: err.message + }; + } +} + +// =============== BATCH BROADCAST FUNCTIONALITY =============== +function initializeBroadcast() { + const fileInput = document.getElementById('broadcastFileInput'); + const statusDiv = document.getElementById('broadcastStatus'); + const startButton = document.getElementById('startBroadcastBtn'); + const progressContainer = document.getElementById('broadcastProgress'); + const progressBar = progressContainer.querySelector('.progress-bar'); + const progressText = progressContainer.querySelector('.progress-text'); + let loadedEvents = []; fileInput.addEventListener('change', (event) => { const file = event.target.files[0]; @@ -151,89 +535,397 @@ document.addEventListener('DOMContentLoaded', () => { try { const parsedData = JSON.parse(e.target.result); if (!Array.isArray(parsedData)) throw new Error('Archive must be an array of events.'); - loadedEvents = parsedData; // Safely assign parsed data - statusDiv.innerHTML = `Loaded ${loadedEvents.length} event(s) from the archive. Ready to broadcast.`; - promptAndBroadcast(); + loadedEvents = parsedData; + statusDiv.innerHTML = `Loaded ${loadedEvents.length} event(s) from the archive.`; + startButton.style.display = 'inline-block'; } catch (err) { console.error('Error loading archive:', err); statusDiv.innerHTML = `Failed to load archive: ${err.message}`; + startButton.style.display = 'none'; } }; reader.readAsText(file); }); - function promptAndBroadcast() { - const relayInput = prompt('Enter relay URLs (one per line) for broadcasting:'); - if (!relayInput) return; + async function processBatch(batch, relayUrls, pool) { + // First, publish all events in the batch in parallel + const publishPromises = batch.map(async event => { + try { + const published = await pool.publish(relayUrls, event); + return { + event, + success: true, + published + }; + } catch (err) { + console.error(`Failed to publish event ${event.id}:`, err); + return { + event, + success: false, + error: err.message + }; + } + }); + + const publishResults = await Promise.allSettled(publishPromises); + + // Quick pause to let relays catch up + await new Promise(resolve => setTimeout(resolve, 100)); + + // Now verify all successful publishes in parallel + const verifyPromises = publishResults + .filter(result => result.value?.success) + .map(result => { + const event = result.value.event; + return Promise.all(relayUrls.map(async relay => { + try { + const verified = await verifyEventOnRelay(event.id, relay, pool); + return { + eventId: event.id, + relay, + success: verified.found + }; + } catch (err) { + return { + eventId: event.id, + relay, + success: false, + error: err.message + }; + } + })); + }); + + const verifyResults = await Promise.allSettled(verifyPromises); + + return { + published: publishResults, + verified: verifyResults + }; + } + + function updateStatus(currentEvent, stats, batchResults = null) { + const elapsedTime = (Date.now() - stats.startTime) / 1000; + const avgTimePerEvent = stats.completedCount > 0 ? elapsedTime / stats.completedCount : 0; + const remainingEvents = loadedEvents.length - stats.completedCount; + const estimatedRemainingTime = Math.ceil(avgTimePerEvent * remainingEvents); + + const progress = (stats.completedCount / loadedEvents.length) * 100; + progressBar.style.width = `${progress}%`; + progressText.textContent = `${Math.round(progress)}% (${stats.completedCount}/${loadedEvents.length})`; + + let statusHTML = ` +
+
+

Progress

+

⏱️ ${Math.floor(elapsedTime / 60)}m ${Math.floor(elapsedTime % 60)}s elapsed

+

⏳ ~${Math.floor(estimatedRemainingTime / 60)}m remaining

+

📊 ${stats.completedCount}/${loadedEvents.length} processed

+
+ +
+

Status

+

✅ ${stats.successCount} succeeded

+

❌ ${stats.failureCount} failed

+

✓ ${stats.verifiedCount} verified

+
`; + + if (currentEvent) { + statusHTML += ` +
+

Current Batch

+

📝 Processing Kind ${currentEvent.kind}

+

+ ${currentEvent.id} + +

+
`; + } + + if (stats.latestVerified) { + statusHTML += ` +
+

Latest Verified

+

+ ${stats.latestVerified.eventId} + +

+

✓ Found on ${stats.latestVerified.count} relays

+
`; + } + + statusHTML += '
'; + statusDiv.innerHTML = statusHTML; + } + + startButton.addEventListener('click', async () => { + const relayUrls = document.getElementById('broadcastRelays') + .value.trim() + .split('\n') + .map(url => url.trim()) + .filter(url => url); - const relayUrls = relayInput.split('\n').map(url => url.trim()).filter(url => url); if (relayUrls.length === 0) { - alert('No valid relays provided.'); + alert('Please enter at least one relay URL'); return; } + if (!validateRelayUrls(relayUrls)) { + alert('One or more relay URLs are invalid. Please check and try again.'); + return; + } + + if (loadedEvents.length === 0) { + alert('No events loaded to broadcast'); + return; + } + + startButton.disabled = true; + progressContainer.style.display = 'block'; + progressBar.style.width = '0%'; + progressText.textContent = '0%'; + + const stats = { + startTime: Date.now(), + completedCount: 0, + successCount: 0, + failureCount: 0, + verifiedCount: 0, + latestVerified: null + }; + const pool = new NostrTools.SimplePool(); - statusDiv.innerHTML = `Broadcasting to ${relayUrls.length} relay(s)...`; + const BATCH_SIZE = 10; - let successCount = 0; - let failureCount = 0; + try { + // Process in batches + for (let i = 0; i < loadedEvents.length; i += BATCH_SIZE) { + const batch = loadedEvents.slice(i, i + BATCH_SIZE); + updateStatus(batch[0], stats); - relayUrls.forEach((url) => { - try { - const sub = pool.sub([url], [filter]); - - subscriptions.push(sub); - - sub.on('event', (event) => { - if (!eventIds.has(event.id)) { - switch (event.kind) { - case 0: - console.log('Profile Metadata captured:', event); - break; - case 2: - console.log('Relay Recommendation captured:', event); - break; - case 3: - console.log('Contact List captured:', event); - break; - case 4: - console.log('Encrypted DM captured:', event); - break; - case 30023: - console.log('Long-Form Content captured:', event); - break; - default: - console.log('Other event captured:', event); - } - collectedEvents.push(event); // Store the raw event data - eventIds.add(event.id); + const results = await processBatch(batch, relayUrls, pool); + + // Update stats based on batch results + results.published.forEach(result => { + if (result.value?.success) { + stats.successCount++; + } else { + stats.failureCount++; } }); - - sub.on('eose', () => { - eoseTracker[url] = true; - console.log(`EOSE received from ${url}`); - statusDiv.innerHTML += `
EOSE received from ${url}`; - checkCompletion(); + + results.verified.forEach(verifyGroup => { + if (verifyGroup.value) { + const successfulVerifications = verifyGroup.value.filter(v => v.success).length; + stats.verifiedCount += successfulVerifications; + + if (successfulVerifications > 0) { + stats.latestVerified = { + eventId: verifyGroup.value[0].eventId, + count: successfulVerifications + }; + } + } }); - - sub.on('error', (err) => { - console.error(`Error on ${url}:`, err); - statusDiv.innerHTML += `
Error on ${url}: ${err.message}`; - // Mark the relay as complete to avoid stalling collection - eoseTracker[url] = true; - checkCompletion(); - }); - } catch (err) { - console.error(`Subscription failed for ${url}:`, err); - statusDiv.innerHTML += `
Subscription failed for ${url}: ${err.message}`; - // Mark the relay as complete to avoid stalling collection - eoseTracker[url] = true; - checkCompletion(); + + stats.completedCount += batch.length; + updateStatus(batch[batch.length - 1], stats, results); } - }); - - pool.close(); - statusDiv.innerHTML += `
Broadcast complete: ${successCount} success(es), ${failureCount} failure(s).`; + + // Final summary + const successRate = ((stats.successCount / loadedEvents.length) * 100).toFixed(1); + const finalHTML = ` +
+

📊 Broadcast Complete

+
+

Total Events: ${loadedEvents.length}

+

Success Rate: ${successRate}%

+

✅ ${stats.successCount} succeeded

+

❌ ${stats.failureCount} failed

+

✓ ${stats.verifiedCount} verifications

+

⏱️ Total Time: ${Math.floor((Date.now() - stats.startTime) / 60000)}m ${Math.floor((Date.now() - stats.startTime) / 1000 % 60)}s

+
+
`; + + statusDiv.innerHTML += finalHTML; + + } catch (err) { + console.error('Unexpected error during batch broadcast:', err); + statusDiv.innerHTML += ` +
+

⚠️ Error

+

${err.message}

+
`; + } finally { + startButton.disabled = false; + } + }); + + // Add copy to clipboard function if it doesn't exist + if (!window.copyToClipboard) { + window.copyToClipboard = async function(text, buttonId) { + try { + await navigator.clipboard.writeText(text); + const button = document.getElementById(buttonId); + button.textContent = 'Copied!'; + button.classList.add('copied'); + setTimeout(() => { + button.textContent = 'Copy'; + button.classList.remove('copied'); + }, 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; } -}); +} + +// =============== BROADCAST SINGLE EVENT FUNCTIONALITY =============== +async function broadcastSingleEvent(event, buttonElement) { + const relayInput = document.getElementById('broadcastRelays').value.trim(); + const broadcastRelays = relayInput.split('\n').map(url => url.trim()).filter(url => url); + + if (broadcastRelays.length === 0) { + alert('Please enter at least one relay URL in the Broadcast section.'); + return; + } + + if (!validateRelayUrls(broadcastRelays)) { + alert('One or more relay URLs are invalid. Please check and try again.'); + return; + } + + if (!event) { + alert('Invalid event selected.'); + return; + } + + buttonElement.disabled = true; + buttonElement.textContent = 'Broadcasting...'; + + const pool = new NostrTools.SimplePool(); + const verificationResults = []; + + try { + const publishResult = await pool.publish(broadcastRelays, event); + + if (publishResult) { + await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds + buttonElement.textContent = 'Verifying...'; + + // Check all relays (broadcast targets + popular relays) + const allRelaysToCheck = [...new Set([...broadcastRelays, ...POPULAR_RELAYS])]; + + for (const relayUrl of allRelaysToCheck) { + const result = await verifyEventOnRelay(event.id, relayUrl, pool); + verificationResults.push(result); + } + + const foundOn = verificationResults.filter(r => r.found).map(r => r.relay); + const nostrBandLink = `https://nostr.band/event/${event.id}`; + + let message = `Event ID ${event.id} broadcast complete!\n\n`; + message += `Found on ${foundOn.length} relay(s):\n${foundOn.join('\n')}\n\n`; + message += `View on nostr.band: ${nostrBandLink}`; + + // Try to copy to clipboard + try { + await navigator.clipboard.writeText(event.id); + message += '\n\nEvent ID copied to clipboard!'; + } catch (err) { + console.error('Failed to copy to clipboard:', err); + } + + alert(message); + + // Log detailed results to console for debugging + console.log('Verification Results:', { + eventId: event.id, + results: verificationResults + }); + } else { + alert(`Event ID ${event.id} failed to broadcast to any relays.`); + } + } catch (err) { + console.error('Unexpected error during broadcasting:', err); + alert(`Unexpected error: ${err.message}`); + } finally { + buttonElement.disabled = false; + buttonElement.textContent = 'Broadcast'; + } +} + +function getKindLabel(kind) { + const kinds = { + 0: ['Profile Metadata', 'Basic profile information like name, about, and picture'], + 1: ['Short Text Note', 'Regular text posts and updates'], + 2: ['Recommend Relay', 'Relay recommendations for other users'], + 3: ['Contacts', 'List of followed users and their relays'], + 4: ['Encrypted DM', 'Direct messages encrypted for specific recipients'], + 6: ['Repost', 'Reposted content from other users'], + 7: ['Reaction', 'Reactions to other posts (likes, emojis, etc.)'], + 40: ['Channel Creation', 'Creates a new channel for group discussions'], + 41: ['Channel Metadata', 'Updates to channel information'], + 42: ['Channel Message', 'Messages posted in channels'], + 30023: ['Long-form Content', 'Blog posts, articles, and longer content'], + 9734: ['Zap Request', 'Lightning payment requests'], + 9735: ['Zap', 'Lightning payment confirmations'], + 10002: ['Relay List', 'List of preferred relays'], + 10003: ['Bookmark List', 'Saved posts and content'], + 30008: ['Profile Badges', 'Badges displayed on profiles'], + 30009: ['Badge Definition', 'Creates or defines new badges'], + 30078: ['Application Specific', 'Data specific to certain applications'], + 31989: ['Handler Recommendation', 'Recommended handlers for protocols'], + 31990: ['Handler Information', 'Information about protocol handlers'] + }; + + const [label, description] = kinds[kind] || ['Unknown', 'Custom or application-specific event type']; + return `
${kind} (${label})${description}
`; +} + +function getContentDisplay(event, highlight = true) { + let content = ''; + + if (event.kind === 0) { + try { + const profile = JSON.parse(event.content); + content = `Name: ${profile.name || 'N/A'}
About: ${profile.about || 'N/A'}`; + } catch { + content = 'Invalid profile data'; + } + } else if (event.kind === 4) { + const recipient = event.tags.find(tag => tag[0] === 'p'); + content = `Encrypted Message: ${event.content || 'N/A'}
Recipient: ${recipient ? recipient[1] : 'Unknown'}`; + } else if (event.kind === 3) { + try { + const contacts = JSON.parse(event.content); + content = `Contact List: ${contacts.length} contacts`; + } catch { + content = 'Invalid contact list data'; + } + } else { + content = event.content || 'No content available'; + } + + if (highlight && window.searchConfig?.term) { + let searchPattern = window.searchConfig.term; + if (!window.searchConfig.useRegex) { + searchPattern = searchPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + let regex; + try { + const flags = window.searchConfig.caseSensitive ? 'g' : 'gi'; + regex = new RegExp(searchPattern, flags); + } catch (err) { + // Invalid regex + return content; + } + + const highlightClass = window.searchConfig.useRegex ? 'regex-highlight' : 'search-highlight'; + content = content.replace(regex, match => + `${match}`); + } + + return content; +} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index a4aeef2..fa119b9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,69 +1,357 @@ -/* Base styles */ +/* Base layout */ +:root { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --bg-elevated: #1a1a1a; + --text-primary: #ffffff; + --text-secondary: #b3b3b3; + --accent-primary: #007bff; + --accent-hover: #0056b3; + --border-color: #404040; + --success-color: #28a745; +} + body { font-family: Arial, sans-serif; margin: 0; padding: 0; - background-color: #f9f9f9; + background-color: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + display: flex; + flex-direction: column; } .container { - max-width: 1200px; + width: 95%; + max-width: 1600px; margin: 20px auto; - padding: 20px; - background: #fff; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + padding: 30px 40px; + background: var(--bg-secondary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + flex: 1; + box-sizing: border-box; + border-radius: 8px; } -/* Header and Stats */ +/* Tooltip styles */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip .tooltip-text { + visibility: hidden; + width: 250px; + background-color: var(--bg-elevated); + color: var(--text-primary); + text-align: center; + border-radius: 6px; + padding: 8px; + border: 1px solid var(--border-color); + font-size: 14px; + + /* Position the tooltip */ + position: absolute; + z-index: 1; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + + /* Animation */ + opacity: 0; + transition: opacity 0.3s; +} + +.tooltip:hover .tooltip-text { + visibility: visible; + opacity: 1; +} + +/* Progress bar styles */ +.progress-container { + width: 100%; + background-color: var(--bg-primary); + border-radius: 4px; + margin: 10px 0; + overflow: hidden; +} + +.progress-bar { + height: 20px; + background-color: var(--accent-primary); + width: 0%; + transition: width 0.3s ease; + position: relative; +} + +.progress-text { + position: absolute; + right: 10px; + color: var(--text-primary); + font-size: 12px; + line-height: 20px; +} + +/* Typography */ h1 { text-align: center; - margin-bottom: 10px; + margin-bottom: 20px; + font-size: 32px; + color: var(--text-primary); } -.stats { - text-align: center; - color: #666; +h2 { + font-size: 24px; margin-bottom: 20px; - font-size: 1.1em; + color: var(--text-primary); +} + +p { + text-align: center; /* This will center the text */ + margin-bottom: 30px; + font-size: 16px; + color: var(--text-secondary); + line-height: 1.5; +} + +/* Navigation */ +.nav-links { + text-align: center; + margin-bottom: 40px; + padding: 20px 0; + border-bottom: 1px solid var(--border-color); +} + +.nav-links a { + display: inline-block; + margin: 10px 20px; + padding: 12px 24px; + background-color: var(--bg-elevated); + color: var(--text-primary); + text-decoration: none; + border-radius: 6px; + font-size: 18px; + transition: all 0.3s ease; + border: 1px solid var(--border-color); +} + +.nav-links a:hover { + background-color: var(--accent-primary); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +/* Section handling */ +.section { + display: none; + text-align: center; +} + +.section.active { + display: block; +} + +/* Form elements */ +form { + max-width: 800px; + margin: 0 auto; + text-align: left; + padding: 20px; +} + +label { + display: block; + margin-top: 20px; + font-size: 16px; + font-weight: bold; + color: var(--text-secondary); +} + +input[type="text"], +input[type="datetime-local"], +textarea { + width: 100%; + padding: 12px; + margin-top: 8px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + box-sizing: border-box; + font-size: 16px; + color: var(--text-primary); + transition: all 0.3s ease; +} + +input[type="text"]:focus, +input[type="datetime-local"]:focus, +textarea:focus { + border-color: var(--accent-primary); + outline: none; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.2); +} + +textarea { + min-height: 120px; + resize: vertical; } input[type="file"] { display: block; - margin: 20px auto; + margin: 30px auto; + padding: 10px; + color: var(--text-secondary); +} + +button { + margin-top: 20px; + padding: 12px 24px; + background: var(--accent-primary); + color: var(--text-primary); + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 16px; + transition: all 0.3s ease; +} + +#downloadBtn { + background: var(--accent-primary); + display: block; /* Makes the button a block element */ + margin: 20px auto; /* Auto margins on left and right will center it */ +} + +button:hover:not(:disabled) { + background: var(--accent-hover); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +button:disabled { + background: var(--bg-elevated); + cursor: not-allowed; +} + +/* Advanced Search Styles */ +.search-container { + margin: 20px 0; + padding: 20px; + background: var(--bg-elevated); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.search-bar { + display: flex; + gap: 10px; + margin-bottom: 15px; +} + +.search-input { + flex: 1; + padding: 12px 20px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 16px; +} + +.search-toggle { + padding: 12px 20px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.3s ease; +} + +.advanced-search { + padding: 15px; + border-top: 1px solid var(--border-color); + margin-top: 15px; +} + +.search-options { + display: flex; + gap: 20px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.filter-fields { + display: flex; + gap: 15px; + align-items: center; +} + +.kind-filter { + margin: 15px 0; +} + +.kind-filter select { + padding: 8px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + min-width: 200px; +} + +.date-filter { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; } /* Table styles */ table { width: 100%; - border-collapse: collapse; - margin-top: 20px; + border-collapse: separate; + border-spacing: 0; + margin: 30px 0; + background: var(--bg-secondary); + border-radius: 8px; + overflow: hidden; } th, td { - padding: 10px; - border: 1px solid #ddd; + padding: 15px; + border: 1px solid var(--border-color); text-align: left; vertical-align: top; } th { + background: var(--bg-elevated); + font-weight: bold; + color: var(--text-primary); cursor: pointer; - background: #f4f4f4; - position: relative; + transition: background-color 0.3s ease; } th:hover { - background: #e0e0e0; + background: var(--accent-primary); } -.empty-message { - text-align: center; - color: #888; - margin-top: 20px; +td { + color: var(--text-secondary); +} + +tr:hover { + background: var(--bg-elevated); +} + +.number-column { + width: 50px; + text-align: right; + color: var(--text-secondary); } .content-cell { - max-width: 600px; + max-width: 800px; white-space: pre-wrap; word-break: break-word; } @@ -82,10 +370,39 @@ th:hover { content: '↓'; } -.number-column { - width: 50px; - text-align: right; - color: #666; +/* Stats and messages */ +.stats { + text-align: center; + color: var(--text-secondary); + margin-bottom: 20px; + font-size: 1.1em; + padding: 20px; + background: var(--bg-elevated); + border-radius: 6px; +} + +.empty-message { + text-align: center; + color: var(--text-secondary); +} + +#status { + margin-top: 20px; + white-space: pre-wrap; + color: var(--text-secondary); +} + +/* Search highlighting */ +.search-highlight { + background-color: rgba(0, 123, 255, 0.2); + border-radius: 3px; + padding: 2px; +} + +.regex-highlight { + background-color: rgba(255, 123, 0, 0.2); + border-radius: 3px; + padding: 2px; } /* Pagination */ @@ -97,14 +414,233 @@ th:hover { .pagination button { margin: 0 5px; padding: 5px 10px; - background: #007BFF; - color: white; + background: var(--accent-primary); + color: var(--text-primary); border: none; cursor: pointer; border-radius: 3px; } .pagination button:disabled { - background: #ccc; + background: var(--bg-elevated); cursor: not-allowed; -} \ No newline at end of file +} + +/* Spinner */ +#spinner { + display: none; + text-align: center; + margin-top: 20px; +} + +.loader { + border: 8px solid var(--bg-elevated); + border-top: 8px solid var(--accent-primary); + border-radius: 50%; + width: 60px; + height: 60px; + animation: spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Footer */ +footer { + background-color: var(--bg-elevated); + color: var(--text-secondary); + padding: 20px; + text-align: center; + margin-top: auto; +} + +.footer-content { + max-width: 1600px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.copyright { + color: var(--text-secondary); + font-size: 14px; +} + +.footer-link { + color: var(--text-primary); + text-decoration: none; + font-size: 14px; + transition: color 0.3s; +} + +.footer-link:hover { + color: var(--accent-primary); +} + +/* Responsive adjustments */ +@media (max-width: 1200px) { + .container { + width: 95%; + padding: 20px; + } + + .content-cell { + max-width: 600px; + } +} + +@media (max-width: 768px) { + .container { + width: 100%; + margin: 0; + padding: 15px; + border-radius: 0; + } + + .nav-links a { + display: block; + margin: 10px auto; + width: 80%; + max-width: 300px; + } + + .search-bar { + flex-direction: column; + } + + .date-filter { + flex-direction: column; + align-items: stretch; + } + + /* Broadcast section styles */ + .relay-input { + max-width: 800px; + margin: 20px auto; + text-align: left; + } + + .relay-input textarea { + width: 100%; + margin-top: 10px; + } + + #broadcastStatus { + margin: 20px 0; + color: var(--text-secondary); + } + + #startBroadcastBtn { + display: block; + margin: 20px auto; + } + + #broadcast .progress-container { + max-width: 800px; + margin: 20px auto; + } + + .broadcast-status { + margin: 20px 0; + padding: 15px; + border: 1px solid #ddd; + border-radius: 5px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 15px; + } + + .status-section { + padding: 10px; + background: #f9f9f9; + border-radius: 4px; + } + + .status-section h3 { + margin: 0 0 10px 0; + color: #333; + font-size: 1.1em; + display: flex; + align-items: center; + gap: 8px; + } + + .status-section h3 .spinner { + width: 16px; + height: 16px; + border: 2px solid #f3f3f3; + border-top: 2px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; + display: inline-block; + margin-left: 5px; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .status-section p { + margin: 5px 0; + color: #666; + display: flex; + align-items: center; + gap: 8px; + } + + .event-id { + font-family: monospace; + background: #f0f0f0; + padding: 2px 6px; + border-radius: 3px; + display: inline-block; + margin-right: 5px; + } + + .copy-button { + background: #e0e0e0; + border: none; + border-radius: 3px; + padding: 2px 8px; + cursor: pointer; + font-size: 0.8em; + color: #666; + } + + .copy-button:hover { + background: #d0d0d0; + } + + .copy-button.copied { + background: #90EE90; + color: #006400; + } + + .status-section.error { + background: #fff0f0; + border-left: 3px solid #ff4444; + } + + .final-summary { + margin-top: 20px; + padding: 20px; + background: #f0f8ff; + border: 1px solid #b8daff; + border-radius: 5px; + } + + .summary-section { + margin: 10px 0; + } + + .summary-section h4 { + margin: 10px 0; + color: #2c5282; + } + +} diff --git a/src/view-archive.html b/src/view-archive.html deleted file mode 100644 index 52464ec..0000000 --- a/src/view-archive.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - Nostr Archive Viewer - - - -
-

Nostr Archive Viewer

-
No data loaded
- - - - - - - - - - - - - - - -
#Time Kind Content
No data loaded. Please load a JSON archive file.
- -
- - - \ No newline at end of file