Alpha Version of Syncing and Desktop/Anroid PWA
This commit is contained in:
1 parent
8cd8133f9e
commit
a8779d7be1
9 files changed
+1329
-402
No files matched your search
+66
-2
@@ -37,11 +37,13 @@ const http = require("http");
|
|||||||
const PORT = process.env.PORT || 4000;
|
const PORT = process.env.PORT || 4000;
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-" + crypto.randomBytes(16).toString("hex");
|
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 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");
|
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 });
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
/* ─────────── Database ─────────── */
|
/* ─────────── 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("journal_mode = WAL");
|
||||||
db.pragma("foreign_keys = ON");
|
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 } });
|
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 ─── */
|
/* ─── Notebook routes ─── */
|
||||||
app.get("/api/notebooks", authMiddleware, (req, res) => {
|
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);
|
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);
|
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) => {
|
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);
|
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" });
|
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 });
|
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) => {
|
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);
|
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);
|
broadcastToUser(req.userId, { type: "note_deleted", data: { id: req.params.id } }, req.deviceId);
|
||||||
res.json({ success: true });
|
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 ─── */
|
/* ─── Drawing data route ─── */
|
||||||
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
app.put("/api/notes/:id/drawing", authMiddleware, (req, res) => {
|
||||||
const { drawingData } = req.body;
|
const { drawingData } = req.body;
|
||||||
@@ -472,5 +536,5 @@ server.listen(PORT, () => {
|
|||||||
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
|
console.log(`\n ✏️ InkNote Server running on http://localhost:${PORT}`);
|
||||||
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
|
console.log(` 📡 WebSocket available at ws://localhost:${PORT}/ws`);
|
||||||
console.log(` 🔒 JWT Secret: ${JWT_SECRET.slice(0, 12)}...`);
|
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
@@ -2,13 +2,34 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<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" />
|
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" />
|
||||||
<title>InkNote</title>
|
<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>" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="splash">
|
||||||
|
<div class="logo">✏️</div>
|
||||||
|
<div class="text">InkNote</div>
|
||||||
|
</div>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+39
-1
@@ -2,8 +2,46 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import NoteApp from './NoteApp.jsx'
|
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(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<NoteApp />
|
<NoteApp />
|
||||||
</React.StrictMode>
|
</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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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; });
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
Reference in new issue
Block a user