Alpha Version of Syncing and Desktop/Anroid PWA

This commit is contained in:
Honey committed 2026-02-18 20:07:10 +01:00
1 parent 8cd8133f9e
commit a8779d7be1
9 files changed
+1381 -454

No files matched your search

+566 -447
View File
File diff suppressed because it is too large. Load diff
+39 -1
View File
@@ -2,8 +2,46 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import NoteApp from './NoteApp.jsx'
// Hide splash screen after app renders
const hideSplash = () => {
const splash = document.getElementById('splash');
if (splash) {
splash.classList.add('hide');
setTimeout(() => splash.remove(), 300);
}
};
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<NoteApp />
</React.StrictMode>
)
);
// Hide splash after a short delay (let React mount)
setTimeout(hideSplash, 200);
// ── Register Service Worker ──
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const reg = await navigator.serviceWorker.register('./sw.js', { scope: '/' });
console.log('[SW] Registered:', reg.scope);
// Check for updates periodically
setInterval(() => reg.update(), 60 * 60 * 1000); // hourly
// Prompt refresh on new version
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
newWorker?.addEventListener('statechange', () => {
if (newWorker.state === 'activated' && navigator.serviceWorker.controller) {
// New version available — could show a toast here
console.log('[SW] New version available');
}
});
});
} catch (err) {
console.warn('[SW] Registration failed:', err);
}
});
}
+204
View File
@@ -0,0 +1,204 @@
/**
* InkNote Offline Store — IndexedDB local-first storage
*
* All reads/writes go here first (instant, works offline).
* Sync engine flushes changes to server when online.
*/
const DB_NAME = 'inknote';
const DB_VERSION = 2;
let db = null;
function open() {
return new Promise((resolve, reject) => {
if (db) return resolve(db);
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const d = e.target.result;
// Notebooks store
if (!d.objectStoreNames.contains('notebooks')) {
const nb = d.createObjectStore('notebooks', { keyPath: 'id' });
nb.createIndex('sortOrder', 'sortOrder');
}
// Notes metadata store
if (!d.objectStoreNames.contains('notes')) {
const n = d.createObjectStore('notes', { keyPath: 'id' });
n.createIndex('folderId', 'folderId');
n.createIndex('trashed', 'trashed');
n.createIndex('updatedAt', 'updatedAt');
}
// Note content store (separate for lazy loading)
if (!d.objectStoreNames.contains('noteContents')) {
d.createObjectStore('noteContents', { keyPath: 'id' });
}
// Sync queue — pending changes to push to server
if (!d.objectStoreNames.contains('syncQueue')) {
const sq = d.createObjectStore('syncQueue', { keyPath: 'queueId', autoIncrement: true });
sq.createIndex('entityId', 'entityId');
sq.createIndex('timestamp', 'timestamp');
}
// Key-value meta store (token, user, last sync time, etc)
if (!d.objectStoreNames.contains('meta')) {
d.createObjectStore('meta', { keyPath: 'key' });
}
};
req.onsuccess = (e) => { db = e.target.result; resolve(db); };
req.onerror = (e) => reject(e.target.error);
});
}
// ─── Generic helpers ───
async function tx(stores, mode, fn) {
const d = await open();
return new Promise((resolve, reject) => {
const t = d.transaction(stores, mode);
const result = fn(t);
t.oncomplete = () => resolve(result._value);
t.onerror = (e) => reject(e.target.error);
});
}
function reqToPromise(req) {
return new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = (e) => rej(e.target.error); });
}
// ─── Meta (key-value) ───
export async function getMeta(key) {
const d = await open();
const r = await reqToPromise(d.transaction('meta', 'readonly').objectStore('meta').get(key));
return r?.value ?? null;
}
export async function setMeta(key, value) {
const d = await open();
await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').put({ key, value }));
}
export async function deleteMeta(key) {
const d = await open();
await reqToPromise(d.transaction('meta', 'readwrite').objectStore('meta').delete(key));
}
// ─── Notebooks ───
export async function getAllNotebooks() {
const d = await open();
const all = await reqToPromise(d.transaction('notebooks', 'readonly').objectStore('notebooks').getAll());
return all.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
}
export async function putNotebook(nb) {
const d = await open();
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').put(nb));
}
export async function putNotebooks(list) {
const d = await open();
const t = d.transaction('notebooks', 'readwrite');
const s = t.objectStore('notebooks');
for (const nb of list) s.put(nb);
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
export async function deleteNotebook(id) {
const d = await open();
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').delete(id));
}
export async function clearNotebooks() {
const d = await open();
await reqToPromise(d.transaction('notebooks', 'readwrite').objectStore('notebooks').clear());
}
// ─── Notes (metadata) ───
export async function getAllNotes() {
const d = await open();
return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').getAll());
}
export async function getNote(id) {
const d = await open();
return reqToPromise(d.transaction('notes', 'readonly').objectStore('notes').get(id));
}
export async function putNote(note) {
const d = await open();
await reqToPromise(d.transaction('notes', 'readwrite').objectStore('notes').put(note));
}
export async function putNotes(list) {
const d = await open();
const t = d.transaction('notes', 'readwrite');
const s = t.objectStore('notes');
for (const n of list) s.put(n);
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
export async function deleteNote(id) {
const d = await open();
const t = d.transaction(['notes', 'noteContents'], 'readwrite');
t.objectStore('notes').delete(id);
t.objectStore('noteContents').delete(id);
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
export async function clearNotes() {
const d = await open();
const t = d.transaction(['notes'], 'readwrite');
t.objectStore('notes').clear();
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
// ─── Note content (lazy loaded) ───
export async function getNoteContent(id) {
const d = await open();
return reqToPromise(d.transaction('noteContents', 'readonly').objectStore('noteContents').get(id));
}
export async function putNoteContent(id, content, drawingData) {
const d = await open();
await reqToPromise(d.transaction('noteContents', 'readwrite').objectStore('noteContents').put({ id, content, drawingData }));
}
// ─── Sync Queue ───
export async function enqueue(action) {
const d = await open();
const entry = { ...action, timestamp: Date.now() };
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').add(entry));
}
export async function peekQueue(limit = 50) {
const d = await open();
return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').getAll(null, limit));
}
export async function dequeue(queueId) {
const d = await open();
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').delete(queueId));
}
export async function clearQueue() {
const d = await open();
await reqToPromise(d.transaction('syncQueue', 'readwrite').objectStore('syncQueue').clear());
}
export async function getQueueSize() {
const d = await open();
return reqToPromise(d.transaction('syncQueue', 'readonly').objectStore('syncQueue').count());
}
// ─── Full wipe (for logout) ───
export async function clearAll() {
const d = await open();
const stores = ['notebooks', 'notes', 'noteContents', 'syncQueue', 'meta'];
const t = d.transaction(stores, 'readwrite');
stores.forEach((s) => t.objectStore(s).clear());
return new Promise((res, rej) => { t.oncomplete = res; t.onerror = rej; });
}
+333
View File
@@ -0,0 +1,333 @@
/**
* InkNote Sync Engine v2 — online-first, offline-fallback
*
* When ONLINE: Server call → cache to IndexedDB (server IDs everywhere)
* When OFFLINE: IndexedDB + queue → flush on reconnect
*/
import * as db from './offlineStore.js';
const DEVICE_ID = (() => {
try {
let d = localStorage.getItem('inknote_device');
if (!d) { d = 'dev_' + Math.random().toString(36).slice(2, 10); localStorage.setItem('inknote_device', d); }
return d;
} catch { return 'dev_' + Math.random().toString(36).slice(2, 10); }
})();
/* ─── API helper ─── */
const api = {
base: '/api',
token: null,
async req(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Device-Id': DEVICE_ID } };
if (this.token) opts.headers['Authorization'] = 'Bearer ' + this.token;
if (body) opts.body = JSON.stringify(body);
const r = await fetch(this.base + path, opts);
if (r.status === 401) { this.token = null; try { localStorage.removeItem('inknote_token'); } catch {} throw new Error('AUTH'); }
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Request failed');
return data;
},
get(p) { return this.req('GET', p); },
post(p, b) { return this.req('POST', p, b); },
put(p, b) { return this.req('PUT', p, b); },
del(p) { return this.req('DELETE', p); },
};
/* ─── Online status ─── */
let _online = navigator.onLine;
window.addEventListener('online', () => { _online = true; notifyStatus('syncing'); flushQueue().then(() => fullSync()).catch(() => {}); });
window.addEventListener('offline', () => { _online = false; notifyStatus('offline'); });
export function isOnline() { return _online; }
/* ─── Status ─── */
let _syncStatus = 'synced';
let _statusCbs = [];
let _dataCbs = [];
function notifyStatus(s) { _syncStatus = s; _statusCbs.forEach(fn => fn(s)); }
export function onSyncStatus(fn) { _statusCbs.push(fn); fn(_syncStatus); return () => { _statusCbs = _statusCbs.filter(f => f !== fn); }; }
function notifyData(t, d) { _dataCbs.forEach(fn => fn(t, d)); }
export function onDataChange(fn) { _dataCbs.push(fn); return () => { _dataCbs = _dataCbs.filter(f => f !== fn); }; }
/* ─── Auth ─── */
export async function login(email, password) {
const data = await api.post('/auth/login', { email, password });
api.token = data.token;
await db.setMeta('token', data.token);
await db.setMeta('user', data.user);
try { localStorage.setItem('inknote_token', data.token); } catch {}
return data.user;
}
export async function register(email, password, displayName) {
const data = await api.post('/auth/register', { email, password, displayName });
api.token = data.token;
await db.setMeta('token', data.token);
await db.setMeta('user', data.user);
try { localStorage.setItem('inknote_token', data.token); } catch {}
return data.user;
}
export async function restoreSession() {
let token = await db.getMeta('token');
if (!token) { try { token = localStorage.getItem('inknote_token'); } catch {} }
if (!token) return null;
api.token = token;
if (_online) {
try { const u = await api.get('/auth/me'); await db.setMeta('user', u); return u; }
catch (e) { if (e.message === 'AUTH') { await logout(); return null; } }
}
return await db.getMeta('user') || {};
}
export async function logout() {
api.token = null;
try { localStorage.removeItem('inknote_token'); } catch {}
await db.clearAll();
}
/* ─── Account ─── */
export async function updateProfile(displayName) {
const u = await api.put('/auth/profile', { displayName });
await db.setMeta('user', u);
return u;
}
export async function updateEmail(newEmail, password) {
const u = await api.put('/auth/email', { newEmail, password });
await db.setMeta('user', u);
return u;
}
export async function updatePassword(currentPassword, newPassword) {
await api.put('/auth/password', { currentPassword, newPassword });
}
/* ─── Full sync ─── */
export async function fullSync() {
if (!_online || !api.token) return;
notifyStatus('syncing');
try {
await flushQueue();
const serverNbs = await api.get('/notebooks');
const nbs = serverNbs.map(nb => ({ id: nb.id, name: nb.name, cover: nb.cover_index || 0, sortOrder: nb.sort_order || 0 }));
await db.clearNotebooks();
if (nbs.length > 0) await db.putNotebooks(nbs);
const serverNotes = await api.get('/notes');
const active = serverNotes.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: false, createdAt: n.created_at, updatedAt: n.updated_at }));
let trashed = [];
try {
const st = await api.get('/notes/trash');
trashed = st.map(n => ({ id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed: true, createdAt: n.created_at, updatedAt: n.updated_at || n.deleted_at }));
} catch {}
await db.clearNotes();
const all = [...active, ...trashed];
if (all.length > 0) await db.putNotes(all);
notifyStatus('synced');
notifyData('fullSync', null);
} catch (e) {
if (e.message === 'AUTH') throw e;
notifyStatus('error');
}
}
/* ─── Queue ─── */
let _flushing = false;
async function flushQueue() {
if (_flushing || !_online || !api.token) return;
_flushing = true;
try {
const pending = await db.peekQueue(100);
for (const entry of pending) {
try { await processEntry(entry); } catch (e) {
if (e.message === 'AUTH') { _flushing = false; throw e; }
if (!_online) break;
console.warn('[sync] skip failed entry:', entry.action, e.message);
}
await db.dequeue(entry.queueId);
}
} catch (e) { if (e.message === 'AUTH') throw e; }
_flushing = false;
}
async function processEntry(entry) {
const d = entry.data;
switch (entry.action) {
case 'createNotebook': {
const r = await api.post('/notebooks', { name: d.name, coverIndex: d.cover });
if (r.id !== d.localId) {
await db.deleteNotebook(d.localId);
await db.putNotebook({ id: r.id, name: d.name, cover: d.cover, sortOrder: d.sortOrder || 0 });
const notes = await db.getAllNotes();
for (const n of notes) { if (n.folderId === d.localId) { n.folderId = r.id; await db.putNote(n); } }
}
break;
}
case 'updateNotebook': await api.put(`/notebooks/${d.id}`, { name: d.name, coverIndex: d.cover, sortOrder: d.sortOrder }); break;
case 'deleteNotebook': await api.del(`/notebooks/${d.id}`); break;
case 'createNote': {
const content = await db.getNoteContent(d.localId);
const r = await api.post('/notes', { title: d.title, notebookId: d.folderId, content: content?.content || '' });
if (r.id !== d.localId) {
const old = await db.getNote(d.localId);
const oldC = await db.getNoteContent(d.localId);
await db.deleteNote(d.localId);
if (old) { delete old._pendingCreate; old.id = r.id; await db.putNote(old); }
if (oldC) await db.putNoteContent(r.id, oldC.content, oldC.drawingData);
}
break;
}
case 'updateNote': await api.put(`/notes/${d.id}`, d.updates); break;
case 'updateDrawing': await api.put(`/notes/${d.id}/drawing`, { drawingData: d.drawingData }); break;
case 'trashNote': await api.del(`/notes/${d.id}`); break;
case 'restoreNote': await api.put(`/notes/${d.id}/restore`, {}); break;
case 'permanentDelete': await api.del(`/notes/${d.id}/permanent`); break;
case 'emptyTrash': await api.del('/notes/trash/empty'); break;
}
}
/* ─── Debounce ─── */
const _timers = {};
function debounce(key, fn, ms = 800) { if (_timers[key]) clearTimeout(_timers[key]); _timers[key] = setTimeout(fn, ms); }
/* ════════ PUBLIC DATA API ════════ */
export async function getNotebooks() { return db.getAllNotebooks(); }
export async function createNotebook({ name, cover }) {
if (_online && api.token) {
try {
const r = await api.post('/notebooks', { name, coverIndex: cover });
const nb = { id: r.id, name, cover, sortOrder: (await db.getAllNotebooks()).length };
await db.putNotebook(nb); notifyData('notebook', nb); return nb;
} catch (e) { if (e.message === 'AUTH') throw e; }
}
const lid = 'nb_' + Math.random().toString(36).slice(2, 10);
const nb = { id: lid, name, cover, sortOrder: (await db.getAllNotebooks()).length };
await db.putNotebook(nb);
await db.enqueue({ action: 'createNotebook', data: { localId: lid, name, cover, sortOrder: nb.sortOrder } });
notifyData('notebook', nb); notifyStatus('pending'); return nb;
}
export async function updateNotebook(id, updates) {
const nbs = await db.getAllNotebooks(); const nb = nbs.find(n => n.id === id); if (!nb) return;
const upd = { ...nb, ...updates }; await db.putNotebook(upd); notifyData('notebook', upd);
if (_online && api.token) { try { await api.put(`/notebooks/${id}`, { name: updates.name, coverIndex: updates.cover, sortOrder: updates.sortOrder }); return; } catch (e) { if (e.message === 'AUTH') throw e; } }
await db.enqueue({ action: 'updateNotebook', data: { id, name: updates.name, cover: updates.cover, sortOrder: updates.sortOrder } }); notifyStatus('pending');
}
export async function deleteNotebookLocal(id) {
const notes = await db.getAllNotes();
for (const n of notes) { if (n.folderId === id && !n.trashed) { n.trashed = true; await db.putNote(n); } }
await db.deleteNotebook(id); notifyData('notebook', { id, deleted: true });
if (_online && api.token) { try { await api.del(`/notebooks/${id}`); return; } catch (e) { if (e.message === 'AUTH') throw e; } }
await db.enqueue({ action: 'deleteNotebook', data: { id } }); notifyStatus('pending');
}
export async function reorderNotebooks(orderedIds) {
const nbs = await db.getAllNotebooks();
for (let i = 0; i < orderedIds.length; i++) { const nb = nbs.find(n => n.id === orderedIds[i]); if (nb) { nb.sortOrder = i; await db.putNotebook(nb); } }
notifyData('notebooks', null);
if (_online && api.token) { for (let i = 0; i < orderedIds.length; i++) { api.put(`/notebooks/${orderedIds[i]}`, { sortOrder: i }).catch(() => {}); } }
}
export async function getNotes() { return db.getAllNotes(); }
export async function loadNoteContent(id) {
const local = await db.getNoteContent(id);
if (local) return { content: local.content || '', drawingData: local.drawingData || null };
if (_online && api.token) {
try { const d = await api.get(`/notes/${id}`); const c = { content: d.content || '', drawingData: null }; await db.putNoteContent(id, c.content, c.drawingData); return c; } catch {}
}
return { content: '', drawingData: null };
}
export async function createNote(folderId) {
const ts = new Date().toISOString();
if (_online && api.token) {
try {
const r = await api.post('/notes', { title: 'Untitled Note', notebookId: folderId, content: '' });
const note = { id: r.id, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts };
await db.putNote(note); await db.putNoteContent(r.id, '', null); notifyData('note', note); return note;
} catch (e) { if (e.message === 'AUTH') throw e; }
}
const lid = 'local_' + Math.random().toString(36).slice(2, 10);
const note = { id: lid, title: 'Untitled Note', folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts, _pendingCreate: true };
await db.putNote(note); await db.putNoteContent(lid, '', null);
await db.enqueue({ action: 'createNote', data: { localId: lid, title: 'Untitled Note', folderId } });
notifyData('note', note); notifyStatus('pending'); return note;
}
export async function updateNoteTitle(id, title) {
const note = await db.getNote(id); if (!note) return;
note.title = title; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
debounce(`title_${id}`, async () => {
if (_online && api.token && !note._pendingCreate) { try { await api.put(`/notes/${id}`, { title }); notifyStatus('synced'); return; } catch {} }
await db.enqueue({ action: 'updateNote', data: { id, updates: { title } } }); notifyStatus('pending');
});
}
export async function updateNoteContent(id, content) {
const ex = await db.getNoteContent(id); await db.putNoteContent(id, content, ex?.drawingData || null);
const note = await db.getNote(id); if (note) { note.updatedAt = new Date().toISOString(); await db.putNote(note); }
notifyData('noteContent', { id });
debounce(`content_${id}`, async () => {
if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}`, { content }); notifyStatus('synced'); return; } catch {} }
await db.enqueue({ action: 'updateNote', data: { id, updates: { content } } }); notifyStatus('pending');
});
}
export async function updateNoteDrawing(id, drawingData) {
const ex = await db.getNoteContent(id); await db.putNoteContent(id, ex?.content || '', drawingData);
notifyData('noteContent', { id });
debounce(`drawing_${id}`, async () => {
const note = await db.getNote(id);
if (_online && api.token && !note?._pendingCreate) { try { await api.put(`/notes/${id}/drawing`, { drawingData }); notifyStatus('synced'); return; } catch {} }
await db.enqueue({ action: 'updateDrawing', data: { id, drawingData } }); notifyStatus('pending');
});
}
export async function toggleNoteStar(id) {
const note = await db.getNote(id); if (!note) return false;
note.starred = !note.starred; await db.putNote(note); notifyData('note', note);
if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { starred: note.starred }).catch(() => {}); }
else { await db.enqueue({ action: 'updateNote', data: { id, updates: { starred: note.starred } } }); notifyStatus('pending'); }
return note.starred;
}
export async function moveNote(id, fid) {
const note = await db.getNote(id); if (!note) return;
note.folderId = fid; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
if (_online && api.token && !note._pendingCreate) { api.put(`/notes/${id}`, { notebookId: fid }).catch(() => {}); }
else { await db.enqueue({ action: 'updateNote', data: { id, updates: { notebookId: fid } } }); notifyStatus('pending'); }
}
export async function trashNote(id) {
const note = await db.getNote(id); if (!note) return;
note.trashed = true; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
if (_online && api.token && !note._pendingCreate) { try { await api.del(`/notes/${id}`); } catch {} }
else { await db.enqueue({ action: 'trashNote', data: { id } }); notifyStatus('pending'); }
}
export async function restoreNoteLocal(id) {
const note = await db.getNote(id); if (!note) return;
note.trashed = false; note.updatedAt = new Date().toISOString(); await db.putNote(note); notifyData('note', note);
if (_online && api.token) { try { await api.put(`/notes/${id}/restore`, {}); } catch {} }
else { await db.enqueue({ action: 'restoreNote', data: { id } }); notifyStatus('pending'); }
}
export async function permanentDeleteNoteLocal(id) {
await db.deleteNote(id); notifyData('note', { id, deleted: true });
if (_online && api.token) { try { await api.del(`/notes/${id}/permanent`); } catch {} }
else { await db.enqueue({ action: 'permanentDelete', data: { id } }); notifyStatus('pending'); }
}
export async function emptyTrashLocal() {
const all = await db.getAllNotes(); for (const n of all) { if (n.trashed) await db.deleteNote(n.id); }
notifyData('notes', null);
if (_online && api.token) { try { await api.del('/notes/trash/empty'); } catch {} }
else { await db.enqueue({ action: 'emptyTrash', data: {} }); notifyStatus('pending'); }
}
export { DEVICE_ID };