Skip to content

Commit c14e117

Browse files
committed
- Added device syncing (uses PeerJS and Google STUN server)
- Also removed some dead code and updated a few other things
1 parent 388d40b commit c14e117

26 files changed

Lines changed: 4040 additions & 886 deletions

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ This project was developed with assistance from AI tools including [Gab AI](http
1616
- **Verse Highlighting**: Right-click verses to highlight in one of six colors for study emphasis
1717
- **Reference Bible**: Use the reference Bible to select from multiple translation options to compare with (ASV, BSB, CSB, ESV, GNV, KJV, LSB, NASB 1995, NASB 2020, NET, NIV, NKJV, NLT); this also sets the translation for Bible Gateway searches and the Verse Analysis popup
1818
- **Search Integration**: Built-in [Bible Gateway search](https://www.biblegateway.com/usage/) functionality to search for any word or passage
19+
- **Theming**: Dark/light mode with six color themes
1920

2021
### Advanced Features
2122
- **Highlights Management**: Search and filter highlighted verses by color/content
2223
- **Keyboard Navigation**: Extensive keyboard shortcuts (F1 to toggle help)
2324
- **Data Portability**: Import/export highlights, notes, and settings
25+
- **Local Device Syncing**: Pair devices on your local network to sync your highlights, notes, and settings using a simple, 8-digit code
2426
- **Responsive Design**: Optimized for both desktop and mobile devices (mobile has limited features)
25-
- **Theming**: Dark/light mode with six color themes
2627

2728
## Markdown Keyboard Shortcuts
2829
### Basic Formatting (Ctrl/Cmd + Key)
@@ -63,12 +64,13 @@ This project was developed with assistance from AI tools including [Gab AI](http
6364
| `Alt + E` | Export Data |
6465
| `Alt + I` | Import Data |
6566
| `Alt + M` | Export Notes |
67+
| `Alt + D` | Manual Sync (if devices paired) |
6668
| `F1` | Show Help Modal |
6769

6870
*Note: Hotkeys can be customized in the settings menu*
6971

7072
### Study Resources Integration
71-
The sidebar provides organized access to extensive theological resources (Reformed Theology/Calvinism focused) including:
73+
The sidebar provides organized access to extensive theological resources (Reformed Theology/Calvinism) including:
7274
- Online Bible platforms
7375
- Christian doctrine references
7476
- Theological resources
@@ -80,8 +82,10 @@ The sidebar provides organized access to extensive theological resources (Reform
8082

8183
- **Frontend**: Pure HTML5, CSS3, and Vanilla JavaScript (ES6+)
8284
- **Libraries**:
83-
- Marked.js for Markdown processing
8485
- Font Awesome for icons
86+
- Marked.js for Markdown processing
87+
- PeerJS for discovery (local device syncing)
88+
- Google for STUN servers (local device syncing)
8589
- **API**: Bible.helloao.org for scripture text, footnotes, and BSB audio
8690
- **Storage**: LocalStorage for user data persistence
8791
- **Build**: Use the provided scripts for setup and consistency
@@ -109,15 +113,19 @@ The sidebar provides organized access to extensive theological resources (Reform
109113

110114
## Privacy & Data
111115

112-
Highlights, notes, and settings are stored locally in your browser.
116+
No cloud storage: Highlights, notes, and settings are stored locally in your browser and never pass through servers (only signaling for discovery). Device sync is optional.
113117

114118
Data that is transmitted to external servers:
115119
- Bible passage requests are handled by bible.helloao.org
116120
- Bible Hub (interlinear) and STEP Bible (both when using Verse Analysis popup)
117121
- Bible Gateway searches
118122
- Reference Bible websites while the panel is opened
119123
- Resource links opened in external sites via the sidebar
120-
- Third-party library usage for Font Awesome and Marked.js
124+
- Third-party libraries:
125+
- Font Awesome
126+
- Marked.js
127+
- PeerJS
128+
- Google STUN servers
121129

122130
## Attribution
123131

@@ -126,6 +134,8 @@ Data that is transmitted to external servers:
126134
- Audio files hosted by [Open Bible](https://openbible.com/audio/)
127135
- Icons by [Font Awesome](https://fontawesome.com)
128136
- Markdown processing by [Marked.js](https://cdn.jsdelivr.net/npm/marked/)
137+
- Local device syncing by [PeerJS](https://unpkg.com)
138+
- STUN servers by [Google]("stun.l.google.com" and "stun1.l.google.com")
129139
- **Reference Bible websites**
130140
- [Bible Gateway](https://www.biblegateway.com)
131141
- [Bible.com (YouVersion)](https://www.bible.com)

dev_tools/Minify-JS.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ $files = @(
1111
"../src/modules/settings.js",
1212
"../src/modules/state.js",
1313
"../src/modules/strongs.js",
14+
"../src/modules/sync.js",
1415
"../src/modules/ui.js",
1516
"../src/sw.js"
1617
)

dev_tools/minify-js.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def main():
8080
"../src/modules/settings.js",
8181
"../src/modules/state.js",
8282
"../src/modules/strongs.js",
83+
"../src/modules/sync.js",
8384
"../src/modules/ui.js"
8485
]
8586

index.html

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

main.js

Lines changed: 101 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { isKJV, playChapterAudio, pauseChapterAudio, resumeChapterAudio, stopCha
22
import { applyHighlight, clearHighlights, closeHighlightsModal, renderHighlights, showColorPicker, showHighlightsModal } from './modules/highlights.js';
33
import { showHelpModal } from './modules/hotkeys.js';
44
import { initBookChapterControls, loadSelectedChapter, navigateFromURL, nextPassage, prevPassage, randomPassage, setupNavigationWithURL, setupPopStateListener } from './modules/navigation.js'
5-
import { clearCache, closeSettings, deleteAllData, exportData, importData, initializeAudioControls, initialiseNarratorSelect, openSettings, saveSettings } from './modules/settings.js'
5+
import { clearCache, closeSettings, deleteAllData, exportData, importData, initializeAudioControls, initialiseNarratorSelect, openSettings, saveSettings, updateAudioControlsVisibility } from './modules/settings.js'
66
import { APP_VERSION, BOOK_ORDER, updateBibleGatewayVersion, loadFromCookies, loadFromStorage, saveToStorage, state } from './modules/state.js'
77
import { closeStrongsPopup, showStrongsReference } from './modules/strongs.js'
8+
import { initSync, syncManager } from './modules/sync.js';
89
import { exportNotes, initResizeHandles, insertMarkdown, restoreBookChapterUI, restorePanelStates, restoreSidebarState, switchNotesView, togglePanelCollapse, toggleReferencePanel, toggleSection,
910
updateMarkdownPreview, updateReferencePanel, updateNotesFontSize, updateScriptureFontSize } from './modules/ui.js'
1011
const OFFLINE_STYLES = `
@@ -30,10 +31,11 @@ let touchStartTime = 0;
3031
let longPressTimer = null;
3132
let touchStartY = 0;
3233
let isScrolling = false;
34+
let syncResumedToastShown = false;
3335
if (typeof marked !== 'undefined') {
3436
marked.setOptions({
35-
breaks: true,
36-
gfm: true
37+
breaks: true,
38+
gfm: true
3739
});
3840
}
3941
class AppError extends Error {
@@ -46,6 +48,11 @@ class AppError extends Error {
4648
}
4749
export function handleError(error, context, userFriendlyMessage) {
4850
console.error(`Error in ${context}:`, error);
51+
if (error.type === 'unavailable-id') {
52+
console.warn('[Sync] Peer ID unavailable – regenerating');
53+
const syncEvent = new CustomEvent('sync:regenerateId');
54+
document.dispatchEvent(syncEvent);
55+
}
4956
const rawMsg = userFriendlyMessage ??
5057
(error instanceof AppError ? error.message : 'An unexpected error occurred');
5158
const escapedMsg = escapeHTML(rawMsg);
@@ -117,8 +124,9 @@ function updateOfflineStatus(isOffline) {
117124
`;
118125
document.body.appendChild(indicator);
119126
}
120-
indicator.textContent = isOffline ? 'Offline Mode' : 'Online';
127+
indicator.textContent = isOffline ? 'Offline Mode (Sync Paused)' : 'Online';
121128
indicator.style.background = isOffline ? '#ff6b6b' : '#51cf66';
129+
document.dispatchEvent(new CustomEvent('offlineStatus', { detail: { isOffline } }));
122130
setTimeout(() => {
123131
indicator.style.opacity = '0';
124132
setTimeout(() => indicator.remove(), 300);
@@ -234,6 +242,7 @@ function setupEventListeners() {
234242
setupModalControls();
235243
setupMarkdownShortcuts();
236244
setupTouchEvents();
245+
setupSyncManagement();
237246
}
238247
function setupHeaderButtons() {
239248
const buttons = {
@@ -264,15 +273,9 @@ function setupAudioControls() {
264273
const playBtn = document.querySelector('.play-audio-btn');
265274
const pauseBtn = document.querySelector('.pause-audio-btn');
266275
const stopBtn = document.querySelector('.stop-audio-btn');
267-
if (playBtn) {
268-
playBtn.addEventListener('click', handleAudioPlayback);
269-
}
270-
if (pauseBtn) {
271-
pauseBtn.addEventListener('click', pauseChapterAudio);
272-
}
273-
if (stopBtn) {
274-
stopBtn.addEventListener('click', stopChapterAudio);
275-
}
276+
if (playBtn) playBtn.addEventListener('click', handleAudioPlayback);
277+
if (pauseBtn) pauseBtn.addEventListener('click', pauseChapterAudio);
278+
if (stopBtn) stopBtn.addEventListener('click', stopChapterAudio);
276279
});
277280
}
278281
function handleAudioPlayback() {
@@ -455,10 +458,91 @@ function setupTouchEvents() {
455458
document.addEventListener('touchend', handleTouchEnd);
456459
document.addEventListener('touchcancel', handleTouchCancel);
457460
}
461+
function setupSyncManagement() {
462+
initSync();
463+
if (state.settings.connectedDevices?.length > 0) {
464+
syncManager.initPeer().catch(err => {
465+
console.error('[App] Failed to init peer on load:', err);
466+
});
467+
}
468+
document.addEventListener('sync:peerConnected', (ev) => {
469+
console.log('[App] Peer connected:', ev.detail.peerId);
470+
if (!syncResumedToastShown) {
471+
const notification = document.createElement('div');
472+
notification.textContent = 'Device connected!';
473+
notification.style.cssText = `
474+
position: fixed;
475+
top: 70px;
476+
right: 10px;
477+
padding: 12px 20px;
478+
background: #4caf50;
479+
color: white;
480+
border-radius: 5px;
481+
z-index: 10000;
482+
font-size: 14px;
483+
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
484+
`;
485+
document.body.appendChild(notification);
486+
setTimeout(() => notification.remove(), 3000);
487+
syncResumedToastShown = true;
488+
} else {
489+
syncResumedToastShown = false;
490+
}
491+
});
492+
document.addEventListener('sync:merged', (ev) => {
493+
if (!ev.detail.changesMade) return;
494+
console.log('[App] Applying real-time sync changes from', ev.detail.peerId);
495+
const currentBook = state.settings.manualBook;
496+
const currentChapter = state.settings.manualChapter;
497+
document.querySelectorAll('.verse').forEach(verseEl => {
498+
const ref = verseEl.dataset.verse;
499+
if (!ref) return;
500+
const versePattern = new RegExp(`^${currentBook.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')} ${currentChapter}:`);
501+
if (!versePattern.test(ref)) return;
502+
const newColor = state.highlights[ref];
503+
const currentClasses = Array.from(verseEl.classList);
504+
const currentHighlight = currentClasses.find(c => c.startsWith('highlight-'));
505+
if (newColor && currentHighlight !== `highlight-${newColor}`) {
506+
currentClasses.filter(c => c.startsWith('highlight-'))
507+
.forEach(c => verseEl.classList.remove(c));
508+
verseEl.classList.add(`highlight-${newColor}`);
509+
console.log(`[App] Updated highlight for ${ref} to ${newColor}`);
510+
} else if (!newColor && currentHighlight) {
511+
verseEl.classList.remove(currentHighlight);
512+
console.log(`[App] Removed highlight for ${ref}`);
513+
}
514+
});
515+
const notesInput = document.getElementById('notesInput');
516+
if (notesInput && document.activeElement !== notesInput) {
517+
notesInput.value = state.notes;
518+
if (state.settings.notesView === 'markdown') {
519+
updateMarkdownPreview();
520+
}
521+
console.log('[App] Updated notes from sync');
522+
}
523+
applyTheme();
524+
applyColorTheme();
525+
updateBibleGatewayVersion();
526+
initialiseNarratorSelect();
527+
updateAudioControlsVisibility();
528+
console.log('[App] UI updated from sync');
529+
});
530+
document.addEventListener('sync:error', (ev) => {
531+
console.error('[App] Sync error:', ev.detail.error);
532+
alert('Sync error: ' + ev.detail.error);
533+
});
534+
document.addEventListener('offlineStatus', (ev) => {
535+
if (ev.detail.isOffline) {
536+
console.log('[App] Pausing sync due to offline');
537+
document.dispatchEvent(new CustomEvent('sync:pauseReconnect'));
538+
}
539+
});
540+
}
458541
async function init() {
459542
try {
543+
showLoading(true);
460544
await loadFromStorage();
461-
loadFromCookies();
545+
await loadFromCookies();
462546
const style = document.createElement('style');
463547
style.textContent = OFFLINE_STYLES;
464548
document.head.appendChild(style);
@@ -494,10 +578,8 @@ async function init() {
494578
setupEventListeners();
495579
if ('serviceWorker' in navigator) {
496580
navigator.serviceWorker.register('/sw.js')
581+
.then(reg => navigator.serviceWorker.ready)
497582
.then(reg => {
498-
return navigator.serviceWorker.ready;
499-
})
500-
.then(reg => {
501583
reg.active.postMessage({ type: 'VERSION', version: APP_VERSION });
502584
console.log('Sent version to SW:', APP_VERSION);
503585
})
@@ -506,6 +588,8 @@ async function init() {
506588
console.log('App initialized successfully');
507589
} catch (error) {
508590
handleError(error, 'app initialization');
591+
} finally {
592+
showLoading(false);
509593
}
510594
}
511595
if (document.readyState === 'loading') {

modules/api.js

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { clearError, handleError, showError, showLoading } from '../main.js';
1+
import { handleError, showError } from '../main.js';
22
import { nextPassage } from './navigation.js';
3-
import { afterContentLoad, displayPassage, extractVerseText } from './passage.js';
3+
import { extractVerseText } from './passage.js';
44
import { bookNameMapping, state, saveToStorage } from './state.js';
55
const API_BASE_URL = 'https://bible.helloao.org/api';
66
const AUDIO_TIMEOUT_MS = 10000;
@@ -329,39 +329,6 @@ export function cleanupAudioPlayer() {
329329
}
330330
state.audioPlayer = null;
331331
}
332-
export async function loadPassageFromAPI(passageInfo) {
333-
try {
334-
showLoading(true);
335-
const { book, chapter, startVerse, endVerse, displayRef, translation } = passageInfo;
336-
state.currentPassageReference = displayRef;
337-
const apiTranslation = translation ? apiTranslationCode(translation) : apiTranslationCode(state.settings.bibleTranslation);
338-
const apiBook = getApiBookCode(book);
339-
const chapterData = await fetchChapter(apiTranslation, apiBook, chapter);
340-
state.currentChapterData = chapterData;
341-
const chapterFootnotes = chapterData.chapter.footnotes || [];
342-
const footnoteCounter = { value: 1 };
343-
const contentItems = processChapterContent(
344-
chapterData.chapter.content,
345-
book,
346-
chapter,
347-
startVerse,
348-
endVerse,
349-
chapterFootnotes,
350-
footnoteCounter
351-
);
352-
if (contentItems.length === 0) {
353-
throw new Error('No content found in the requested range');
354-
}
355-
displayPassage(contentItems);
356-
afterContentLoad();
357-
clearError();
358-
updateAudioControls(chapterData.thisChapterAudioLinks);
359-
} catch (error) {
360-
handleError(error, 'loadPassageFromAPI');
361-
} finally {
362-
showLoading(false);
363-
}
364-
}
365332
function processChapterContent(content, book, chapter, startVerse, endVerse, footnotes, footnoteCounter) {
366333
return content
367334
.filter(item => {

modules/highlights.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,17 @@ export function applyHighlight(color) {
4242
if (color !== 'none') {
4343
state.currentVerse.classList.add(`highlight-${color}`);
4444
state.highlights[verseRef] = color;
45+
if (!state._syncMeta.highlights[verseRef]) {
46+
state._syncMeta.highlights[verseRef] = {};
47+
}
48+
state._syncMeta.highlights[verseRef].ts = Date.now();
4549
} else {
4650
delete state.highlights[verseRef];
51+
if (!state._syncMeta.highlights[verseRef]) {
52+
state._syncMeta.highlights[verseRef] = {};
53+
}
54+
state._syncMeta.highlights[verseRef].deleted = true;
55+
state._syncMeta.highlights[verseRef].ts = Date.now();
4756
}
4857
saveToStorage();
4958
const picker = document.getElementById('colorPicker');
@@ -57,6 +66,14 @@ export function clearHighlights() {
5766
if (!confirm('Are you sure you want to delete ALL highlights? This cannot be undone.')) {
5867
return;
5968
}
69+
const now = Date.now();
70+
Object.keys(state.highlights).forEach(ref => {
71+
if (!state._syncMeta.highlights) state._syncMeta.highlights = {};
72+
state._syncMeta.highlights[ref] = {
73+
deleted: true,
74+
ts: now
75+
};
76+
});
6077
state.highlights = {};
6178
document.querySelectorAll('.verse').forEach(verse => {
6279
verse.classList.remove(...HIGHLIGHT_COLORS.map(col => `highlight-${col}`));
@@ -66,6 +83,7 @@ export function clearHighlights() {
6683
if (modal?.classList.contains('active')) {
6784
renderHighlights('all', '');
6885
}
86+
console.log('[Highlights] Cleared all highlights with sync tombstones');
6987
} catch (error) {
7088
console.error('Error clearing highlights:', error);
7189
}

0 commit comments

Comments
 (0)