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
+1329 -402

No files matched your search

+66 -2
View File
@@ -37,11 +37,13 @@ const http = require("http");
const PORT = process.env.PORT || 4000;
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-" + crypto.randomBytes(16).toString("hex");
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(32).toString("hex");
const DATA_DIR = path.join(__dirname, "data");
const UPLOAD_DIR = path.join(__dirname, "uploads");
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
/* ─────────── Database ─────────── */
const db = new Database(path.join(__dirname, "inknote.db"));
const db = new Database(path.join(DATA_DIR, "inknote.db"));
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
@@ -213,6 +215,43 @@ app.post("/api/auth/login", (req, res) => {
res.json({ token, user: { id: user.id, email: user.email, displayName: user.display_name } });
});
/* ─── Auth profile routes ─── */
app.get("/api/auth/me", authMiddleware, (req, res) => {
const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId);
if (!user) return res.status(404).json({ error: "User not found" });
res.json({ id: user.id, email: user.email, displayName: user.display_name });
});
app.put("/api/auth/profile", authMiddleware, (req, res) => {
const { displayName } = req.body;
if (!displayName || !displayName.trim()) return res.status(400).json({ error: "Display name required" });
db.prepare("UPDATE users SET display_name = ?, updated_at = datetime('now') WHERE id = ?").run(displayName.trim(), req.userId);
const user = db.prepare("SELECT id, email, display_name FROM users WHERE id = ?").get(req.userId);
res.json({ id: user.id, email: user.email, displayName: user.display_name });
});
app.put("/api/auth/email", authMiddleware, (req, res) => {
const { newEmail, password } = req.body;
if (!newEmail || !password) return res.status(400).json({ error: "Email and password required" });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
if (!bcrypt.compareSync(password, user.password_hash)) return res.status(401).json({ error: "Wrong password" });
const exists = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(newEmail, req.userId);
if (exists) return res.status(409).json({ error: "Email already in use" });
db.prepare("UPDATE users SET email = ?, updated_at = datetime('now') WHERE id = ?").run(newEmail, req.userId);
res.json({ id: user.id, email: newEmail, displayName: user.display_name });
});
app.put("/api/auth/password", authMiddleware, (req, res) => {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: "Both passwords required" });
if (newPassword.length < 6) return res.status(400).json({ error: "Password must be at least 6 characters" });
const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId);
if (!bcrypt.compareSync(currentPassword, user.password_hash)) return res.status(401).json({ error: "Current password is wrong" });
const hash = bcrypt.hashSync(newPassword, 12);
db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?").run(hash, req.userId);
res.json({ success: true });
});
/* ─── Notebook routes ─── */
app.get("/api/notebooks", authMiddleware, (req, res) => {
const notebooks = db.prepare("SELECT * FROM notebooks WHERE user_id = ? AND deleted_at IS NULL ORDER BY sort_order").all(req.userId);
@@ -256,6 +295,11 @@ app.get("/api/notes", authMiddleware, (req, res) => {
res.json(notes);
});
app.get("/api/notes/trash", authMiddleware, (req, res) => {
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
res.json(notes);
});
app.get("/api/notes/:id", authMiddleware, (req, res) => {
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" });
@@ -328,12 +372,32 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
res.json({ success: true, vectorClock: newClock });
});
/* ─── Trash batch endpoint (MUST be before :id routes) ─── */
app.delete("/api/notes/trash/empty", authMiddleware, (req, res) => {
db.prepare("DELETE FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL").run(req.userId);
res.json({ success: true });
});
app.delete("/api/notes/:id", authMiddleware, (req, res) => {
db.prepare("UPDATE notes SET deleted_at = datetime('now') WHERE id = ? AND user_id = ?").run(req.params.id, req.userId);
broadcastToUser(req.userId, { type: "note_deleted", data: { id: req.params.id } }, req.deviceId);
res.json({ success: true });
});
/* ─── Trash endpoints ─── */
app.put("/api/notes/:id/restore", authMiddleware, (req, res) => {
const note = db.prepare("SELECT id FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" });
db.prepare("UPDATE notes SET deleted_at = NULL, updated_at = datetime('now') WHERE id = ?").run(req.params.id);
broadcastToUser(req.userId, { type: "note_restored", data: { id: req.params.id } }, req.deviceId);
res.json({ success: true });
});
app.delete("/api/notes/:id/permanent", authMiddleware, (req, res) => {
db.prepare("DELETE FROM notes WHERE id = ? AND user_id = ?").run(req.params.id, req.userId);
res.json({ success: true });
});
/* ─── Drawing data route ─── */
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
const { drawingData } = req.body;
@@ -472,5 +536,5 @@ server.listen(PORT, () => {
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`);
console.log(` 💾 Database: ${path.join(__dirname, "inknote.db")}\n`);
console.log(` 💾 Database: ${path.join(DATA_DIR, "inknote.db")}\n`);
});
+25 -4
View File
@@ -2,13 +2,34 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0d1117" />
<title>InkNote</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f8fa" media="(prefers-color-scheme: light)" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="InkNote" />
<meta name="description" content="Write & draw — notes that sync everywhere" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="./manifest.json" />
<link rel="apple-touch-icon" href="./icon-192.png" />
<link rel="icon" type="image/png" sizes="192x192" href="./icon-192.png" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✏️</text></svg>" />
<style>body{margin:0;padding:0;overflow:hidden;}</style>
<title>InkNote</title>
<style>
body { margin: 0; padding: 0; overflow: hidden; overscroll-behavior: none; }
#root { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); height: 100vh; }
#splash { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 16px; background: #0d1117; z-index: 9999; transition: opacity 0.3s; }
#splash.hide { opacity: 0; pointer-events: none; }
#splash .logo { width: 64px; height: 64px; border-radius: 18px; background: linear-gradient(135deg, #388bfd, #a855f7); display: flex; align-items: center; justify-content: center; font-size: 32px; animation: pulse 1.5s infinite; }
#splash .text { color: #e6edf3; font-family: Georgia, serif; font-size: 22px; font-weight: 700; }
@keyframes pulse { 0%,100% { transform: scale(1); } 50% { transform: scale(1.05); } }
</style>
</head>
<body>
<div id="splash">
<div class="logo">✏️</div>
<div class="text">InkNote</div>
</div>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
+65
View File
@@ -0,0 +1,65 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Allow large drawing data + body size
client_max_body_size 50m;
# PWA service worker — MUST not be cached by browser/proxy
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Service-Worker-Allowed "/";
add_header Content-Type "application/javascript";
try_files $uri =404;
}
# PWA manifest
location = /manifest.json {
add_header Cache-Control "public, max-age=3600";
add_header Content-Type "application/manifest+json";
try_files $uri =404;
}
# Static assets — long cache with content hash from Vite
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Icons
location ~* ^/icon-.*\.png$ {
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
# SPA fallback — all other routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://inknote-server:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10;
proxy_send_timeout 60;
proxy_read_timeout 60;
}
# WebSocket proxy
location /ws {
proxy_pass http://inknote-server:4000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "InkNote",
"short_name": "InkNote",
"description": "Write & draw — notes that sync everywhere",
"start_url": ".",
"scope": ".",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"orientation": "any",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"prefer_related_applications": false
}
+66
View File
@@ -0,0 +1,66 @@
const CACHE_NAME = 'inknote-v2';
// Install — cache app shell on first load
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(async (cache) => {
// Cache the main page and manifest
const urls = ['/', '/manifest.json', '/icon-192.png', '/icon-512.png'];
for (const url of urls) {
try { await cache.add(url); } catch (err) { console.warn('[SW] Failed to cache:', url); }
}
})
);
self.skipWaiting();
});
// Activate — clean old caches
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k))))
);
self.clients.claim();
});
// Fetch handler
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// Skip API/WebSocket requests entirely — handled by app + IndexedDB
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) return;
// Skip non-GET requests
if (e.request.method !== 'GET') return;
// Navigation (HTML pages) — network-first, fallback to cache
if (e.request.mode === 'navigate') {
e.respondWith(
fetch(e.request)
.then(r => { const c = r.clone(); caches.open(CACHE_NAME).then(cache => cache.put('/', c)); return r; })
.catch(() => caches.match('/'))
);
return;
}
// JS/CSS/images — stale-while-revalidate
e.respondWith(
caches.match(e.request).then(cached => {
const fetchP = fetch(e.request).then(r => {
if (r.ok && r.type === 'basic') {
caches.open(CACHE_NAME).then(cache => cache.put(e.request, r.clone()));
}
return r;
}).catch(() => cached);
return cached || fetchP;
})
);
});
// Background sync
self.addEventListener('sync', (e) => {
if (e.tag === 'sync-changes') {
e.waitUntil(self.clients.matchAll().then(clients => {
clients.forEach(c => c.postMessage({ type: 'FLUSH_QUEUE' }));
}));
}
});
+514 -395
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 };