diff --git a/electron/main.cjs b/electron/main.cjs index 39e9733..cf57ed3 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1,15 +1,10 @@ -const { app, BrowserWindow, ipcMain, safeStorage, shell } = require('electron') +const { app, BrowserWindow, ipcMain, safeStorage } = require('electron') const fs = require('fs') const path = require('path') const isDev = !app.isPackaged const credentialFileName = 'credentials.enc' -app.setName('Nextcloud Notes') - -const gotLock = app.requestSingleInstanceLock() -if (!gotLock) app.exit(0) - function userDataPath(fileName) { return path.join(app.getPath('userData'), fileName) } @@ -41,27 +36,12 @@ function readCredentials() { } } -function publicAccount(credentials) { - if (!credentials) return null - return { baseUrl: credentials.baseUrl, username: credentials.username } -} - -function getStoredCredentials() { - const credentials = readCredentials() - if (!credentials) throw new Error('Sign in before syncing notes') - return credentials -} - function writeCredentials(credentials) { if (!safeStorage.isEncryptionAvailable()) { - throw new Error('macOS Keychain is locked or unavailable. Unlock Keychain and try signing in again.') - } - try { - const encrypted = safeStorage.encryptString(JSON.stringify(credentials)) - fs.writeFileSync(userDataPath(credentialFileName), encrypted) - } catch (err) { - throw new Error(`Could not save credentials to secure storage: ${err.message || err}`) + throw new Error('Secure credential storage is not available on this system. Unlock macOS Keychain and try again.') } + const encrypted = safeStorage.encryptString(JSON.stringify(credentials)) + fs.writeFileSync(userDataPath(credentialFileName), encrypted) } function clearCredentials() { @@ -84,18 +64,11 @@ async function request(credentials, method, apiPath, { query, body, etag } = {}) if (body !== undefined) headers['Content-Type'] = 'application/json' if (etag) headers['If-Match'] = `"${etag}"` - let response - try { - response = await fetch(url, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - signal: AbortSignal.timeout(30000), - }) - } catch (err) { - if (err.name === 'TimeoutError') throw new Error('Nextcloud request timed out') - throw err - } + const response = await fetch(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) if (!response.ok) { const text = await response.text().catch(() => '') @@ -111,31 +84,6 @@ async function request(credentials, method, apiPath, { query, body, etag } = {}) } } -function expectNotePayload(note) { - if (!note || typeof note !== 'object') throw new Error('Note payload is required') - return { - title: String(note.title || ''), - content: String(note.content || ''), - category: String(note.category || ''), - } -} - -function registerHandler(channel, handler) { - ipcMain.handle(channel, async (_event, payload) => { - try { - return { ok: true, data: await handler(payload || {}) } - } catch (err) { - return { - ok: false, - error: { - message: err.message || 'Request failed', - status: err.status || null, - }, - } - } - }) -} - async function syncNotes(credentials, pruneBefore) { let cursor = '' const changed = [] @@ -166,7 +114,6 @@ async function syncNotes(credentials, pruneBefore) { } function createWindow() { - const appIndex = path.join(__dirname, '..', 'dist', 'index.html') const window = new BrowserWindow({ width: 1180, height: 780, @@ -178,25 +125,9 @@ function createWindow() { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, - sandbox: true, - webSecurity: true, }, }) - window.webContents.setWindowOpenHandler(({ url }) => { - if (/^https?:\/\//i.test(url)) shell.openExternal(url) - return { action: 'deny' } - }) - - window.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => { - callback(false) - }) - - window.webContents.on('will-navigate', (event, url) => { - const allowedUrl = isDev ? 'http://127.0.0.1:5173/' : `file://${appIndex}` - if (url !== allowedUrl) event.preventDefault() - }) - window.webContents.on('render-process-gone', (_event, details) => { console.error('Renderer process gone:', details.reason, details.exitCode) }) @@ -208,7 +139,7 @@ function createWindow() { if (isDev) { window.loadURL('http://127.0.0.1:5173') } else { - window.loadFile(appIndex) + window.loadFile(path.join(__dirname, '..', 'dist', 'index.html')) } } @@ -219,19 +150,12 @@ app.whenReady().then(() => { }) }) -app.on('second-instance', () => { - const [window] = BrowserWindow.getAllWindows() - if (!window) return - if (window.isMinimized()) window.restore() - window.focus() -}) - app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) -registerHandler('credentials:get', () => publicAccount(readCredentials())) -registerHandler('credentials:save', async credentials => { +ipcMain.handle('credentials:get', () => readCredentials()) +ipcMain.handle('credentials:save', async (_event, credentials) => { const normalized = { baseUrl: normalizeBaseUrl(credentials.baseUrl), username: String(credentials.username || '').trim(), @@ -240,24 +164,22 @@ registerHandler('credentials:save', async credentials => { if (!normalized.username || !normalized.password) throw new Error('Username and app password are required') await request(normalized, 'GET', '/settings') writeCredentials(normalized) - return publicAccount(normalized) + return { baseUrl: normalized.baseUrl, username: normalized.username } }) -registerHandler('credentials:clear', () => { +ipcMain.handle('credentials:clear', () => { clearCredentials() return true }) -registerHandler('notes:sync', ({ pruneBefore }) => syncNotes(getStoredCredentials(), pruneBefore)) -registerHandler('notes:create', async ({ note }) => { - const { data } = await request(getStoredCredentials(), 'POST', '/notes', { body: expectNotePayload(note) }) +ipcMain.handle('notes:sync', (_event, { credentials, pruneBefore }) => syncNotes(credentials, pruneBefore)) +ipcMain.handle('notes:create', async (_event, { credentials, note }) => { + const { data } = await request(credentials, 'POST', '/notes', { body: note }) return Array.isArray(data) ? data[0] : data }) -registerHandler('notes:update', async ({ id, etag, note }) => { - if (!Number.isInteger(id)) throw new Error('Valid note ID is required') - const { data } = await request(getStoredCredentials(), 'PUT', `/notes/${id}`, { body: expectNotePayload(note), etag }) +ipcMain.handle('notes:update', async (_event, { credentials, id, etag, note }) => { + const { data } = await request(credentials, 'PUT', `/notes/${id}`, { body: note, etag }) return Array.isArray(data) ? data[0] : data }) -registerHandler('notes:delete', async ({ id }) => { - if (!Number.isInteger(id)) throw new Error('Valid note ID is required') - const { data } = await request(getStoredCredentials(), 'DELETE', `/notes/${id}`) +ipcMain.handle('notes:delete', async (_event, { credentials, id }) => { + const { data } = await request(credentials, 'DELETE', `/notes/${id}`) return data || { deleted: true } }) diff --git a/electron/preload.cjs b/electron/preload.cjs index f5fb152..93e0003 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -1,19 +1,11 @@ const { contextBridge, ipcRenderer } = require('electron') -async function invoke(channel, payload) { - const result = await ipcRenderer.invoke(channel, payload) - if (result?.ok) return result.data - const error = new Error(result?.error?.message || 'Request failed') - error.status = result?.error?.status || null - throw error -} - contextBridge.exposeInMainWorld('notesBridge', { - getAccount: () => invoke('credentials:get'), - saveCredentials: credentials => invoke('credentials:save', credentials), - clearCredentials: () => invoke('credentials:clear'), - syncNotes: payload => invoke('notes:sync', payload), - createNote: payload => invoke('notes:create', payload), - updateNote: payload => invoke('notes:update', payload), - deleteNote: payload => invoke('notes:delete', payload), + getCredentials: () => ipcRenderer.invoke('credentials:get'), + saveCredentials: credentials => ipcRenderer.invoke('credentials:save', credentials), + clearCredentials: () => ipcRenderer.invoke('credentials:clear'), + syncNotes: payload => ipcRenderer.invoke('notes:sync', payload), + createNote: payload => ipcRenderer.invoke('notes:create', payload), + updateNote: payload => ipcRenderer.invoke('notes:update', payload), + deleteNote: payload => ipcRenderer.invoke('notes:delete', payload), }) diff --git a/index.html b/index.html index 8f777b9..8adebba 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,6 @@ - Nextcloud Notes diff --git a/package-lock.json b/package-lock.json index 78cafc4..4532971 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,22 @@ { "name": "nextcloud-notes-desktop", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nextcloud-notes-desktop", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1" }, "devDependencies": { "@vitejs/plugin-react": "^6.0.2", "concurrently": "^9.2.1", - "electron": "^42.0.1", + "electron": "^39.8.10", "electron-builder": "^26.8.1", "vite": "^8.0.13", "wait-on": "^9.0.10" @@ -150,48 +150,35 @@ } }, "node_modules/@electron/get": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", - "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", "progress": "^2.0.3", - "semver": "^7.6.3", + "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=22.12.0" + "node": ">=12" }, "optionalDependencies": { - "undici": "^7.24.4" + "global-agent": "^3.0.0" } }, - "node_modules/@electron/get/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@electron/get/node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=20.18.1" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/@electron/notarize": { @@ -2453,22 +2440,22 @@ } }, "node_modules/electron": { - "version": "42.0.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.0.1.tgz", - "integrity": "sha512-d8HnycE970DGESe91Nj30eonFBUcAI9EZ1TwUGJVzSAnJZdh0BkFEinAXjdklvDYst+bVDc8HsksCuqVLrnqdg==", + "version": "39.8.10", + "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.10.tgz", + "integrity": "sha512-zbYtGPYUI7PzqLAzkk21Rk6j67WN0hxn0Mq/njErZo1d0HSf33is4f8ICI5fMLy5vYe0JtCtM5sYunNOaochSQ==", "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", + "@electron/get": "^2.0.0", + "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { - "electron": "cli.js", - "install-electron": "install.js" + "electron": "cli.js" }, "engines": { - "node": ">= 22.12.0" + "node": ">= 12.20.55" } }, "node_modules/electron-builder": { @@ -2641,6 +2628,23 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2930,6 +2934,21 @@ "node": ">= 6" } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3553,6 +3572,12 @@ "node": ">= 20" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3906,6 +3931,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -5361,24 +5398,28 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^18.3.1" } }, "node_modules/react-markdown": { @@ -5651,10 +5692,13 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } }, "node_modules/semver": { "version": "7.8.0", diff --git a/package.json b/package.json index 826cc22..8ba0ee2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nextcloud-notes-desktop", - "version": "0.1.3", + "version": "0.1.4", "private": true, "description": "Fast cross-platform desktop client for Nextcloud Notes", "author": { @@ -20,8 +20,8 @@ "dist:linux": "npm run build && electron-builder --linux" }, "dependencies": { - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1" }, @@ -31,7 +31,7 @@ "devDependencies": { "@vitejs/plugin-react": "^6.0.2", "concurrently": "^9.2.1", - "electron": "^42.0.1", + "electron": "^39.8.10", "electron-builder": "^26.8.1", "vite": "^8.0.13", "wait-on": "^9.0.10" diff --git a/src/main.jsx b/src/main.jsx index 3b4608f..aa54c51 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -13,23 +13,14 @@ import { } from './db.js' import './styles.css' -const NEW_NOTE_ID = 'new' - -function nowSeconds() { - return Math.floor(Date.now() / 1000) -} - -function emptyNote() { - return { - id: NEW_NOTE_ID, - title: 'Untitled note', - content: '# Untitled note\n', - category: '', - modified: nowSeconds(), - favorite: false, - etag: '', - readonly: false, - } +const NEW_NOTE = { + id: 'new', + title: 'Untitled note', + content: '', + category: '', + modified: 0, + favorite: false, + etag: '', } function formatDate(timestamp) { @@ -48,60 +39,13 @@ function normalizeNote(note) { title: note.title || deriveTitle(note.content || ''), content: note.content || '', category: note.category || '', - modified: Number(note.modified || nowSeconds()), + modified: Number(note.modified || Math.floor(Date.now() / 1000)), favorite: Boolean(note.favorite), etag: note.etag || '', readonly: Boolean(note.readonly), } } -function Login({ onLogin }) { - const [baseUrl, setBaseUrl] = React.useState('https://cloud.danvics.com') - const [username, setUsername] = React.useState('') - const [password, setPassword] = React.useState('') - const [error, setError] = React.useState('') - const [busy, setBusy] = React.useState(false) - - async function submit(event) { - event.preventDefault() - setBusy(true) - setError('') - try { - const account = await window.notesBridge.saveCredentials({ baseUrl, username, password }) - setPassword('') - onLogin(account) - } catch (err) { - setError(err.message || 'Could not connect to Nextcloud Notes') - } finally { - setBusy(false) - } - } - - return ( -
-
-
N
-

Nextcloud Notes

-

Connect with a Nextcloud app password. The password stays in the Electron main process after login.

- - - - {error &&
{error}
} - -
-
- ) -} - class ErrorBoundary extends React.Component { constructor(props) { super(props) @@ -123,7 +67,7 @@ class ErrorBoundary extends React.Component {
!

Nextcloud Notes hit an error

-

The app did not start cleanly. This message replaces the blank screen so the issue can be diagnosed.

+

The app did not start cleanly. This replaces the blank screen so the issue can be diagnosed.

{this.state.error.message || String(this.state.error)}
@@ -133,8 +77,54 @@ class ErrorBoundary extends React.Component { } } +function Login({ onLogin }) { + const [baseUrl, setBaseUrl] = React.useState('https://cloud.danvics.com') + const [username, setUsername] = React.useState('') + const [password, setPassword] = React.useState('') + const [error, setError] = React.useState('') + const [busy, setBusy] = React.useState(false) + + async function submit(event) { + event.preventDefault() + setBusy(true) + setError('') + try { + const saved = await window.notesBridge.saveCredentials({ baseUrl, username, password }) + onLogin({ ...saved, password }) + } catch (err) { + setError(err.message || 'Could not connect to Nextcloud Notes') + } finally { + setBusy(false) + } + } + + return ( +
+
+
N
+

Nextcloud Notes

+

Connect with a Nextcloud app password. Notes load locally first, then sync incrementally.

+ + + + {error &&
{error}
} + +
+
+ ) +} + function App() { - const [account, setAccount] = React.useState(null) + const [credentials, setCredentials] = React.useState(null) const [notes, setNotes] = React.useState([]) const [selectedId, setSelectedId] = React.useState(null) const [query, setQuery] = React.useState('') @@ -143,75 +133,56 @@ function App() { const [syncStatus, setSyncStatus] = React.useState('Loading local cache...') const [error, setError] = React.useState('') const [draft, setDraft] = React.useState(null) - const deferredQuery = React.useDeferredValue(query) - const dirtyRef = React.useRef(false) - const draftRef = React.useRef(null) const saveTimer = React.useRef(null) - const saveVersion = React.useRef(0) - const saveInFlight = React.useRef(false) - const saveQueued = React.useRef(false) const syncing = React.useRef(false) const selected = draft || notes.find(note => note.id === selectedId) || null const categories = React.useMemo(() => Array.from(new Set(notes.map(note => note.category || 'Uncategorized'))).sort(), [notes]) - const filteredNotes = React.useMemo(() => { - const normalizedQuery = deferredQuery.trim().toLowerCase() - return notes.filter(note => { - const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase() - const matchesQuery = !normalizedQuery || text.includes(normalizedQuery) - const normalizedCategory = note.category || 'Uncategorized' - const matchesCategory = category === 'all' || normalizedCategory === category - return matchesQuery && matchesCategory - }) - }, [notes, deferredQuery, category]) - - React.useEffect(() => { - draftRef.current = draft - }, [draft]) + const filteredNotes = notes.filter(note => { + const text = `${note.title}\n${note.content}\n${note.category}`.toLowerCase() + const matchesQuery = !query || text.includes(query.toLowerCase()) + const normalizedCategory = note.category || 'Uncategorized' + const matchesCategory = category === 'all' || normalizedCategory === category + return matchesQuery && matchesCategory + }) React.useEffect(() => () => clearTimeout(saveTimer.current), []) React.useEffect(() => { async function boot() { const cached = await getAllCachedNotes() - const savedAccount = await window.notesBridge.getAccount() setNotes(cached) setSelectedId(cached[0]?.id || null) - if (savedAccount) setAccount(savedAccount) - setSyncStatus(savedAccount ? 'Ready to sync' : 'Sign in to sync') + const savedCredentials = await window.notesBridge.getCredentials() + if (savedCredentials) setCredentials(savedCredentials) + setSyncStatus(savedCredentials ? 'Ready to sync' : 'Sign in to sync') } boot().catch(err => setError(err.message || 'Startup failed')) }, []) React.useEffect(() => { - if (account) syncNow(false) - }, [account]) + if (credentials) syncNow(false) + }, [credentials]) React.useEffect(() => { - if (dirtyRef.current) return - setDraft(notes.find(note => note.id === selectedId) || null) + const next = notes.find(note => note.id === selectedId) || null + setDraft(next) }, [notes, selectedId]) - async function refreshNotes() { - const cached = await getAllCachedNotes() - setNotes(cached) - return cached - } - async function syncNow(forceFull = false) { - if (!account || syncing.current) return - await flushSave() + if (!credentials || syncing.current) return syncing.current = true setError('') setSyncStatus('Syncing...') try { const pruneBefore = forceFull ? null : await getMeta('lastSync', null) - const result = await window.notesBridge.syncNotes({ pruneBefore }) + const result = await window.notesBridge.syncNotes({ credentials, pruneBefore }) const changed = result.changed.map(normalizeNote) await upsertCachedNotes(changed) const deletedIds = await pruneDeletedNotes(result.serverIds) - const cached = await refreshNotes() + const cached = await getAllCachedNotes() await setMeta('lastSync', result.syncedAt) + setNotes(cached) if (!selectedId || deletedIds.includes(selectedId)) setSelectedId(cached[0]?.id || null) setSyncStatus(`Synced ${formatDate(result.syncedAt)}`) } catch (err) { @@ -222,91 +193,57 @@ function App() { } } - function updateDraft(patch, { autosave = true } = {}) { - const next = { ...(draftRef.current || emptyNote()), ...patch, modified: nowSeconds() } - dirtyRef.current = true - saveVersion.current += 1 - setDraft(next) - if (autosave) scheduleSave(next, saveVersion.current) + function updateDraft(patch) { + setDraft(current => ({ ...(current || NEW_NOTE), ...patch, modified: Math.floor(Date.now() / 1000) })) } - async function saveDraft(note = draftRef.current, version = saveVersion.current) { - if (!account || !note || note.readonly) return + async function saveDraft(note = draft) { + if (!credentials || !note) return clearTimeout(saveTimer.current) - if (saveInFlight.current) { - saveQueued.current = true - return - } - saveInFlight.current = true const payload = { title: note.title || deriveTitle(note.content || ''), content: note.content || '', category: note.category || '' } - setError('') setSyncStatus('Saving...') try { - const saved = note.id === NEW_NOTE_ID - ? await window.notesBridge.createNote({ note: payload }) - : await window.notesBridge.updateNote({ id: note.id, etag: note.etag, note: payload }) + const saved = note.id === 'new' + ? await window.notesBridge.createNote({ credentials, note: payload }) + : await window.notesBridge.updateNote({ credentials, id: note.id, etag: note.etag, note: payload }) const normalized = normalizeNote({ ...note, ...payload, ...saved }) await upsertCachedNotes([normalized]) - await refreshNotes() - if (version === saveVersion.current) { - dirtyRef.current = false - setSelectedId(normalized.id) - setDraft(normalized) - } else if (note.id === NEW_NOTE_ID && draftRef.current?.id === NEW_NOTE_ID) { - const migratedDraft = { ...draftRef.current, id: normalized.id, etag: normalized.etag } - setSelectedId(normalized.id) - setDraft(migratedDraft) - draftRef.current = migratedDraft - } + setNotes(await getAllCachedNotes()) + setSelectedId(normalized.id) + setDraft(normalized) setSyncStatus(`Saved ${formatDate(normalized.modified)}`) } catch (err) { - if (err.status === 412) setError('This note changed on the server. Run sync before saving again to avoid overwriting it.') + if (err.status === 412) setError('This note changed on the server. Sync before saving again to avoid overwriting it.') else setError(err.message || 'Save failed') setSyncStatus('Save failed') - } finally { - saveInFlight.current = false - if (saveQueued.current) { - saveQueued.current = false - await saveDraft(draftRef.current, saveVersion.current) - } } } - function scheduleSave(nextDraft, version) { + function scheduleSave(nextDraft) { clearTimeout(saveTimer.current) - saveTimer.current = setTimeout(() => saveDraft(nextDraft, version), 700) + saveTimer.current = setTimeout(() => saveDraft(nextDraft), 650) } - async function flushSave() { - clearTimeout(saveTimer.current) - if (dirtyRef.current) await saveDraft(draftRef.current, saveVersion.current) - } - - async function selectNote(id) { - await flushSave() - dirtyRef.current = false - setSelectedId(id) + function handleContentChange(content) { + const nextDraft = { ...(draft || NEW_NOTE), content, title: deriveTitle(content), modified: Math.floor(Date.now() / 1000) } + setDraft(nextDraft) + scheduleSave(nextDraft) } async function createNewNote() { - await flushSave() - const note = emptyNote() - dirtyRef.current = true - saveVersion.current += 1 - setSelectedId(NEW_NOTE_ID) + const note = { ...NEW_NOTE, id: 'new', content: '# Untitled note\n', modified: Math.floor(Date.now() / 1000) } + setSelectedId('new') setDraft(note) - scheduleSave(note, saveVersion.current) } async function deleteSelected() { - if (!account || !selected || selected.id === NEW_NOTE_ID) return - clearTimeout(saveTimer.current) + if (!credentials || !selected || selected.id === 'new') return setSyncStatus('Deleting...') try { - await window.notesBridge.deleteNote({ id: selected.id }) + await window.notesBridge.deleteNote({ credentials, id: selected.id }) await deleteCachedNote(selected.id) - dirtyRef.current = false - const cached = await refreshNotes() + const cached = await getAllCachedNotes() + setNotes(cached) setSelectedId(cached[0]?.id || null) setSyncStatus('Deleted') } catch (err) { @@ -319,16 +256,13 @@ function App() { clearTimeout(saveTimer.current) await window.notesBridge.clearCredentials() await clearCache() - dirtyRef.current = false - setAccount(null) + setCredentials(null) setNotes([]) setSelectedId(null) setDraft(null) - setError('') - setSyncStatus('Sign in to sync') } - if (!account) return + if (!credentials) return return (
@@ -340,7 +274,6 @@ function App() { -
{account.username} · {account.baseUrl}
setQuery(event.target.value)} placeholder="Search notes" />
@@ -348,7 +281,7 @@ function App() {
{filteredNotes.map(note => ( - @@ -362,24 +295,24 @@ function App() { className="title-input" value={selected?.title || ''} onChange={event => updateDraft({ title: event.target.value })} - onBlur={flushSave} + onBlur={() => saveDraft()} placeholder="Select or create a note" - disabled={!selected || selected.readonly} + disabled={!selected} /> updateDraft({ category: event.target.value })} - onBlur={flushSave} + onBlur={() => saveDraft()} placeholder="Category" - disabled={!selected || selected.readonly} + disabled={!selected} />
- +
@@ -390,9 +323,8 @@ function App() {