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 (
-
-
-
- )
-}
-
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 (
+
+
+
+ )
+}
+
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 => (
-
syncNow(true)}>Full sync
syncNow(false)}>Sync
setMode(mode === 'edit' ? 'preview' : 'edit')}>{mode === 'edit' ? 'Preview' : 'Edit'}
- Delete
+ Delete
Logout
@@ -390,9 +323,8 @@ function App() {