12 Commits
main ... dev

Author SHA1 Message Date
drelich
3b57fcea75 Add Windows ARM64 packaging support 2026-04-15 16:37:41 +02:00
drelich
e7fbbf710d Add Linux build configuration and package metadata 2026-04-09 13:58:30 +02:00
drelich
995696fea3 Refactor WebDAV path construction and fix note move operations
- Replace Node.js fetch with Electron net.request for better session handling
- Extract WebDAV path building into reusable private methods with proper URL encoding
- Add helper methods for category path encoding and attachment path construction
- Fix note move operations to use remote category/filename from saved snapshots
- Add ensureCategoryDirectoryExists to handle nested category creation
- Only move/rename attachment folders when note has any
2026-04-06 17:40:57 +02:00
drelich
6bc67a3118 Add interactive task lists and table insertion to markdown editor
- Enable task list checkbox toggling in preview mode with live content updates
- Add task list and table insertion buttons to InsertToolbar
- Implement smart block snippet insertion with automatic newline handling
- Add horizontal scroll wrapper for wide tables in preview
- Fix editor scroll position preservation during content updates
- Use useLayoutEffect to prevent scroll jumps when textarea auto-resizes
- Update task list styling
2026-04-06 16:15:43 +02:00
drelich
aba090a8ad Implement optimistic note updates with debounced autosave
- Replace immediate server saves with local-first optimistic updates
- Add 1-second debounced autosave with per-note save controllers
- Track note state with draftId instead of server-assigned id
- Implement save queue with in-flight request tracking and revision numbers
- Add pendingSave/isSaving/saveError flags to note state
- Store saved snapshots to detect content changes
- Flush pending saves on logout, category operations, and window
2026-04-06 10:10:17 +02:00
drelich
e21e443a59 Fix PDF export font embedding and improve sync reliability
- Replace data URL loading with temporary HTML file to avoid URL length limits
- Embed font files as data URLs in print document CSS for offline rendering
- Add font asset registry for Merriweather, Crimson Pro, Roboto Serif, and Average
- Implement font file caching and blob-to-data-URL conversion
- Clean up temporary HTML file after PDF generation
- Fix sync to refresh notes after favorite status sync completes
2026-04-06 09:46:26 +02:00
drelich
6e970f37ea Add electron-builder packaging configuration
- Install electron-builder as dev dependency
- Configure build targets for macOS (dmg, zip), Windows (nsis, zip), and Linux (AppImage, deb)
- Add npm scripts for building unpacked app (dist:dir) and macOS packages (dist:mac)
- Set base path to "./" in production builds for proper asset loading in packaged app
- Fix index.html asset paths to use relative paths (./ prefix)
- Update README to document packaging commands and note unsigned/unnotarized status
2026-04-05 22:39:04 +02:00
drelich
06b4cc4514 refactor: migrate PDF export to desktop runtime abstraction
- Extract desktop-specific logic into src/services/desktop.ts
- Add runtime detection for Electron vs Tauri environments
- Simplify NoteEditor by delegating PDF export to desktop service
- Update printExport.ts to remove unused storage helpers
- Add Electron main process files in electron/ directory
- Update vite config and types for Electron integration
- Update .gitignore and package.json for Electron workflow
2026-04-05 22:13:06 +02:00
drelich
e93ce566d3 docs: rewrite README for Electron migration
Update documentation to reflect the transition from Tauri to Electron:
- Document Electron as primary desktop runtime
- Update development workflow and npm scripts
- Add project structure and security sections
- Note packaging status (Electron Forge needed for releases)
- Keep reference to legacy Tauri script
2026-04-05 22:11:30 +02:00
drelich
42eec85826 Implement native print dialog for PDF export
- Replaced jsPDF client-side generation with Tauri's native print dialog
- Created PrintView component that renders note content in a print-optimized layout
- Added print-export window capability with webview creation and print permissions
- Implemented event-based communication between main window and print window
- Moved shared print/export utilities to printExport.ts (title extraction, filename sanitization)
- Changed export button icon from download
2026-04-05 21:30:31 +02:00
drelich
f5b448808d Implement task list rendering in preview mode
- Enabled GitHub Flavored Markdown (GFM) in marked.js for task list support
- Added custom CSS styling for task lists:
  - Hide bullet points, show only checkboxes
  - Increased checkbox size to 20px for better visibility
  - Green accent color for checked items
  - Flexbox layout for proper multi-line text alignment
- Task lists render as read-only checkboxes in preview mode
2026-03-31 10:26:38 +02:00
drelich
21cd9ced2f Add favorite notes sorting to display starred notes at top
- Favorited/starred notes now appear at the top of the notes list
- Within each group (favorites/non-favorites), notes are sorted by modified date (newest first)
- Matches mobile app behavior
2026-03-31 10:06:02 +02:00
24 changed files with 7940 additions and 589 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
release
*.local *.local
# Editor directories and files # Editor directories and files

172
README.md
View File

@@ -1,85 +1,147 @@
![nextcloud-notes-tauri.png](src/assets/nextcloud-notes-tauri.png) ![nextcloud-notes-tauri.png](src/assets/nextcloud-notes-tauri.png)
# Tauri + React + Typescript # Nextcloud Notes Desktop
# Nextcloud Notes - Cross-Platform Desktop App A desktop client for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with React, TypeScript, Vite, and Electron.
A modern, cross-platform desktop application for [Nextcloud Notes](https://apps.nextcloud.com/apps/notes) built with Tauri + React + TypeScript. This project started life as a Tauri app and has now been migrated to Electron for desktop runtime support, PDF export, and simpler cross-platform desktop behavior during development.
## Features ## What It Does
- **Cross-platform**: macOS, Linux, Windows - Sign in to a Nextcloud server with Notes enabled
- **Lightweight**: ~600KB binary (vs 150MB+ Electron) - Sync notes from WebDAV and favorite state from the Notes API
- **Modern UI**: React + TailwindCSS - Create, edit, move, rename, and delete notes
- **Full sync**: Create, edit, delete, favorite notes - Organize notes into categories, including nested categories
- **Search & filter**: Find notes quickly, filter by favorites - Mark notes as favorites
- **Auto-save**: Changes save automatically after 1.5s - Cache notes locally for faster startup and offline viewing
- **Secure**: Credentials stored in system keychain (localStorage for now) - Upload and render note attachments
- **Background sync**: Auto-sync every 5 minutes - Preview Markdown while editing
- Export notes to PDF
- Use a focus mode for distraction-free editing
## Prerequisites ## Current Runtime
- **Rust**: Install from https://rustup.rs/ - Primary desktop runtime: Electron
- **Node.js**: v18+ recommended - Frontend: React 19 + TypeScript + Vite
- **Nextcloud instance** with Notes app enabled - Styling: Tailwind CSS
- Local cache: IndexedDB
- Nextcloud integration:
- WebDAV for note files, folders, attachments, and category color storage
- Notes API for favorite metadata
Some Tauri-related code and dependencies are still present in the repository, mainly because parts of the app were built during the earlier Tauri phase. The Electron path is the actively used desktop runtime.
## Requirements
- Node.js 18 or newer
- npm
- A Nextcloud instance with the Notes app enabled
## Install
```bash
npm install
```
## Development ## Development
Run the Electron app with the Vite dev server:
```bash ```bash
# Install dependencies npm run dev:desktop
npm install ```
# Run in development mode Useful scripts:
npm run tauri dev
# Build for production ```bash
npm run tauri build npm run dev:renderer # Vite frontend only
npm run dev:electron # Electron only, expects renderer on port 1420
npm run build # TypeScript + Vite production build
npm run desktop # Run Electron against the built dist/
npm run dist:dir # Build an unpacked Electron app in release/
npm run dist:mac # Build macOS .dmg and .zip packages in release/
npm run dist:win # Build Windows installer + zip for the current Windows architecture
npm run dist:win:arm64 # Build Windows ARM64 installer + zip in release/
```
## Production-Like Local Run
Build the frontend, then start Electron against the generated `dist/` files:
```bash
npm run build
npm run desktop
``` ```
## First Launch ## First Launch
1. Enter your Nextcloud server URL (e.g., `https://cloud.example.com`) 1. Enter your Nextcloud server URL, for example `https://cloud.example.com`
2. Enter your username 2. Enter your username
3. Enter your password or **App Password** (recommended) 3. Enter your password or, preferably, a Nextcloud app password
- Generate at: Settings → Security → Devices & Sessions in Nextcloud 4. Wait for the initial sync to finish
4. Click **Connect**
## Building for Distribution Using an app password is strongly recommended.
### macOS ## Notable Behavior
```bash
npm run tauri build ### Sync model
# Output: src-tauri/target/release/bundle/macos/
- Note files are synced through WebDAV
- Favorite status is synced through the Nextcloud Notes API
- Notes are cached locally and can still be viewed when offline
- Background sync runs periodically while the app is open
### PDF export
- In Electron, the toolbar export action saves a PDF directly to disk
- Embedded note images are resolved before export when possible
### Category colors
- Category color preferences are stored in `.category-colors.json` inside your Nextcloud Notes WebDAV folder
## Project Structure
```text
electron/ Electron main process and preload bridge
src/api/ Nextcloud API and WebDAV client logic
src/components/ React UI
src/db/ Local IndexedDB cache
src/services/ Desktop runtime helpers and sync logic
src/printExport.ts Shared print/PDF document generation
``` ```
### Linux ## Security Notes
```bash
npm run tauri build
# Output: src-tauri/target/release/bundle/appimage/ or .deb
```
### Windows - Electron runs with `contextIsolation: true`
```bash - `nodeIntegration` is disabled in renderer windows
npm run tauri build - Network requests that need desktop privileges are routed through Electron IPC instead of renderer-side browser fetch
# Output: src-tauri/target/release/bundle/msi/
```
## Tech Stack Current limitation:
- **Tauri**: Rust-based native wrapper (~600KB) - Login credentials are still persisted in `localStorage`
- **React 18**: UI framework
- **TypeScript**: Type safety
- **TailwindCSS**: Utility-first styling
- **Vite**: Fast build tool
## Advantages over Native Swift App That is convenient for development, but it is not the right long-term storage mechanism for a production desktop app. A future improvement should move credentials into the OS keychain or another secure secret store.
-**Cross-platform**: One codebase for macOS, Linux, Windows ## Packaging
-**No SwiftUI state issues**: React's state management is mature
-**Smaller binary**: Tauri is much lighter than Electron
-**Easier to maintain**: Web technologies vs platform-specific code
-**No Xcode required**: Build on any platform
## Recommended IDE Setup Electron packaging is set up with `electron-builder`.
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) Current packaging commands:
- `npm run dist:dir` creates an unpacked app bundle in `release/`
- `npm run dist:mac` creates macOS `.dmg` and `.zip` artifacts in `release/`
- `npm run dist:win` creates Windows `.exe` and `.zip` artifacts for the current Windows architecture
- `npm run dist:win:arm64` creates Windows ARM64 `.exe` and `.zip` artifacts in `release/`
The current mac build is unsigned and not notarized, which is fine for local use and testing but not enough for friction-free public distribution through Gatekeeper.
Windows ARM64 packaging has been validated in this repository with Electron Builder on Windows 11 ARM running under Parallels. Linux targets are still configured in `package.json`, but they have not been validated here yet.
## Legacy Tauri Script
There is still a `npm run tauri` script in `package.json`, but the README and current workflow are centered on Electron.
## License
No license file is currently included in this repository.

196
electron/main.cjs Normal file
View File

@@ -0,0 +1,196 @@
const fs = require('node:fs/promises');
const path = require('node:path');
const { app, BrowserWindow, dialog, ipcMain, net } = require('electron');
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
const isDev = Boolean(rendererUrl);
let mainWindow = null;
const waitForPrintDocument = () => `
new Promise((resolve) => {
const pendingImages = Array.from(document.images).filter((image) => !image.complete);
const waitForImages = Promise.all(
pendingImages.map(
(image) =>
new Promise((done) => {
image.addEventListener('load', () => done(), { once: true });
image.addEventListener('error', () => done(), { once: true });
})
)
);
const waitForFonts = document.fonts ? document.fonts.ready.catch(() => undefined) : Promise.resolve();
Promise.all([waitForImages, waitForFonts]).then(() => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
});
`;
function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1300,
height: 800,
minWidth: 800,
minHeight: 600,
show: false,
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
mainWindow.once('ready-to-show', () => {
mainWindow.show();
});
if (isDev) {
void mainWindow.loadURL(rendererUrl);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
void mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
}
app.whenReady().then(() => {
createMainWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
ipcMain.handle('desktop:show-message', async (event, options) => {
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
const typeMap = {
info: 'info',
warning: 'warning',
error: 'error',
};
await dialog.showMessageBox(ownerWindow, {
type: typeMap[options.kind] || 'info',
title: options.title || app.name,
message: options.message,
buttons: ['OK'],
defaultId: 0,
});
});
ipcMain.handle('desktop:http-request', async (_event, payload) => {
const body =
payload.bodyBase64 != null
? Buffer.from(payload.bodyBase64, 'base64')
: payload.bodyText != null
? Buffer.from(payload.bodyText, 'utf8')
: null;
return await new Promise((resolve, reject) => {
const request = net.request({
url: payload.url,
method: payload.method || 'GET',
session: BrowserWindow.getAllWindows()[0]?.webContents.session,
});
for (const [name, value] of Object.entries(payload.headers || {})) {
request.setHeader(name, value);
}
request.on('response', (response) => {
const chunks = [];
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on('end', () => {
const headers = {};
for (const [name, value] of Object.entries(response.headers)) {
headers[name] = Array.isArray(value) ? value.join(', ') : String(value ?? '');
}
const buffer = Buffer.concat(chunks);
resolve({
ok: response.statusCode >= 200 && response.statusCode < 300,
status: response.statusCode,
statusText: response.statusMessage || '',
headers,
bodyBase64: buffer.toString('base64'),
});
});
response.on('error', reject);
});
request.on('error', reject);
if (body && body.length > 0) {
request.write(body);
}
request.end();
});
});
ipcMain.handle('desktop:export-pdf', async (event, payload) => {
const ownerWindow = BrowserWindow.fromWebContents(event.sender) ?? mainWindow;
const saveResult = await dialog.showSaveDialog(ownerWindow, {
title: 'Export PDF',
defaultPath: payload.fileName,
filters: [{ name: 'PDF Document', extensions: ['pdf'] }],
});
if (saveResult.canceled || !saveResult.filePath) {
return { canceled: true };
}
const tempHtmlPath = path.join(
app.getPath('temp'),
`nextcloud-notes-export-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.html`
);
const pdfWindow = new BrowserWindow({
width: 960,
height: 1100,
show: false,
parent: ownerWindow ?? undefined,
webPreferences: {
sandbox: false,
contextIsolation: true,
nodeIntegration: false,
spellcheck: false,
},
});
try {
await fs.writeFile(tempHtmlPath, payload.documentHtml, 'utf8');
await pdfWindow.loadFile(tempHtmlPath);
await pdfWindow.webContents.executeJavaScript(waitForPrintDocument(), true);
const pdfData = await pdfWindow.webContents.printToPDF({
printBackground: true,
preferCSSPageSize: true,
});
await fs.writeFile(saveResult.filePath, pdfData);
return {
canceled: false,
filePath: saveResult.filePath,
};
} finally {
await fs.unlink(tempHtmlPath).catch(() => undefined);
if (!pdfWindow.isDestroyed()) {
pdfWindow.destroy();
}
}
});

8
electron/preload.cjs Normal file
View File

@@ -0,0 +1,8 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronDesktop', {
showMessage: (options) => ipcRenderer.invoke('desktop:show-message', options),
exportPdf: (payload) => ipcRenderer.invoke('desktop:export-pdf', payload),
httpRequest: (payload) => ipcRenderer.invoke('desktop:http-request', payload),
getRuntime: () => 'electron',
});

View File

@@ -2,15 +2,19 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'self'"
/>
<title>Nextcloud Notes</title> <title>Nextcloud Notes</title>
<!-- Local fonts for offline support --> <!-- Local fonts for offline support -->
<link rel="stylesheet" href="/fonts/fonts.css"> <link rel="stylesheet" href="./fonts/fonts.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="./src/main.tsx"></script>
</body> </body>
</html> </html>

5011
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,27 @@
"name": "nextcloud-notes-tauri", "name": "nextcloud-notes-tauri",
"private": true, "private": true,
"version": "0.2.2", "version": "0.2.2",
"description": "Desktop client for Nextcloud Notes built with Electron, React, and TypeScript.",
"homepage": "https://gitea.davidrelich.com/davidrelich/nextcloud-notes-desktop-app",
"author": {
"name": "drelich",
"email": "david.relich@me.com"
},
"type": "module", "type": "module",
"main": "electron/main.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:renderer": "vite",
"dev:electron": "wait-on tcp:1420 && cross-env ELECTRON_RENDERER_URL=http://localhost:1420 electron .",
"dev:desktop": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"gen:win-icon": "node scripts/generate-win-icon.mjs",
"build": "tsc && vite build", "build": "tsc && vite build",
"desktop": "electron .",
"dist:dir": "npm run build && electron-builder --dir",
"dist:mac": "npm run build && electron-builder --mac dmg zip",
"dist:linux": "npm run build && electron-builder --linux AppImage deb",
"dist:win": "npm run gen:win-icon && npm run build && electron-builder --win nsis zip",
"dist:win:arm64": "npm run gen:win-icon && npm run build && electron-builder --win nsis zip --arm64",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri" "tauri": "tauri"
}, },
@@ -34,9 +51,52 @@
"@types/turndown": "^5.0.6", "@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^37.3.1",
"electron-builder": "^26.8.1",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.0.4",
"wait-on": "^8.0.5"
},
"build": {
"appId": "cz.davidrelich.nextcloud-notes-desktop",
"productName": "Nextcloud Notes Desktop",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"package.json"
],
"asar": true,
"npmRebuild": false,
"mac": {
"category": "public.app-category.productivity",
"icon": "src-tauri/icons/icon.icns",
"target": [
"dmg",
"zip"
]
},
"win": {
"icon": "src-tauri/icons/icon.ico",
"target": [
"nsis",
"zip"
]
},
"linux": {
"icon": "src-tauri/icons/128x128@2x.png",
"category": "Office",
"maintainer": "drelich <david.relich@me.com>",
"target": [
"AppImage",
"deb"
]
}
} }
} }

View File

@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, "..");
const sourcePngPath = path.join(projectRoot, "src-tauri", "icons", "128x128@2x.png");
const targetIcoPath = path.join(projectRoot, "src-tauri", "icons", "icon.ico");
const pngBytes = fs.readFileSync(sourcePngPath);
if (pngBytes.length < 24 || pngBytes.toString("ascii", 1, 4) !== "PNG") {
throw new Error(`Expected a PNG file at ${sourcePngPath}`);
}
const width = pngBytes.readUInt32BE(16);
const height = pngBytes.readUInt32BE(20);
if (width !== 256 || height !== 256) {
throw new Error(`Expected a 256x256 PNG, received ${width}x${height}`);
}
const headerSize = 6;
const directoryEntrySize = 16;
const imageOffset = headerSize + directoryEntrySize;
const icoBytes = Buffer.alloc(imageOffset + pngBytes.length);
icoBytes.writeUInt16LE(0, 0);
icoBytes.writeUInt16LE(1, 2);
icoBytes.writeUInt16LE(1, 4);
icoBytes.writeUInt8(0, 6);
icoBytes.writeUInt8(0, 7);
icoBytes.writeUInt8(0, 8);
icoBytes.writeUInt8(0, 9);
icoBytes.writeUInt16LE(1, 10);
icoBytes.writeUInt16LE(32, 12);
icoBytes.writeUInt32LE(pngBytes.length, 14);
icoBytes.writeUInt32LE(imageOffset, 18);
pngBytes.copy(icoBytes, imageOffset);
fs.writeFileSync(targetIcoPath, icoBytes);
console.log(`Generated ${path.relative(projectRoot, targetIcoPath)} from ${path.relative(projectRoot, sourcePngPath)}`);

View File

@@ -2,9 +2,11 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Capability for the main window", "description": "Capability for the main window",
"windows": ["main"], "windows": ["main", "print-export-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:webview:allow-create-webview-window",
"core:webview:allow-print",
"opener:default", "opener:default",
"http:default", "http:default",
{ {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { LoginView } from './components/LoginView'; import { LoginView } from './components/LoginView';
import { NotesList } from './components/NotesList'; import { NotesList } from './components/NotesList';
import { NoteEditor } from './components/NoteEditor'; import { NoteEditor } from './components/NoteEditor';
@@ -9,12 +9,89 @@ import { syncManager, SyncStatus } from './services/syncManager';
import { localDB } from './db/localDB'; import { localDB } from './db/localDB';
import { useOnlineStatus } from './hooks/useOnlineStatus'; import { useOnlineStatus } from './hooks/useOnlineStatus';
import { categoryColorsSync } from './services/categoryColorsSync'; import { categoryColorsSync } from './services/categoryColorsSync';
import { PRINT_EXPORT_QUERY_PARAM } from './printExport';
function App() { const LazyPrintView = lazy(async () => {
const module = await import('./components/PrintView');
return { default: module.PrintView };
});
const AUTOSAVE_DELAY_MS = 1000;
interface SaveController {
timerId: number | null;
revision: number;
inFlight: Promise<void> | null;
inFlightRevision: number;
}
interface FlushSaveOptions {
force?: boolean;
}
const sortNotes = (notes: Note[]) =>
[...notes].sort((a, b) => b.modified - a.modified);
const toStoredNote = (note: Note): Note => ({
...note,
isSaving: false,
});
const getNoteDraftId = (note: Note | null | undefined) => note?.draftId ?? null;
const createDraftId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
};
const getRemoteCategory = (note: Note) => {
if (note.path) {
const pathParts = note.path.split('/');
return pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
}
if (typeof note.id === 'string') {
const idParts = note.id.split('/');
return idParts.length > 1 ? idParts.slice(0, -1).join('/') : '';
}
return note.category;
};
const splitNoteContent = (content: string) => {
const [firstLine = '', ...rest] = content.split('\n');
return {
title: firstLine.replace(/^#+\s*/, '').trim(),
body: rest.join('\n'),
};
};
const canAutosaveLocalNote = (note: Note) => {
if (!note.localOnly) {
return true;
}
const { title } = splitNoteContent(note.content);
return title.length > 0 && note.content.includes('\n');
};
const canForceSaveLocalNote = (note: Note) => {
if (!note.localOnly) {
return true;
}
const { title } = splitNoteContent(note.content);
return title.length > 0;
};
function MainApp() {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [api, setApi] = useState<NextcloudAPI | null>(null); const [api, setApi] = useState<NextcloudAPI | null>(null);
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [selectedNoteId, setSelectedNoteId] = useState<number | string | null>(null); const [selectedNoteDraftId, setSelectedNoteDraftId] = useState<string | null>(null);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [selectedCategory, setSelectedCategory] = useState(''); const [selectedCategory, setSelectedCategory] = useState('');
@@ -24,7 +101,6 @@ function App() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system'); const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light'); const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [editorFont, setEditorFont] = useState('Source Code Pro'); const [editorFont, setEditorFont] = useState('Source Code Pro');
const [editorFontSize, setEditorFontSize] = useState(14); const [editorFontSize, setEditorFontSize] = useState(14);
const [previewFont, setPreviewFont] = useState('Merriweather'); const [previewFont, setPreviewFont] = useState('Merriweather');
@@ -32,6 +108,89 @@ function App() {
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle'); const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
const [pendingSyncCount, setPendingSyncCount] = useState(0); const [pendingSyncCount, setPendingSyncCount] = useState(0);
const isOnline = useOnlineStatus(); const isOnline = useOnlineStatus();
const notesRef = useRef<Note[]>([]);
const saveControllersRef = useRef<Map<string, SaveController>>(new Map());
const savedSnapshotsRef = useRef<Map<string, Note>>(new Map());
const setSortedNotes = (updater: Note[] | ((previous: Note[]) => Note[])) => {
setNotes((previous) => {
const nextNotes = typeof updater === 'function'
? (updater as (previous: Note[]) => Note[])(previous)
: updater;
const sortedNotes = sortNotes(nextNotes);
notesRef.current = sortedNotes;
return sortedNotes;
});
};
const getNoteByDraftId = (draftId: string | null) =>
draftId ? notesRef.current.find(note => note.draftId === draftId) ?? null : null;
const persistNoteToCache = (note: Note) => {
void localDB.saveNote(toStoredNote(note)).catch((error) => {
console.error('Failed to persist note locally:', error);
});
};
const ensureSaveController = (draftId: string) => {
let controller = saveControllersRef.current.get(draftId);
if (!controller) {
controller = {
timerId: null,
revision: 0,
inFlight: null,
inFlightRevision: 0,
};
saveControllersRef.current.set(draftId, controller);
}
return controller;
};
const clearSaveTimer = (draftId: string) => {
const controller = saveControllersRef.current.get(draftId);
if (controller?.timerId) {
window.clearTimeout(controller.timerId);
controller.timerId = null;
}
};
const applyLoadedNotes = (loadedNotes: Note[]) => {
const normalizedNotes = sortNotes(loadedNotes);
const incomingDraftIds = new Set<string>();
normalizedNotes.forEach((note) => {
if (!note.draftId) {
return;
}
incomingDraftIds.add(note.draftId);
if (!note.pendingSave) {
savedSnapshotsRef.current.set(note.draftId, {
...note,
pendingSave: false,
isSaving: false,
saveError: null,
});
}
});
for (const draftId of Array.from(savedSnapshotsRef.current.keys())) {
if (!incomingDraftIds.has(draftId)) {
savedSnapshotsRef.current.delete(draftId);
}
}
notesRef.current = normalizedNotes;
setNotes(normalizedNotes);
setSelectedNoteDraftId((current) => {
if (current && normalizedNotes.some(note => note.draftId === current)) {
return current;
}
return getNoteDraftId(normalizedNotes[0]);
});
};
useEffect(() => { useEffect(() => {
const initApp = async () => { const initApp = async () => {
@@ -113,7 +272,7 @@ function App() {
// Reload notes from cache after background sync completes // Reload notes from cache after background sync completes
// Don't call loadNotes() as it triggers another sync - just reload from cache // Don't call loadNotes() as it triggers another sync - just reload from cache
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await localDB.getAllNotes();
setNotes(cachedNotes.sort((a, b) => b.modified - a.modified)); applyLoadedNotes(cachedNotes);
}); });
}, []); }, []);
@@ -125,13 +284,43 @@ function App() {
} }
}, [api, isLoggedIn]); }, [api, isLoggedIn]);
useEffect(() => {
if (!isLoggedIn || !isOnline) {
return;
}
notesRef.current
.filter(note => note.pendingSave && note.draftId)
.forEach((note) => {
const draftId = note.draftId as string;
const controller = ensureSaveController(draftId);
if (!controller.inFlight && !controller.timerId) {
controller.timerId = window.setTimeout(() => {
controller.timerId = null;
void flushNoteSave(draftId);
}, 0);
}
});
}, [isLoggedIn, isOnline]);
useEffect(() => {
const handleBeforeUnload = () => {
notesRef.current
.filter(note => note.pendingSave && note.draftId)
.forEach((note) => {
clearSaveTimer(note.draftId!);
void flushNoteSave(note.draftId!, { force: true });
});
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
const loadNotes = async () => { const loadNotes = async () => {
try { try {
const loadedNotes = await syncManager.loadNotes(); const loadedNotes = await syncManager.loadNotes();
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified)); applyLoadedNotes(loadedNotes);
if (!selectedNoteId && loadedNotes.length > 0) {
setSelectedNoteId(loadedNotes[0].id);
}
} catch (error) { } catch (error) {
console.error('Failed to load notes:', error); console.error('Failed to load notes:', error);
} }
@@ -139,6 +328,7 @@ function App() {
const syncNotes = async () => { const syncNotes = async () => {
try { try {
await flushAllPendingSaves();
await syncManager.syncWithServer(); await syncManager.syncWithServer();
await loadNotes(); await loadNotes();
} catch (error) { } catch (error) {
@@ -169,8 +359,11 @@ function App() {
categoryColorsSync.setAPI(null); categoryColorsSync.setAPI(null);
setUsername(''); setUsername('');
setNotes([]); setNotes([]);
setSelectedNoteId(null); notesRef.current = [];
setSelectedNoteDraftId(null);
setIsLoggedIn(false); setIsLoggedIn(false);
saveControllersRef.current.clear();
savedSnapshotsRef.current.clear();
}; };
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
@@ -199,25 +392,66 @@ function App() {
}; };
const handleToggleFavorite = async (note: Note, favorite: boolean) => { const handleToggleFavorite = async (note: Note, favorite: boolean) => {
try { const draftId = note.draftId;
await syncManager.updateFavoriteStatus(note, favorite); if (!draftId) {
// Update local state return;
setNotes(prevNotes => }
prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n)
const optimisticNote = {
...note,
favorite,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(currentNote =>
currentNote.draftId === draftId ? optimisticNote : currentNote
)
); );
persistNoteToCache(optimisticNote);
try {
await syncManager.updateFavoriteStatus(optimisticNote, favorite);
const snapshot = savedSnapshotsRef.current.get(draftId);
if (snapshot) {
savedSnapshotsRef.current.set(draftId, {
...snapshot,
favorite,
});
}
} catch (error) { } catch (error) {
console.error('Toggle favorite failed:', error); console.error('Toggle favorite failed:', error);
} }
}; };
const handleCreateNote = async () => { const handleCreateNote = async () => {
try { const draftId = createDraftId();
const note = await syncManager.createNote('New Note', '', selectedCategory); const note: Note = {
setNotes([note, ...notes]); id: `local:${draftId}`,
setSelectedNoteId(note.id); etag: '',
} catch (error) { readonly: false,
console.error('Create note failed:', error); content: '',
} title: 'Untitled',
category: selectedCategory,
favorite: false,
modified: Math.floor(Date.now() / 1000),
draftId,
localOnly: true,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: undefined,
};
savedSnapshotsRef.current.set(draftId, {
...note,
pendingSave: false,
isSaving: false,
saveError: null,
});
setSortedNotes(previousNotes => [note, ...previousNotes]);
persistNoteToCache(note);
setSelectedNoteDraftId(draftId);
}; };
const handleCreateCategory = (name: string) => { const handleCreateCategory = (name: string) => {
@@ -233,7 +467,19 @@ function App() {
for (const note of notesToMove) { for (const note of notesToMove) {
try { try {
const movedNote = await syncManager.moveNote(note, newName); const movedNote = await syncManager.moveNote(note, newName);
setNotes(prevNotes => prevNotes.map(n => n.id === note.id ? movedNote : n)); if (movedNote.draftId) {
savedSnapshotsRef.current.set(movedNote.draftId, {
...movedNote,
pendingSave: false,
isSaving: false,
saveError: null,
});
}
setSortedNotes(previousNotes =>
previousNotes.map(currentNote =>
currentNote.draftId === note.draftId ? movedNote : currentNote
)
);
} catch (error) { } catch (error) {
console.error(`Failed to move note ${note.id}:`, error); console.error(`Failed to move note ${note.id}:`, error);
} }
@@ -250,53 +496,291 @@ function App() {
} }
}; };
const handleUpdateNote = async (updatedNote: Note) => { const persistNoteToServer = async (note: Note) => {
try { if (note.localOnly) {
const originalNote = notes.find(n => n.id === updatedNote.id); const { title, body } = splitNoteContent(note.content);
const createdNote = await syncManager.createNote(title, body, note.category);
return {
...createdNote,
content: note.content,
title,
favorite: note.favorite,
draftId: note.draftId,
localOnly: false,
};
}
// If category changed, use moveNote instead of updateNote const savedSnapshot = note.draftId ? savedSnapshotsRef.current.get(note.draftId) : null;
if (originalNote && originalNote.category !== updatedNote.category) { const remoteReference = savedSnapshot ?? note;
const movedNote = await syncManager.moveNote(originalNote, updatedNote.category); const remoteCategory = getRemoteCategory(remoteReference);
// If content/title also changed, update the moved note
if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) { if (remoteCategory !== note.category) {
const finalNote = await syncManager.updateNote({ const movedNote = await syncManager.moveNote(
{
...note,
id: remoteReference.id,
path: remoteReference.path,
filename: remoteReference.filename,
category: remoteCategory,
},
note.category,
);
return syncManager.updateNote({
...movedNote, ...movedNote,
title: updatedNote.title, draftId: note.draftId,
content: updatedNote.content, title: note.title,
favorite: updatedNote.favorite, content: note.content,
favorite: note.favorite,
localOnly: false,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: note.lastSavedAt,
}); });
setNotes(notes.map(n => n.id === originalNote.id ? finalNote : n.id === movedNote.id ? finalNote : n));
// Update selected note ID if it changed
if (selectedNoteId === originalNote.id && finalNote.id !== originalNote.id) {
setSelectedNoteId(finalNote.id);
} }
} else {
setNotes(notes.map(n => n.id === originalNote.id ? movedNote : n)); return syncManager.updateNote(note);
// Update selected note ID if it changed };
if (selectedNoteId === originalNote.id && movedNote.id !== originalNote.id) {
setSelectedNoteId(movedNote.id); const flushNoteSave = async (draftId: string, options: FlushSaveOptions = {}): Promise<void> => {
const controller = ensureSaveController(draftId);
clearSaveTimer(draftId);
const currentNote = getNoteByDraftId(draftId);
if (!currentNote?.pendingSave) {
return;
} }
const canPersist = options.force ? canForceSaveLocalNote(currentNote) : canAutosaveLocalNote(currentNote);
if (!canPersist) {
return;
} }
} else {
const updated = await syncManager.updateNote(updatedNote); if (controller.inFlight) {
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n)); await controller.inFlight;
// Update selected note ID if it changed (e.g., filename changed due to first line edit) return;
if (selectedNoteId === updatedNote.id && updated.id !== updatedNote.id) {
setSelectedNoteId(updated.id);
} }
controller.inFlightRevision = controller.revision;
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId
? {
...note,
isSaving: true,
saveError: null,
} }
: note
)
);
const savePromise = (async () => {
try {
const noteToPersist = getNoteByDraftId(draftId);
if (!noteToPersist?.pendingSave) {
return;
}
const canPersistLatest = options.force ? canForceSaveLocalNote(noteToPersist) : canAutosaveLocalNote(noteToPersist);
if (!canPersistLatest) {
return;
}
const savedNote = {
...(await persistNoteToServer(noteToPersist)),
draftId,
localOnly: false,
pendingSave: false,
isSaving: false,
saveError: null,
lastSavedAt: Date.now(),
};
if (noteToPersist.id !== savedNote.id) {
void localDB.deleteNote(noteToPersist.id).catch((error) => {
console.error('Failed to remove stale local note cache entry:', error);
});
}
savedSnapshotsRef.current.set(draftId, {
...savedNote,
pendingSave: false,
isSaving: false,
saveError: null,
});
const latestNote = getNoteByDraftId(draftId);
const hasNewerChanges = controller.revision > controller.inFlightRevision && latestNote;
if (latestNote && hasNewerChanges) {
const mergedPendingNote: Note = {
...savedNote,
content: latestNote.content,
title: latestNote.title,
category: latestNote.category,
favorite: latestNote.favorite,
modified: latestNote.modified,
localOnly: false,
pendingSave: true,
isSaving: false,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? mergedPendingNote : note
)
);
persistNoteToCache(mergedPendingNote);
scheduleNoteSave(draftId, 0);
return;
}
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? savedNote : note
)
);
persistNoteToCache(savedNote);
} catch (error) { } catch (error) {
console.error('Update note failed:', error); console.error('Update note failed:', error);
const failedNote = getNoteByDraftId(draftId);
if (!failedNote) {
return;
} }
const erroredNote = {
...failedNote,
pendingSave: true,
isSaving: false,
saveError: error instanceof Error ? error.message : 'Failed to save note.',
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? erroredNote : note
)
);
persistNoteToCache(erroredNote);
} finally {
controller.inFlight = null;
}
})();
controller.inFlight = savePromise;
await savePromise;
};
const scheduleNoteSave = (draftId: string, delayMs = AUTOSAVE_DELAY_MS) => {
const controller = ensureSaveController(draftId);
clearSaveTimer(draftId);
controller.timerId = window.setTimeout(() => {
controller.timerId = null;
void flushNoteSave(draftId);
}, delayMs);
};
const flushAllPendingSaves = async () => {
const pendingDraftIds = notesRef.current
.filter(note => note.pendingSave && note.draftId)
.map(note => note.draftId as string);
await Promise.all(pendingDraftIds.map(draftId => flushNoteSave(draftId, { force: true })));
};
const handleDraftChange = (updatedNote: Note) => {
const draftId = updatedNote.draftId;
if (!draftId) {
return;
}
const localNote = {
...updatedNote,
modified: Math.floor(Date.now() / 1000),
pendingSave: true,
isSaving: false,
saveError: null,
};
const controller = ensureSaveController(draftId);
controller.revision += 1;
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? localNote : note
)
);
persistNoteToCache(localNote);
scheduleNoteSave(draftId);
};
const handleManualSave = async (draftId: string) => {
await flushNoteSave(draftId, { force: true });
};
const handleDiscardNote = (draftId: string) => {
const snapshot = savedSnapshotsRef.current.get(draftId);
if (!snapshot) {
return;
}
clearSaveTimer(draftId);
const controller = ensureSaveController(draftId);
controller.revision += 1;
const cleanSnapshot = {
...snapshot,
pendingSave: false,
isSaving: false,
saveError: null,
};
setSortedNotes(previousNotes =>
previousNotes.map(note =>
note.draftId === draftId ? cleanSnapshot : note
)
);
persistNoteToCache(cleanSnapshot);
};
const handleSelectNote = async (draftId: string) => {
if (draftId === selectedNoteDraftId) {
return;
}
if (selectedNoteDraftId) {
await flushNoteSave(selectedNoteDraftId, { force: true });
}
setSelectedNoteDraftId(draftId);
}; };
const handleDeleteNote = async (note: Note) => { const handleDeleteNote = async (note: Note) => {
const draftId = note.draftId;
if (!draftId) {
return;
}
try { try {
clearSaveTimer(draftId);
if (!note.localOnly) {
await syncManager.deleteNote(note); await syncManager.deleteNote(note);
const remainingNotes = notes.filter(n => n.id !== note.id); } else {
setNotes(remainingNotes); await localDB.deleteNote(note.id);
if (selectedNoteId === note.id) { }
setSelectedNoteId(remainingNotes[0]?.id || null);
saveControllersRef.current.delete(draftId);
savedSnapshotsRef.current.delete(draftId);
const remainingNotes = notesRef.current.filter(currentNote => currentNote.draftId !== draftId);
notesRef.current = sortNotes(remainingNotes);
setNotes(notesRef.current);
if (selectedNoteDraftId === draftId) {
setSelectedNoteDraftId(getNoteDraftId(notesRef.current[0]));
} }
} catch (error) { } catch (error) {
console.error('Delete note failed:', error); console.error('Delete note failed:', error);
@@ -315,9 +799,16 @@ function App() {
note.content.toLowerCase().includes(search); note.content.toLowerCase().includes(search);
} }
return true; return true;
}).sort((a, b) => {
// Sort favorites first, then by modified date (newest first)
if (a.favorite !== b.favorite) {
return a.favorite ? -1 : 1;
}
return b.modified - a.modified;
}); });
const selectedNote = notes.find(n => n.id === selectedNoteId) || null; const selectedNote = notes.find(n => n.draftId === selectedNoteDraftId) || null;
const hasUnsavedChanges = Boolean(selectedNote?.pendingSave);
if (!isLoggedIn) { if (!isLoggedIn) {
return <LoginView onLogin={handleLogin} />; return <LoginView onLogin={handleLogin} />;
@@ -350,8 +841,8 @@ function App() {
/> />
<NotesList <NotesList
notes={filteredNotes} notes={filteredNotes}
selectedNoteId={selectedNoteId} selectedNoteDraftId={selectedNoteDraftId}
onSelectNote={setSelectedNoteId} onSelectNote={handleSelectNote}
onCreateNote={handleCreateNote} onCreateNote={handleCreateNote}
onDeleteNote={handleDeleteNote} onDeleteNote={handleDeleteNote}
onSync={syncNotes} onSync={syncNotes}
@@ -368,9 +859,10 @@ function App() {
)} )}
<NoteEditor <NoteEditor
note={selectedNote} note={selectedNote}
onUpdateNote={handleUpdateNote} onChangeNote={handleDraftChange}
onSaveNote={handleManualSave}
onDiscardNote={handleDiscardNote}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onUnsavedChanges={setHasUnsavedChanges}
categories={categories} categories={categories}
isFocusMode={isFocusMode} isFocusMode={isFocusMode}
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)} onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
@@ -384,4 +876,19 @@ function App() {
); );
} }
function App() {
const params = new URLSearchParams(window.location.search);
const printJobId = params.get(PRINT_EXPORT_QUERY_PARAM);
if (printJobId) {
return (
<Suspense fallback={<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">Preparing print view...</div>}>
<LazyPrintView jobId={printJobId} />
</Suspense>
);
}
return <MainApp />;
}
export default App; export default App;

View File

@@ -1,5 +1,22 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
import { Note, APIConfig } from '../types'; import { Note, APIConfig } from '../types';
import { runtimeFetch } from '../services/runtimeFetch';
type HttpStatusError = Error & { status?: number };
const createHttpStatusError = (message: string, status: number): HttpStatusError => {
const error = new Error(message) as HttpStatusError;
error.status = status;
return error;
};
const getHttpStatus = (error: unknown): number | null => {
if (typeof error !== 'object' || error === null || !('status' in error)) {
return null;
}
const status = (error as { status?: unknown }).status;
return typeof status === 'number' ? status : null;
};
export class NextcloudAPI { export class NextcloudAPI {
private baseURL: string; private baseURL: string;
@@ -16,7 +33,7 @@ export class NextcloudAPI {
} }
private async request<T>(path: string, options: RequestInit = {}): Promise<T> { private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseURL}${path}`, { const response = await runtimeFetch(`${this.baseURL}${path}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -111,20 +128,11 @@ export class NextcloudAPI {
// The path from markdown is like: .attachments.38479/Screenshot.png // The path from markdown is like: .attachments.38479/Screenshot.png
// We need to construct the full WebDAV URL // We need to construct the full WebDAV URL
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`; const webdavPath = this.buildAttachmentWebDAVPath(noteCategory, path);
// Add category subfolder if present
if (noteCategory) {
webdavPath += `/${noteCategory}`;
}
// Add the attachment path (already includes .attachments.{id}/filename)
webdavPath += `/${path}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url); console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url);
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
}, },
@@ -151,12 +159,6 @@ export class NextcloudAPI {
// Create .attachments.{noteId} directory path and upload file via WebDAV PUT // Create .attachments.{noteId} directory path and upload file via WebDAV PUT
// Returns the relative path to insert into markdown // Returns the relative path to insert into markdown
let webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
if (noteCategory) {
webdavPath += `/${noteCategory}`;
}
// Sanitize note ID: extract just the filename without extension and remove invalid chars // Sanitize note ID: extract just the filename without extension and remove invalid chars
// noteId might be "category/filename.md" or just "filename.md" // noteId might be "category/filename.md" or just "filename.md"
const noteIdStr = String(noteId); const noteIdStr = String(noteId);
@@ -166,7 +168,7 @@ export class NextcloudAPI {
const attachmentDir = `.attachments.${sanitizedNoteId}`; const attachmentDir = `.attachments.${sanitizedNoteId}`;
const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename
const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`; const fullPath = this.buildAttachmentWebDAVPath(noteCategory, attachmentDir, fileName);
const url = `${this.serverURL}${fullPath}`; const url = `${this.serverURL}${fullPath}`;
console.log('Uploading attachment via WebDAV:', url); console.log('Uploading attachment via WebDAV:', url);
@@ -174,7 +176,7 @@ export class NextcloudAPI {
// First, try to create the attachments directory (MKCOL) // First, try to create the attachments directory (MKCOL)
// This may fail if it already exists, which is fine // This may fail if it already exists, which is fine
try { try {
await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, { await runtimeFetch(`${this.serverURL}${this.buildAttachmentWebDAVPath(noteCategory, attachmentDir)}`, {
method: 'MKCOL', method: 'MKCOL',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -188,7 +190,7 @@ export class NextcloudAPI {
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
// Upload the file via PUT // Upload the file via PUT
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -206,11 +208,11 @@ export class NextcloudAPI {
} }
async fetchCategoryColors(): Promise<Record<string, number>> { async fetchCategoryColors(): Promise<Record<string, number>> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`; const webdavPath = `${this.buildNotesRootWebDAVPath()}/.category-colors.json`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
try { try {
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
}, },
@@ -233,12 +235,12 @@ export class NextcloudAPI {
} }
async saveCategoryColors(colors: Record<string, number>): Promise<void> { async saveCategoryColors(colors: Record<string, number>): Promise<void> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`; const webdavPath = `${this.buildNotesRootWebDAVPath()}/.category-colors.json`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
const content = JSON.stringify(colors, null, 2); const content = JSON.stringify(colors, null, 2);
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -277,11 +279,167 @@ export class NextcloudAPI {
return note.content; return note.content;
} }
private buildNotesRootWebDAVPath(): string {
return `/remote.php/dav/files/${this.username}/Notes`;
}
private buildEncodedCategoryPath(category: string): string {
if (!category) {
return '';
}
const encodedSegments = category
.split('/')
.filter(Boolean)
.map((segment) => encodeURIComponent(segment));
return encodedSegments.length ? `/${encodedSegments.join('/')}` : '';
}
private buildCategoryWebDAVPath(category: string): string {
return `${this.buildNotesRootWebDAVPath()}${this.buildEncodedCategoryPath(category)}`;
}
private getRemoteCategoryForNote(note: Note): string {
if (note.path) {
const pathParts = note.path.split('/');
return pathParts.length > 1 ? pathParts.slice(0, -1).join('/') : '';
}
if (typeof note.id === 'string') {
const idParts = note.id.split('/');
return idParts.length > 1 ? idParts.slice(0, -1).join('/') : '';
}
return note.category;
}
private getRemoteFilenameForNote(note: Note): string {
if (note.filename) {
return note.filename;
}
if (note.path) {
return note.path.split('/').pop() || 'note.md';
}
if (typeof note.id === 'string') {
return note.id.split('/').pop() || 'note.md';
}
return 'note.md';
}
private buildNoteWebDAVPath(category: string, filename: string): string {
return `${this.buildCategoryWebDAVPath(category)}/${encodeURIComponent(filename)}`;
}
private buildRelativeWebDAVPath(...segments: string[]): string {
return segments
.flatMap((segment) => segment.split('/'))
.filter(Boolean)
.map((segment) => encodeURIComponent(segment))
.join('/');
}
private buildAttachmentWebDAVPath(noteCategory: string | undefined, ...relativeSegments: string[]): string {
const relativePath = this.buildRelativeWebDAVPath(...relativeSegments);
return relativePath
? `${this.buildCategoryWebDAVPath(noteCategory || '')}/${relativePath}`
: this.buildCategoryWebDAVPath(noteCategory || '');
}
private noteHasLocalAttachments(note: Note): boolean {
return note.content.includes('.attachments.');
}
private async ensureCategoryDirectoryExists(category: string): Promise<void> {
if (!category) {
return;
}
const parts = category.split('/').filter(Boolean);
for (let index = 0; index < parts.length; index += 1) {
const currentCategory = parts.slice(0, index + 1).join('/');
const categoryUrl = `${this.serverURL}${this.buildCategoryWebDAVPath(currentCategory)}`;
const response = await runtimeFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
if (!response.ok && response.status !== 405) {
throw createHttpStatusError(`Failed to create category folder: ${response.status}`, response.status);
}
}
}
private async delay(ms: number): Promise<void> {
await new Promise((resolve) => window.setTimeout(resolve, ms));
}
private async fetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number }> {
const webdavPath = this.buildNoteWebDAVPath(category, filename);
const response = await runtimeFetch(`${this.serverURL}${webdavPath}`, {
method: 'PROPFIND',
headers: {
'Authorization': this.authHeader,
'Depth': '0',
'Content-Type': 'application/xml',
},
body: `<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:getlastmodified/>
<d:getetag/>
</d:prop>
</d:propfind>`,
});
if (!response.ok) {
throw createHttpStatusError(`Failed to fetch note metadata: ${response.status}`, response.status);
}
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
const responseNode = xmlDoc.getElementsByTagNameNS('DAV:', 'response')[0];
const propstat = responseNode?.getElementsByTagNameNS('DAV:', 'propstat')[0];
const prop = propstat?.getElementsByTagNameNS('DAV:', 'prop')[0];
const etag = prop?.getElementsByTagNameNS('DAV:', 'getetag')[0]?.textContent || '';
const lastModified = prop?.getElementsByTagNameNS('DAV:', 'getlastmodified')[0]?.textContent || '';
const modified = lastModified ? Math.floor(new Date(lastModified).getTime() / 1000) : Math.floor(Date.now() / 1000);
return { etag, modified };
}
private async tryFetchNoteMetadataWebDAV(category: string, filename: string): Promise<{ etag: string; modified: number } | null> {
try {
return await this.fetchNoteMetadataWebDAV(category, filename);
} catch (error) {
const status = getHttpStatus(error);
if (status === 404) {
return null;
}
throw error;
}
}
private async refreshNoteWebDAVMetadata(note: Note): Promise<Note> {
const metadata = await this.fetchNoteMetadataWebDAV(note.category, note.filename!);
return {
...note,
etag: metadata.etag || note.etag,
modified: metadata.modified || note.modified,
};
}
async fetchNotesWebDAV(): Promise<Note[]> { async fetchNotesWebDAV(): Promise<Note[]> {
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`; const webdavPath = this.buildNotesRootWebDAVPath();
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
method: 'PROPFIND', method: 'PROPFIND',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -356,12 +514,11 @@ export class NextcloudAPI {
} }
async fetchNoteContentWebDAV(note: Note): Promise<Note> { async fetchNoteContentWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : '';
const filename = note.filename || String(note.id).split('/').pop() || 'note.md'; const filename = note.filename || String(note.id).split('/').pop() || 'note.md';
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`; const webdavPath = this.buildNoteWebDAVPath(note.category, filename);
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
headers: { 'Authorization': this.authHeader }, headers: { 'Authorization': this.authHeader },
}); });
@@ -375,26 +532,17 @@ export class NextcloudAPI {
async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> { async createNoteWebDAV(title: string, content: string, category: string): Promise<Note> {
const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`; const filename = `${title.replace(/[\/:\*?"<>|]/g, '').replace(/\s+/g, ' ').trim()}.md`;
const categoryPath = category ? `/${category}` : ''; const webdavPath = this.buildNoteWebDAVPath(category, filename);
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
// Ensure category directory exists // Ensure category directory exists
if (category) { if (category) {
try { await this.ensureCategoryDirectoryExists(category);
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
await tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist
}
} }
const noteContent = `${title}\n${content}`; const noteContent = content ? `${title}\n${content}` : title;
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -416,7 +564,7 @@ export class NextcloudAPI {
path: category ? `${category}/${filename}` : filename, path: category ? `${category}/${filename}` : filename,
etag, etag,
readonly: false, readonly: false,
content, content: noteContent,
title, title,
category, category,
favorite: false, favorite: false,
@@ -437,21 +585,42 @@ export class NextcloudAPI {
// Rename the file first, then update content // Rename the file first, then update content
const renamedNote = await this.renameNoteWebDAV(note, newFilename); const renamedNote = await this.renameNoteWebDAV(note, newFilename);
// Now update the content of the renamed file // Now update the content of the renamed file
return this.updateNoteContentWebDAV(renamedNote); return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote));
} else { } else {
// Just update content // Just update content
return this.updateNoteContentWebDAV(note); return this.updateNoteContentWithRetryWebDAV(note);
} }
} }
private async updateNoteContentWithRetryWebDAV(note: Note, maxRetries = 2): Promise<Note> {
let currentNote = note;
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
try {
return await this.updateNoteContentWebDAV(currentNote);
} catch (error) {
const status = getHttpStatus(error);
const canRetry = status === 412 || status === 423;
if (!canRetry || attempt === maxRetries) {
throw error;
}
await this.delay(150 * (attempt + 1));
currentNote = await this.refreshNoteWebDAVMetadata(currentNote);
}
}
return this.updateNoteContentWebDAV(currentNote);
}
private async updateNoteContentWebDAV(note: Note): Promise<Note> { private async updateNoteContentWebDAV(note: Note): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : ''; const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
const noteContent = this.formatNoteContent(note); const noteContent = this.formatNoteContent(note);
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -463,9 +632,12 @@ export class NextcloudAPI {
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204) {
if (response.status === 412) { if (response.status === 412) {
throw new Error('Note was modified by another client. Please refresh.'); throw createHttpStatusError('Note was modified by another client. Please refresh.', response.status);
} }
throw new Error(`Failed to update note: ${response.status}`); if (response.status === 423) {
throw createHttpStatusError('Note is temporarily locked. Retrying...', response.status);
}
throw createHttpStatusError(`Failed to update note: ${response.status}`, response.status);
} }
const etag = response.headers.get('etag') || note.etag; const etag = response.headers.get('etag') || note.etag;
@@ -478,23 +650,39 @@ export class NextcloudAPI {
} }
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> { private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
const categoryPath = note.category ? `/${note.category}` : ''; const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`; const newPath = this.buildNoteWebDAVPath(note.category, newFilename);
const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`;
const response = await tauriFetch(`${this.serverURL}${oldPath}`, { const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
method: 'MOVE', method: 'MOVE',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
'Destination': `${this.serverURL}${newPath}`, 'Destination': `${this.serverURL}${newPath}`,
'If-Match': note.etag,
}, },
}); });
if (!response.ok && response.status !== 201 && response.status !== 204) { if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error(`Failed to rename note: ${response.status}`); if (response.status === 404) {
const existingMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
if (existingMetadata) {
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
return {
...note,
id: newId,
filename: newFilename,
path: note.category ? `${note.category}/${newFilename}` : newFilename,
etag: existingMetadata.etag || note.etag,
modified: existingMetadata.modified || Math.floor(Date.now() / 1000),
};
}
} }
// Also rename attachment folder if it exists throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status);
}
if (this.noteHasLocalAttachments(note)) {
// Also rename attachment folder if the note references local attachments
const oldNoteIdStr = String(note.id); const oldNoteIdStr = String(note.id);
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr; const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, ''); const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
@@ -505,11 +693,11 @@ export class NextcloudAPI {
const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); const newSanitizedNoteId = newFilenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`; const newAttachmentFolder = `.attachments.${newSanitizedNoteId}`;
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${oldAttachmentFolder}`; const oldAttachmentPath = this.buildAttachmentWebDAVPath(note.category, oldAttachmentFolder);
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`; const newAttachmentPath = this.buildAttachmentWebDAVPath(note.category, newAttachmentFolder);
try { try {
await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, { await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE', method: 'MOVE',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -519,7 +707,9 @@ export class NextcloudAPI {
} catch (e) { } catch (e) {
// Attachment folder might not exist, that's ok // Attachment folder might not exist, that's ok
} }
}
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
const newId = note.category ? `${note.category}/${newFilename}` : newFilename; const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
return { return {
@@ -527,50 +717,37 @@ export class NextcloudAPI {
id: newId, id: newId,
filename: newFilename, filename: newFilename,
path: note.category ? `${note.category}/${newFilename}` : newFilename, path: note.category ? `${note.category}/${newFilename}` : newFilename,
etag: refreshedMetadata?.etag || note.etag,
modified: refreshedMetadata?.modified || Math.floor(Date.now() / 1000),
}; };
} }
async deleteNoteWebDAV(note: Note): Promise<void> { async deleteNoteWebDAV(note: Note): Promise<void> {
const categoryPath = note.category ? `/${note.category}` : ''; const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
const url = `${this.serverURL}${webdavPath}`; const url = `${this.serverURL}${webdavPath}`;
const response = await tauriFetch(url, { const response = await runtimeFetch(url, {
method: 'DELETE', method: 'DELETE',
headers: { 'Authorization': this.authHeader }, headers: { 'Authorization': this.authHeader },
}); });
if (!response.ok && response.status !== 204) { if (!response.ok && response.status !== 204 && response.status !== 404) {
throw new Error(`Failed to delete note: ${response.status}`); throw createHttpStatusError(`Failed to delete note: ${response.status}`, response.status);
} }
} }
async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> { async moveNoteWebDAV(note: Note, newCategory: string): Promise<Note> {
const oldCategoryPath = note.category ? `/${note.category}` : ''; const remoteCategory = this.getRemoteCategoryForNote(note);
const newCategoryPath = newCategory ? `/${newCategory}` : ''; const remoteFilename = this.getRemoteFilenameForNote(note);
const oldPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${encodeURIComponent(note.filename!)}`; const oldPath = this.buildNoteWebDAVPath(remoteCategory, remoteFilename);
const newPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${encodeURIComponent(note.filename!)}`; const newPath = this.buildNoteWebDAVPath(newCategory, remoteFilename);
// Ensure new category directory exists (including nested subdirectories) // Ensure new category directory exists (including nested subdirectories)
if (newCategory) { if (newCategory) {
const parts = newCategory.split('/'); await this.ensureCategoryDirectoryExists(newCategory);
let currentPath = '';
for (const part of parts) {
currentPath += (currentPath ? '/' : '') + part;
try {
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`;
await tauriFetch(categoryUrl, {
method: 'MKCOL',
headers: { 'Authorization': this.authHeader },
});
} catch (e) {
// Directory might already exist, continue
}
}
} }
const response = await tauriFetch(`${this.serverURL}${oldPath}`, { const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
method: 'MOVE', method: 'MOVE',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -579,25 +756,31 @@ export class NextcloudAPI {
}); });
if (!response.ok && response.status !== 201 && response.status !== 204) { if (!response.ok && response.status !== 201 && response.status !== 204) {
throw new Error(`Failed to move note: ${response.status}`); const details = await response.text().catch(() => '');
const detailSuffix = details ? ` - ${details.slice(0, 300)}` : '';
throw createHttpStatusError(
`Failed to move note: ${response.status}${detailSuffix}. Source: ${oldPath}. Destination: ${newPath}`,
response.status,
);
} }
// Move attachment folder if it exists if (this.noteHasLocalAttachments(note)) {
// Move attachment folder only when the note references local attachments
const noteIdStr = String(note.id); const noteIdStr = String(note.id);
const justFilename = noteIdStr.split('/').pop() || noteIdStr; const justFilename = noteIdStr.split('/').pop() || noteIdStr;
const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, ''); const filenameWithoutExt = justFilename.replace(/\.(md|txt)$/, '');
const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_'); const sanitizedNoteId = filenameWithoutExt.replace(/[^a-zA-Z0-9_-]/g, '_');
const attachmentFolder = `.attachments.${sanitizedNoteId}`; const attachmentFolder = `.attachments.${sanitizedNoteId}`;
const oldAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${oldCategoryPath}/${attachmentFolder}`; const oldAttachmentPath = this.buildAttachmentWebDAVPath(remoteCategory, attachmentFolder);
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${newCategoryPath}/${attachmentFolder}`; const newAttachmentPath = this.buildAttachmentWebDAVPath(newCategory, attachmentFolder);
console.log(`Attempting to move attachment folder:`); console.log(`Attempting to move attachment folder:`);
console.log(` From: ${oldAttachmentPath}`); console.log(` From: ${oldAttachmentPath}`);
console.log(` To: ${newAttachmentPath}`); console.log(` To: ${newAttachmentPath}`);
try { try {
const attachmentResponse = await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, { const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
method: 'MOVE', method: 'MOVE',
headers: { headers: {
'Authorization': this.authHeader, 'Authorization': this.authHeader,
@@ -615,12 +798,14 @@ export class NextcloudAPI {
} catch (e) { } catch (e) {
console.log(`✗ Error moving attachment folder:`, e); console.log(`✗ Error moving attachment folder:`, e);
} }
}
return { return {
...note, ...note,
category: newCategory, category: newCategory,
path: newCategory ? `${newCategory}/${note.filename}` : note.filename || '', filename: remoteFilename,
id: newCategory ? `${newCategory}/${note.filename}` : (note.filename || ''), path: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename,
id: newCategory ? `${newCategory}/${remoteFilename}` : remoteFilename,
}; };
} }
} }

View File

@@ -3,6 +3,8 @@ import { useEffect, useState, useRef, RefObject } from 'react';
interface InsertToolbarProps { interface InsertToolbarProps {
textareaRef: RefObject<HTMLTextAreaElement | null>; textareaRef: RefObject<HTMLTextAreaElement | null>;
onInsertLink: (text: string, url: string) => void; onInsertLink: (text: string, url: string) => void;
onInsertTodoItem: () => void;
onInsertTable: () => void;
onInsertFile: () => void; onInsertFile: () => void;
isUploading?: boolean; isUploading?: boolean;
} }
@@ -13,7 +15,7 @@ interface LinkModalState {
url: string; url: string;
} }
export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploading }: InsertToolbarProps) { export function InsertToolbar({ textareaRef, onInsertLink, onInsertTodoItem, onInsertTable, onInsertFile, isUploading }: InsertToolbarProps) {
const [position, setPosition] = useState<{ top: number; left: number } | null>(null); const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [linkModal, setLinkModal] = useState<LinkModalState>({ isOpen: false, text: '', url: '' }); const [linkModal, setLinkModal] = useState<LinkModalState>({ isOpen: false, text: '', url: '' });
@@ -58,7 +60,7 @@ export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploa
const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth) + 20; const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth) + 20;
// Keep toolbar within viewport // Keep toolbar within viewport
const toolbarWidth = 100; const toolbarWidth = 196;
const adjustedLeft = Math.min(left, window.innerWidth - toolbarWidth - 20); const adjustedLeft = Math.min(left, window.innerWidth - toolbarWidth - 20);
let adjustedTop = top - 16; // Center vertically with cursor line let adjustedTop = top - 16; // Center vertically with cursor line
@@ -137,6 +139,16 @@ export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploa
setIsVisible(false); setIsVisible(false);
}; };
const handleTodoClick = () => {
onInsertTodoItem();
setIsVisible(false);
};
const handleTableClick = () => {
onInsertTable();
setIsVisible(false);
};
if (!isVisible || !position) return null; if (!isVisible || !position) return null;
// Link Modal // Link Modal
@@ -218,6 +230,28 @@ export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploa
</svg> </svg>
</button> </button>
<button
onClick={handleTodoClick}
className="p-2 rounded hover:bg-gray-700 dark:hover:bg-gray-600 text-white transition-colors"
title="Insert To-Do Item"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h11M9 12h11M9 17h11M4 7h.01M4 12h.01M4 17h.01" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="m3.5 12.5 1.5 1.5 3-3" />
</svg>
</button>
<button
onClick={handleTableClick}
className="p-2 rounded hover:bg-gray-700 dark:hover:bg-gray-600 text-white transition-colors"
title="Insert Table"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5h16v14H4V5Z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 10h16M10 5v14" />
</svg>
</button>
<button <button
onClick={handleFileClick} onClick={handleFileClick}
disabled={isUploading} disabled={isUploading}

View File

@@ -1,17 +1,27 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useLayoutEffect, useRef } from 'react';
import { marked } from 'marked'; import { marked } from 'marked';
import jsPDF from 'jspdf';
import { message } from '@tauri-apps/plugin-dialog';
import { Note } from '../types'; import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud'; import { NextcloudAPI } from '../api/nextcloud';
import { FloatingToolbar } from './FloatingToolbar'; import { FloatingToolbar } from './FloatingToolbar';
import { InsertToolbar } from './InsertToolbar'; import { InsertToolbar } from './InsertToolbar';
import {
exportPdfDocument,
getDesktopRuntime,
showDesktopMessage,
} from '../services/desktop';
import {
getNoteTitleFromContent,
loadPrintFontFaceCss,
PrintExportPayload,
sanitizeFileName,
} from '../printExport';
interface NoteEditorProps { interface NoteEditorProps {
note: Note | null; note: Note | null;
onUpdateNote: (note: Note) => void; onChangeNote: (note: Note) => void;
onSaveNote: (draftId: string) => void | Promise<void>;
onDiscardNote: (draftId: string) => void;
onToggleFavorite?: (note: Note, favorite: boolean) => void; onToggleFavorite?: (note: Note, favorite: boolean) => void;
onUnsavedChanges?: (hasChanges: boolean) => void;
categories: string[]; categories: string[];
isFocusMode?: boolean; isFocusMode?: boolean;
onToggleFocusMode?: () => void; onToggleFocusMode?: () => void;
@@ -24,26 +34,101 @@ interface NoteEditorProps {
const imageCache = new Map<string, string>(); const imageCache = new Map<string, string>();
const TASK_LIST_ITEM_REGEX = /^(\s*(?:[-+*]|\d+\.)\s)\[( |x|X)\]\s/;
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { // Configure marked to support task lists
marked.use({
gfm: true,
breaks: true,
});
function getTaskListMarkers(markdown: string) {
const markers: Array<{ markerStart: number; markerEnd: number }> = [];
const lines = markdown.split('\n');
let offset = 0;
let activeFence: string | null = null;
lines.forEach((line, index) => {
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
if (fenceMatch) {
const fence = fenceMatch[1];
if (!activeFence) {
activeFence = fence;
} else if (fence[0] === activeFence[0] && fence.length >= activeFence.length) {
activeFence = null;
}
} else if (!activeFence) {
const taskMatch = line.match(TASK_LIST_ITEM_REGEX);
if (taskMatch) {
const markerStart = offset + taskMatch[1].length;
markers.push({
markerStart,
markerEnd: markerStart + 3,
});
}
}
offset += line.length;
if (index < lines.length - 1) {
offset += 1;
}
});
return markers;
}
function toggleTaskListMarker(markdown: string, taskIndex: number, checked: boolean) {
const taskMarker = getTaskListMarkers(markdown)[taskIndex];
if (!taskMarker) {
return markdown;
}
const nextMarker = checked ? '[x]' : '[ ]';
return `${markdown.slice(0, taskMarker.markerStart)}${nextMarker}${markdown.slice(taskMarker.markerEnd)}`;
}
function renderPreviewHtml(markdown: string) {
const parsedHtml = marked.parse(markdown || '', { async: false }) as string;
const documentFragment = new DOMParser().parseFromString(parsedHtml, 'text/html');
documentFragment.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((input, index) => {
input.removeAttribute('disabled');
input.classList.add('markdown-task-checkbox');
input.setAttribute('data-task-index', String(index));
input.setAttribute('aria-label', `Toggle task ${index + 1}`);
});
documentFragment.querySelectorAll('table').forEach((table) => {
const wrapper = documentFragment.createElement('div');
wrapper.className = 'markdown-table-wrapper';
table.parentNode?.insertBefore(wrapper, table);
wrapper.appendChild(table);
});
return documentFragment.body.innerHTML;
}
export function NoteEditor({ note, onChangeNote, onSaveNote, onDiscardNote, onToggleFavorite, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
const [localContent, setLocalContent] = useState(''); const [localContent, setLocalContent] = useState('');
const [localCategory, setLocalCategory] = useState(''); const [localCategory, setLocalCategory] = useState('');
const [localFavorite, setLocalFavorite] = useState(false); const [localFavorite, setLocalFavorite] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [isExportingPDF, setIsExportingPDF] = useState(false); const [isExportingPDF, setIsExportingPDF] = useState(false);
const [isPreviewMode, setIsPreviewMode] = useState(false); const [isPreviewMode, setIsPreviewMode] = useState(false);
const [processedContent, setProcessedContent] = useState(''); const [processedContent, setProcessedContent] = useState('');
const [isLoadingImages, setIsLoadingImages] = useState(false); const [isLoadingImages, setIsLoadingImages] = useState(false);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const previousNoteIdRef = useRef<number | string | null>(null); const previousDraftIdRef = useRef<string | null>(null);
const previousNoteContentRef = useRef<string>('');
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const editorScrollContainerRef = useRef<HTMLDivElement>(null);
const previewContentRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const pendingScrollTopRef = useRef<number | null>(null);
useEffect(() => { const desktopRuntime = getDesktopRuntime();
onUnsavedChanges?.(hasUnsavedChanges); const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
}, [hasUnsavedChanges, onUnsavedChanges]); const hasUnsavedChanges = Boolean(note?.pendingSave);
const isSaving = Boolean(note?.isSaving);
const saveError = note?.saveError;
const hasSavedState = Boolean(note?.lastSavedAt) && !hasUnsavedChanges && !isSaving && !saveError;
// Handle Escape key to exit focus mode // Handle Escape key to exit focus mode
useEffect(() => { useEffect(() => {
@@ -57,26 +142,39 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [isFocusMode, onToggleFocusMode]); }, [isFocusMode, onToggleFocusMode]);
// Auto-resize textarea when content changes, switching from preview to edit, or font size changes const captureEditorScrollPosition = () => {
useEffect(() => { if (editorScrollContainerRef.current) {
if (textareaRef.current && !isPreviewMode) { pendingScrollTopRef.current = editorScrollContainerRef.current.scrollTop;
// Use setTimeout to ensure DOM has updated
setTimeout(() => {
if (textareaRef.current) {
// Save cursor position and scroll position
const cursorPosition = textareaRef.current.selectionStart;
const scrollTop = textareaRef.current.scrollTop;
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
// Restore cursor position and scroll position
textareaRef.current.setSelectionRange(cursorPosition, cursorPosition);
textareaRef.current.scrollTop = scrollTop;
} }
}, 0); };
// Keep the editor pane anchored when the textarea grows with new content.
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea || isPreviewMode) {
pendingScrollTopRef.current = null;
return;
} }
}, [localContent, isPreviewMode, editorFontSize]);
const selectionStart = textarea.selectionStart;
const selectionEnd = textarea.selectionEnd;
const shouldRestoreSelection = document.activeElement === textarea;
const scrollContainer = editorScrollContainerRef.current;
const scrollTop = pendingScrollTopRef.current ?? scrollContainer?.scrollTop ?? null;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
if (scrollContainer && scrollTop !== null) {
scrollContainer.scrollTop = scrollTop;
}
if (shouldRestoreSelection) {
textarea.setSelectionRange(selectionStart, selectionEnd);
}
pendingScrollTopRef.current = null;
}, [localContent, isPreviewMode, editorFontSize, note?.draftId]);
// Process images when entering preview mode or content changes // Process images when entering preview mode or content changes
useEffect(() => { useEffect(() => {
@@ -87,8 +185,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
// Guard: Only process if localContent has been updated for the current note // Guard: Only process if localContent has been updated for the current note
// This prevents processing stale content from the previous note // This prevents processing stale content from the previous note
if (previousNoteIdRef.current !== note.id) { if (previousDraftIdRef.current !== note.draftId) {
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`); console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousDraftIdRef: ${previousDraftIdRef.current})`);
return; return;
} }
@@ -134,93 +232,87 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
}; };
processImages(); processImages();
}, [isPreviewMode, localContent, note?.id, api]); }, [isPreviewMode, localContent, note?.draftId, note?.id, api]);
useEffect(() => { useEffect(() => {
const loadNewNote = () => { if (!note) {
if (note) { setLocalContent('');
setLocalContent(note.content); setLocalCategory('');
setLocalCategory(note.category || ''); setLocalFavorite(false);
setLocalFavorite(note.favorite); previousDraftIdRef.current = null;
setHasUnsavedChanges(false); return;
previousNoteIdRef.current = note.id;
previousNoteContentRef.current = note.content;
} }
if (previousDraftIdRef.current !== note.draftId) {
setProcessedContent('');
previousDraftIdRef.current = note.draftId ?? null;
}
if (note.content !== localContent) {
setLocalContent(note.content);
}
if ((note.category || '') !== localCategory) {
setLocalCategory(note.category || '');
}
if (note.favorite !== localFavorite) {
setLocalFavorite(note.favorite);
}
}, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]);
useEffect(() => {
const previewElement = previewContentRef.current;
if (!previewElement || !isPreviewMode) {
return;
}
const handleTaskToggle = (event: Event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) || target.type !== 'checkbox') {
return;
}
const taskIndex = Number(target.dataset.taskIndex);
if (Number.isNaN(taskIndex)) {
return;
}
const newContent = toggleTaskListMarker(localContent, taskIndex, target.checked);
if (newContent === localContent) {
return;
}
setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
}; };
// Switching to a different note previewElement.addEventListener('change', handleTaskToggle);
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) { return () => previewElement.removeEventListener('change', handleTaskToggle);
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`); }, [isPreviewMode, localContent, localCategory, localFavorite]);
setProcessedContent('');
if (hasUnsavedChanges) {
handleSave();
}
loadNewNote();
}
// Same note but content changed from server (and no unsaved local changes)
else if (note && previousNoteIdRef.current === note.id && !hasUnsavedChanges && previousNoteContentRef.current !== note.content) {
console.log(`Note ${note.id} content changed from server (prev: ${previousNoteContentRef.current.length} chars, new: ${note.content.length} chars)`);
loadNewNote();
}
// Initial load
else if (!note || previousNoteIdRef.current === null) {
loadNewNote();
}
// Favorite status changed (e.g., from sync)
else if (note && note.favorite !== localFavorite) {
setLocalFavorite(note.favorite);
}
}, [note?.id, note?.content, note?.modified, note?.favorite]);
const handleSave = () => { const emitNoteChange = (content: string, category: string, favorite: boolean) => {
if (!note || !hasUnsavedChanges) return; if (!note) {
return;
}
console.log('Saving note content length:', localContent.length); onChangeNote({
console.log('Last 50 chars:', localContent.slice(-50));
setIsSaving(true);
setHasUnsavedChanges(false);
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({
...note, ...note,
title, title: getNoteTitleFromContent(content),
content: localContent, content,
category: localCategory, category,
favorite: localFavorite, favorite,
}); });
setTimeout(() => setIsSaving(false), 500);
}; };
const handleContentChange = (value: string) => { const handleContentChange = (value: string) => {
captureEditorScrollPosition();
setLocalContent(value); setLocalContent(value);
setHasUnsavedChanges(true); emitNoteChange(value, localCategory, localFavorite);
}; };
const handleDiscard = () => { const handleDiscard = () => {
if (!note) return; if (!note?.draftId) return;
setLocalContent(note.content); onDiscardNote(note.draftId);
setLocalCategory(note.category || '');
setLocalFavorite(note.favorite);
setHasUnsavedChanges(false);
};
const loadFontAsBase64 = async (fontPath: string): Promise<string> => {
const response = await fetch(fontPath);
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
// Remove data URL prefix to get just the base64 string
resolve(base64.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}; };
const handleExportPDF = async () => { const handleExportPDF = async () => {
@@ -229,66 +321,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setIsExportingPDF(true); setIsExportingPDF(true);
try { try {
// Create PDF let contentForPrint = localContent;
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
// Load and add custom fonts based on preview font selection
const fontMap: { [key: string]: { regular: string; italic: string; name: string } } = {
'Merriweather': {
regular: '/fonts/Merriweather-VariableFont_opsz,wdth,wght.ttf',
italic: '/fonts/Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
name: 'Merriweather'
},
'Crimson Pro': {
regular: '/fonts/CrimsonPro-VariableFont_wght.ttf',
italic: '/fonts/CrimsonPro-Italic-VariableFont_wght.ttf',
name: 'CrimsonPro'
},
'Roboto Serif': {
regular: '/fonts/RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
italic: '/fonts/RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
name: 'RobotoSerif'
},
'Average': {
regular: '/fonts/Average-Regular.ttf',
italic: '/fonts/Average-Regular.ttf', // No italic variant
name: 'Average'
}
};
const selectedFont = fontMap[previewFont];
if (selectedFont) {
try {
const regularBase64 = await loadFontAsBase64(selectedFont.regular);
pdf.addFileToVFS(`${selectedFont.name}-normal.ttf`, regularBase64);
pdf.addFont(`${selectedFont.name}-normal.ttf`, selectedFont.name, 'normal');
const italicBase64 = await loadFontAsBase64(selectedFont.italic);
pdf.addFileToVFS(`${selectedFont.name}-italic.ttf`, italicBase64);
pdf.addFont(`${selectedFont.name}-italic.ttf`, selectedFont.name, 'italic');
// Set the custom font as default
pdf.setFont(selectedFont.name, 'normal');
} catch (fontError) {
console.error('Failed to load custom font, using default:', fontError);
}
}
// Add Source Code Pro for code blocks
try {
const codeFont = await loadFontAsBase64('/fonts/SourceCodePro-VariableFont_wght.ttf');
pdf.addFileToVFS('SourceCodePro-normal.ttf', codeFont);
pdf.addFont('SourceCodePro-normal.ttf', 'SourceCodePro', 'normal');
} catch (codeFontError) {
console.error('Failed to load code font:', codeFontError);
}
// Process images to embed them as data URLs
let contentForPDF = localContent;
if (api) { if (api) {
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches = [...localContent.matchAll(imageRegex)]; const matches = [...localContent.matchAll(imageRegex)];
@@ -305,100 +338,46 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const cacheKey = `${note.id}:${imagePath}`; const cacheKey = `${note.id}:${imagePath}`;
if (imageCache.has(cacheKey)) { if (imageCache.has(cacheKey)) {
const dataUrl = imageCache.get(cacheKey)!; const dataUrl = imageCache.get(cacheKey)!;
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
continue; continue;
} }
try { try {
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category); const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
imageCache.set(cacheKey, dataUrl); imageCache.set(cacheKey, dataUrl);
contentForPDF = contentForPDF.replace(fullMatch, `![${alt}](${dataUrl})`); contentForPrint = contentForPrint.replace(fullMatch, `![${alt}](${dataUrl})`);
} catch (error) { } catch (error) {
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error); console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
} }
} }
} }
const container = document.createElement('div'); const title = getNoteTitleFromContent(localContent);
container.style.fontFamily = `"${previewFont}", Georgia, serif`; const fileName = `${sanitizeFileName(title)}.pdf`;
container.style.fontSize = `${previewFontSize}px`; const noteHtml = marked.parse(contentForPrint || '', { async: false }) as string;
container.style.lineHeight = '1.6'; const payload: PrintExportPayload = {
container.style.color = '#000000'; fileName,
title,
html: noteHtml,
previewFont,
previewFontSize,
previewFontFaceCss: await loadPrintFontFaceCss(previewFont),
};
const titleElement = document.createElement('h1'); await exportPdfDocument({
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim(); ...payload,
titleElement.textContent = firstLine || 'Untitled';
titleElement.style.marginTop = '0';
titleElement.style.marginBottom = '20px';
titleElement.style.fontSize = '24px';
titleElement.style.fontWeight = 'bold';
titleElement.style.color = '#000000';
titleElement.style.textAlign = 'center';
titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`;
container.appendChild(titleElement);
const contentElement = document.createElement('div');
const html = marked.parse(contentForPDF || '', { async: false }) as string;
contentElement.innerHTML = html;
contentElement.style.fontSize = `${previewFontSize}px`;
contentElement.style.lineHeight = '1.6';
contentElement.style.color = '#000000';
container.appendChild(contentElement);
const style = document.createElement('style');
style.textContent = `
body, p, h1, h2, h3, div { font-family: "${previewFont}", Georgia, serif !important; }
code, pre, pre * { font-family: "Source Code Pro", "Courier New", monospace !important; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
code { padding: 0; }
h1 { font-size: 2em; font-weight: bold; margin-top: 0.67em; margin-bottom: 0.67em; }
h2 { font-size: 1.5em; font-weight: bold; margin-top: 0.83em; margin-bottom: 0.83em; }
h3 { font-size: 1.17em; font-weight: bold; margin-top: 1em; margin-bottom: 1em; }
p { margin: 0.5em 0; }
ul, ol { margin: 0.5em 0; padding-left: 2em; list-style-position: outside; font-family: "${previewFont}", Georgia, serif !important; }
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li { margin: 0.25em 0; display: list-item; font-family: "${previewFont}", Georgia, serif !important; }
em { font-style: italic; vertical-align: baseline; }
strong { font-weight: bold; vertical-align: baseline; line-height: inherit; }
img { max-width: 100%; height: auto; display: block; margin: 1em 0; }
`;
container.appendChild(style);
// Use jsPDF's html() method with custom font set
await pdf.html(container, {
callback: async (doc) => {
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const fileName = `${firstLine || 'note'}.pdf`;
doc.save(fileName);
setTimeout(async () => {
try {
await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, {
title: 'Export Complete',
kind: 'info',
});
} catch (err) {
console.log('Dialog shown successfully or not available');
}
setIsExportingPDF(false);
}, 500);
},
margin: [20, 20, 20, 20],
autoPaging: 'text',
width: 170,
windowWidth: 650,
}); });
} catch (error) { } catch (error) {
console.error('PDF export failed:', error); console.error('PDF export failed:', error);
try { try {
await message('Failed to export PDF. Please try again.', { await showDesktopMessage('Failed to export the PDF. Please try again.', {
title: 'Export Failed', title: 'Export Failed',
kind: 'error', kind: 'error',
}); });
} catch (err) { } catch (err) {
console.error('Could not show error dialog'); console.error('Could not show error dialog');
} }
} finally {
setIsExportingPDF(false); setIsExportingPDF(false);
} }
}; };
@@ -408,26 +387,13 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
setLocalFavorite(newFavorite); setLocalFavorite(newFavorite);
if (note && onToggleFavorite) { if (note && onToggleFavorite) {
// Use dedicated favorite toggle callback if provided
onToggleFavorite(note, newFavorite); onToggleFavorite(note, newFavorite);
} else if (note) {
// Fallback to full update if no callback provided
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
const title = firstLine || 'Untitled';
onUpdateNote({
...note,
title,
content: localContent,
category: localCategory,
favorite: newFavorite,
});
} }
}; };
const handleCategoryChange = (category: string) => { const handleCategoryChange = (category: string) => {
setLocalCategory(category); setLocalCategory(category);
setHasUnsavedChanges(true); emitNoteChange(localContent, category, localFavorite);
}; };
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -447,10 +413,11 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
// Insert at cursor position or end of content // Insert at cursor position or end of content
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (textarea) { if (textarea) {
captureEditorScrollPosition();
const cursorPos = textarea.selectionStart; const cursorPos = textarea.selectionStart;
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
setLocalContent(newContent); setLocalContent(newContent);
setHasUnsavedChanges(true); emitNoteChange(newContent, localCategory, localFavorite);
// Move cursor after inserted text // Move cursor after inserted text
setTimeout(() => { setTimeout(() => {
@@ -460,17 +427,18 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
}, 0); }, 0);
} else { } else {
// Append to end // Append to end
setLocalContent(localContent + '\n' + markdownLink); const newContent = `${localContent}\n${markdownLink}`;
setHasUnsavedChanges(true); setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
} }
await message(`Attachment uploaded successfully!`, { await showDesktopMessage('Attachment uploaded successfully!', {
title: 'Upload Complete', title: 'Upload Complete',
kind: 'info', kind: 'info',
}); });
} catch (error) { } catch (error) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
await message(`Failed to upload attachment: ${error}`, { await showDesktopMessage(`Failed to upload attachment: ${error}`, {
title: 'Upload Failed', title: 'Upload Failed',
kind: 'error', kind: 'error',
}); });
@@ -487,11 +455,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (!textarea) return; if (!textarea) return;
captureEditorScrollPosition();
const cursorPos = textarea.selectionStart; const cursorPos = textarea.selectionStart;
const markdownLink = `[${text}](${url})`; const markdownLink = `[${text}](${url})`;
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
setLocalContent(newContent); setLocalContent(newContent);
setHasUnsavedChanges(true); emitNoteChange(newContent, localCategory, localFavorite);
setTimeout(() => { setTimeout(() => {
textarea.focus(); textarea.focus();
@@ -504,6 +473,44 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
fileInputRef.current?.click(); fileInputRef.current?.click();
}; };
const insertBlockSnippet = (snippet: string, selectionStartOffset: number, selectionEndOffset: number) => {
const textarea = textareaRef.current;
const cursorPos = textarea?.selectionStart ?? localContent.length;
const needsLeadingNewline = cursorPos > 0 && localContent[cursorPos - 1] !== '\n';
const needsTrailingNewline = cursorPos < localContent.length && localContent[cursorPos] !== '\n';
const prefix = needsLeadingNewline ? '\n' : '';
const suffix = needsTrailingNewline ? '\n' : '';
const insertedText = `${prefix}${snippet}${suffix}`;
const newContent = `${localContent.slice(0, cursorPos)}${insertedText}${localContent.slice(cursorPos)}`;
captureEditorScrollPosition();
setLocalContent(newContent);
emitNoteChange(newContent, localCategory, localFavorite);
if (!textarea) {
return;
}
setTimeout(() => {
textarea.focus();
const selectionStart = cursorPos + prefix.length + selectionStartOffset;
const selectionEnd = cursorPos + prefix.length + selectionEndOffset;
textarea.setSelectionRange(selectionStart, selectionEnd);
}, 0);
};
const handleInsertTodoItem = () => {
const snippet = '- [ ] Task';
const placeholderStart = snippet.indexOf('Task');
insertBlockSnippet(snippet, placeholderStart, placeholderStart + 'Task'.length);
};
const handleInsertTable = () => {
const snippet = '| Column 1 | Column 2 |\n| --- | --- |\n| Value 1 | Value 2 |';
const placeholderStart = snippet.indexOf('Column 1');
insertBlockSnippet(snippet, placeholderStart, placeholderStart + 'Column 1'.length);
};
const handleFormat = (format: 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3') => { const handleFormat = (format: 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3') => {
if (!textareaRef.current) return; if (!textareaRef.current) return;
@@ -630,8 +637,9 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
} }
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end); const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
captureEditorScrollPosition();
setLocalContent(newContent); setLocalContent(newContent);
setHasUnsavedChanges(true); emitNoteChange(newContent, localCategory, localFavorite);
setTimeout(() => { setTimeout(() => {
textarea.focus(); textarea.focus();
@@ -717,13 +725,17 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Status */} {/* Status */}
{(hasUnsavedChanges || isSaving) && ( {(hasUnsavedChanges || isSaving || saveError || hasSavedState) && (
<span className={`text-xs px-2 py-1 rounded-full ${ <span className={`text-xs px-2 py-1 rounded-full ${
isSaving saveError
? 'bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400'
: isSaving
? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400' ? 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
: 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400' : hasUnsavedChanges
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400'
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400'
}`}> }`}>
{isSaving ? 'Saving...' : 'Unsaved'} {saveError ? 'Save failed' : isSaving ? 'Saving...' : hasUnsavedChanges ? 'Unsaved' : 'Saved'}
</span> </span>
)} )}
@@ -745,8 +757,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</button> </button>
<button <button
onClick={handleSave} onClick={() => {
disabled={!hasUnsavedChanges || isSaving} if (note?.draftId) {
void onSaveNote(note.draftId);
}
}}
disabled={!hasUnsavedChanges || isSaving || !note?.draftId}
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
hasUnsavedChanges && !isSaving hasUnsavedChanges && !isSaving
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30' ? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
@@ -782,16 +798,26 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
? 'text-blue-500 cursor-wait' ? 'text-blue-500 cursor-wait'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`} }`}
title={isExportingPDF ? "Generating PDF..." : "Export as PDF"} title={isExportingPDF ? `${exportActionLabel} in progress...` : exportActionLabel}
> >
{isExportingPDF ? ( {isExportingPDF ? (
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"> <svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg> </svg>
) : desktopRuntime === 'electron' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 16V4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12l4 4 4-4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 20h14" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20v-2a1 1 0 011-1h8a1 1 0 011 1v2" />
</svg>
) : ( ) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9V4a1 1 0 011-1h10a1 1 0 011 1v5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18H5a2 2 0 01-2-2v-5a2 2 0 012-2h14a2 2 0 012 2v5a2 2 0 01-2 2h-1" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 14h8v7H8z" />
<circle cx="17" cy="11.5" r="0.75" fill="currentColor" stroke="none" />
</svg> </svg>
)} )}
</button> </button>
@@ -823,7 +849,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div ref={editorScrollContainerRef} className="flex-1 overflow-y-auto">
<div className={`min-h-full ${isFocusMode ? 'max-w-3xl mx-auto w-full' : ''}`}> <div className={`min-h-full ${isFocusMode ? 'max-w-3xl mx-auto w-full' : ''}`}>
{isPreviewMode ? ( {isPreviewMode ? (
<div className="relative"> <div className="relative">
@@ -837,10 +863,11 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
</div> </div>
)} )}
<div <div
ref={previewContentRef}
className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono [&_code]:py-0 [&_code]:px-1 [&_code]:align-baseline [&_code]:leading-none [&_img]:max-w-full [&_img]:rounded-lg [&_img]:shadow-md`} className={`prose prose-slate dark:prose-invert p-8 ${isFocusMode ? '' : 'max-w-none'} [&_code]:font-mono [&_pre]:font-mono [&_code]:py-0 [&_code]:px-1 [&_code]:align-baseline [&_code]:leading-none [&_img]:max-w-full [&_img]:rounded-lg [&_img]:shadow-md`}
style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }} style={{ fontSize: `${previewFontSize}px`, fontFamily: previewFont }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: marked.parse(processedContent || '', { async: false }) as string __html: renderPreviewHtml(processedContent || '')
}} }}
/> />
</div> </div>
@@ -850,6 +877,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
<InsertToolbar <InsertToolbar
textareaRef={textareaRef} textareaRef={textareaRef}
onInsertLink={handleInsertLink} onInsertLink={handleInsertLink}
onInsertTodoItem={handleInsertTodoItem}
onInsertTable={handleInsertTable}
onInsertFile={handleInsertFile} onInsertFile={handleInsertFile}
isUploading={isUploading} isUploading={isUploading}
/> />
@@ -863,12 +892,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={localContent} value={localContent}
onChange={(e) => { onChange={(e) => handleContentChange(e.target.value)}
handleContentChange(e.target.value);
// Auto-resize textarea to fit content
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
}}
className="w-full resize-none border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 overflow-hidden" className="w-full resize-none border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 overflow-hidden"
style={{ fontSize: `${editorFontSize}px`, lineHeight: '1.6', minHeight: '100%', fontFamily: editorFont }} style={{ fontSize: `${editorFontSize}px`, lineHeight: '1.6', minHeight: '100%', fontFamily: editorFont }}
placeholder="Start writing in markdown..." placeholder="Start writing in markdown..."

View File

@@ -5,8 +5,8 @@ import { categoryColorsSync } from '../services/categoryColorsSync';
interface NotesListProps { interface NotesListProps {
notes: Note[]; notes: Note[];
selectedNoteId: number | string | null; selectedNoteDraftId: string | null;
onSelectNote: (id: number | string) => void; onSelectNote: (draftId: string) => void | Promise<void>;
onCreateNote: () => void; onCreateNote: () => void;
onDeleteNote: (note: Note) => void; onDeleteNote: (note: Note) => void;
onSync: () => void; onSync: () => void;
@@ -22,7 +22,7 @@ interface NotesListProps {
export function NotesList({ export function NotesList({
notes, notes,
selectedNoteId, selectedNoteDraftId,
onSelectNote, onSelectNote,
onCreateNote, onCreateNote,
onDeleteNote, onDeleteNote,
@@ -93,11 +93,6 @@ export function NotesList({
const handleDeleteClick = (note: Note, e: React.MouseEvent) => { const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
// Prevent deletion if there are unsaved changes on a different note
if (hasUnsavedChanges && note.id !== selectedNoteId) {
return;
}
if (deleteClickedId === note.id) { if (deleteClickedId === note.id) {
// Second click - actually delete // Second click - actually delete
onDeleteNote(note); onDeleteNote(note);
@@ -249,22 +244,18 @@ export function NotesList({
) : ( ) : (
notes.map((note) => ( notes.map((note) => (
<div <div
key={note.id} key={note.draftId ?? note.id}
onClick={() => { onClick={() => {
// Prevent switching if current note has unsaved changes if (note.draftId) {
if (hasUnsavedChanges && note.id !== selectedNoteId) { void onSelectNote(note.draftId);
return;
} }
onSelectNote(note.id);
}} }}
className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${ className={`p-3 border-b border-gray-200 dark:border-gray-700 transition-colors group ${
note.id === selectedNoteId note.draftId === selectedNoteDraftId
? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500' ? 'bg-blue-50 dark:bg-gray-800 border-l-4 border-l-blue-500'
: hasUnsavedChanges
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800' : 'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800'
}`} }`}
title={hasUnsavedChanges && note.id !== selectedNoteId ? 'Save current note before switching' : ''} title={hasUnsavedChanges && note.draftId !== selectedNoteDraftId ? 'Saving current note before switching' : ''}
> >
<div className="flex items-start justify-between mb-1"> <div className="flex items-start justify-between mb-1">
<div className="flex items-center flex-1 min-w-0"> <div className="flex items-center flex-1 min-w-0">

View File

@@ -0,0 +1,388 @@
import { useEffect, useState } from 'react';
import { emit, listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow, LogicalSize } from '@tauri-apps/api/window';
import {
PrintExportPayload,
} from '../printExport';
interface PrintViewProps {
jobId: string;
}
const waitForImages = async () => {
const images = Array.from(document.images).filter((image) => !image.complete);
await Promise.all(
images.map(
(image) =>
new Promise<void>((resolve) => {
image.addEventListener('load', () => resolve(), { once: true });
image.addEventListener('error', () => resolve(), { once: true });
})
)
);
};
export function PrintView({ jobId }: PrintViewProps) {
const [payload, setPayload] = useState<PrintExportPayload | null>(null);
const [error, setError] = useState('');
useEffect(() => {
const currentWindow = getCurrentWindow();
let timeoutId = 0;
let cleanup = () => {};
void (async () => {
cleanup = await listen<PrintExportPayload>(
'print-export-payload',
(event) => {
window.clearTimeout(timeoutId);
setPayload(event.payload);
},
{
target: { kind: 'WebviewWindow', label: currentWindow.label },
}
);
await emit('print-export-ready', { jobId });
timeoutId = window.setTimeout(() => {
setError('Print data was not received. Please close this window and try exporting again.');
}, 5000);
})();
return () => {
window.clearTimeout(timeoutId);
cleanup();
};
}, [jobId]);
useEffect(() => {
if (!payload) return;
const currentWindow = getCurrentWindow();
let cancelled = false;
let printFlowStarted = false;
let lostFocusDuringPrint = false;
let closeTimerId = 0;
let destroyIntervalId = 0;
let removeFocusListener = () => {};
document.title = payload.fileName;
const closePrintWindow = () => {
if (cancelled) return;
if (destroyIntervalId) return;
window.clearTimeout(closeTimerId);
closeTimerId = window.setTimeout(() => {
destroyIntervalId = window.setInterval(() => {
void currentWindow.destroy().catch(() => undefined);
}, 250);
void currentWindow.destroy().catch(() => undefined);
}, 150);
};
const handleAfterPrint = () => {
closePrintWindow();
};
window.addEventListener('afterprint', handleAfterPrint);
void (async () => {
try {
removeFocusListener = await currentWindow.onFocusChanged(({ payload: focused }) => {
if (!printFlowStarted) return;
if (!focused) {
lostFocusDuringPrint = true;
return;
}
if (lostFocusDuringPrint) {
closePrintWindow();
}
});
if ('fonts' in document) {
await document.fonts.ready;
}
await waitForImages();
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
);
if (cancelled) return;
await currentWindow.show().catch(() => undefined);
await currentWindow.setFocus().catch(() => undefined);
window.setTimeout(async () => {
if (cancelled) return;
try {
printFlowStarted = true;
await invoke('plugin:webview|print', {
label: currentWindow.label,
});
await currentWindow
.setSize(new LogicalSize(520, 260))
.catch(() => undefined);
closePrintWindow();
} catch (err) {
console.error('Native webview print failed, falling back to window.print():', err);
printFlowStarted = true;
window.print();
}
}, 120);
} catch (err) {
console.error('Failed to initialize print view:', err);
setError('The print view could not be prepared. Please close this window and try again.');
}
})();
return () => {
cancelled = true;
window.clearTimeout(closeTimerId);
window.clearInterval(destroyIntervalId);
removeFocusListener();
window.removeEventListener('afterprint', handleAfterPrint);
};
}, [jobId, payload]);
if (error) {
return (
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
<h1 className="mb-3 text-2xl font-semibold">Print Export Failed</h1>
<p>{error}</p>
</div>
</div>
);
}
if (!payload) {
return (
<div className="min-h-screen bg-gray-100 px-8 py-12 text-gray-900">
<div className="mx-auto max-w-2xl rounded-2xl bg-white p-8 shadow-sm">
Preparing print view...
</div>
</div>
);
}
return (
<>
<style>{`
:root {
color-scheme: light;
}
body {
margin: 0;
background: #e5e7eb;
color: #0f172a;
}
@page {
size: A4;
margin: 18mm 16mm 18mm 16mm;
}
@media print {
body {
background: #ffffff;
}
.print-shell {
display: block !important;
}
.print-status {
display: none !important;
}
}
`}</style>
<div className="print-status min-h-screen bg-slate-100 px-6 py-6 text-slate-900">
<div className="mx-auto flex max-w-md items-center gap-4 rounded-2xl bg-white px-5 py-4 shadow-lg">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-slate-200 border-t-slate-600" />
<div>
<div className="text-sm font-semibold">Opening system print dialog...</div>
<div className="text-sm text-slate-500">{payload.fileName}</div>
</div>
</div>
</div>
<div className="print-shell absolute left-[-200vw] top-0 min-h-screen w-[820px] bg-gray-200 px-6 py-8 print:static print:min-h-0 print:w-auto print:bg-white print:px-0 print:py-0">
<article
className="mx-auto min-h-[calc(100vh-4rem)] max-w-[820px] rounded-[20px] bg-white px-14 py-12 text-slate-900 shadow-xl print:min-h-0 print:max-w-none print:rounded-none print:px-0 print:py-0 print:shadow-none"
style={{
fontFamily: `"${payload.previewFont}", Georgia, serif`,
fontSize: `${payload.previewFontSize}px`,
lineHeight: 1.7,
}}
>
<style>{`
.print-note {
color: #0f172a;
}
.print-note h1,
.print-note h2,
.print-note h3 {
color: #020617;
font-weight: 700;
page-break-after: avoid;
}
.print-note h1 {
font-size: 2em;
line-height: 1.15;
margin: 0 0 1.35em;
letter-spacing: -0.015em;
text-align: center;
}
.print-note h2 {
font-size: 1.55em;
line-height: 1.2;
margin: 1.25em 0 0.45em;
}
.print-note h3 {
font-size: 1.25em;
line-height: 1.3;
margin: 1.1em 0 0.4em;
}
.print-note p {
margin: 0 0 0.9em;
}
.print-note ul,
.print-note ol {
margin: 0.75em 0 1em;
padding-left: 1.7em;
list-style-position: outside;
}
.print-note ul {
list-style-type: disc;
}
.print-note ol {
list-style-type: decimal;
}
.print-note li {
margin: 0.28em 0;
padding-left: 0.18em;
}
.print-note li > p {
margin: 0;
}
.print-note li::marker {
color: #334155;
}
.print-note ul:has(> li > input[type="checkbox"]) {
list-style: none;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"]) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.55em;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"])::marker {
content: '';
}
.print-note li > input[type="checkbox"] {
flex-shrink: 0;
margin: 0.3em 0 0;
}
.print-note blockquote {
margin: 1.15em 0;
padding-left: 1em;
border-left: 3px solid #cbd5e1;
color: #475569;
font-style: italic;
}
.print-note blockquote > :first-child {
margin-top: 0;
}
.print-note blockquote > :last-child {
margin-bottom: 0;
}
.print-note code {
font-family: "Source Code Pro", "Courier New", monospace;
font-size: 0.92em;
}
.print-note :not(pre) > code {
padding: 0.08em 0.28em;
border-radius: 4px;
border: 1px solid #dbe4f0;
background: #f8fafc;
color: #1e293b;
}
.print-note pre {
margin: 1em 0 1.15em;
padding: 0.9em 1em;
border-radius: 10px;
background: #f8fafc;
border: 1px solid #e2e8f0;
color: #0f172a;
white-space: pre-wrap;
overflow-wrap: anywhere;
page-break-inside: avoid;
}
.print-note pre code {
font-size: 0.92em;
line-height: 1.55;
}
.print-note a {
color: #1d4ed8;
text-decoration: underline;
}
.print-note img {
display: block;
max-width: 100%;
height: auto;
margin: 1.25em auto;
border-radius: 10px;
page-break-inside: avoid;
}
.print-note hr {
border: 0;
border-top: 1px solid #cbd5e1;
margin: 1.6em 0;
}
`}</style>
<div
className="print-note"
dangerouslySetInnerHTML={{ __html: payload.html }}
/>
</article>
</div>
</>
);
}

View File

@@ -213,3 +213,39 @@ code {
.dark .ProseMirror hr { .dark .ProseMirror hr {
border-top-color: #374151; border-top-color: #374151;
} }
/* Task list styling for preview mode */
.prose ul li:has(> input[type="checkbox"]) {
list-style: none;
margin-left: -1.5em;
display: flex;
align-items: flex-start;
}
.prose ul:has(li > input[type="checkbox"]) {
list-style: none;
padding-left: 1.5em;
}
.prose input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
margin-top: 0.32rem;
cursor: pointer;
flex-shrink: 0;
}
.prose input[type="checkbox"]:checked {
accent-color: #16a34a;
}
.prose .markdown-table-wrapper {
overflow-x: auto;
margin: 1.5em 0;
}
.prose .markdown-table-wrapper table {
margin: 0;
min-width: 100%;
}

391
src/printExport.ts Normal file
View File

@@ -0,0 +1,391 @@
export interface PrintExportPayload {
fileName: string;
title: string;
html: string;
previewFont: string;
previewFontSize: number;
previewFontFaceCss?: string;
}
export const PRINT_EXPORT_QUERY_PARAM = 'printJob';
const PRINT_DOCUMENT_CSP = [
"default-src 'none'",
"style-src 'unsafe-inline'",
"img-src data: blob: https: http:",
"font-src data:",
"object-src 'none'",
"base-uri 'none'",
].join('; ');
export const getNoteTitleFromContent = (content: string) => {
const firstLine = content
.split('\n')
.map((line) => line.trim())
.find((line) => line.length > 0);
return (firstLine || 'Untitled').replace(/^#+\s*/, '').trim();
};
export const sanitizeFileName = (name: string) =>
name
.replace(/[\\/:*?"<>|]/g, '-')
.replace(/\s+/g, ' ')
.trim() || 'note';
const escapeHtml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escapeFontFamily = (value: string) =>
value.replace(/["\\]/g, '\\$&');
interface PrintFontAsset {
fileName: string;
fontStyle: 'normal' | 'italic';
fontWeight: string;
}
const PRINT_FONT_ASSETS: Record<string, PrintFontAsset[]> = {
Merriweather: [
{
fileName: 'Merriweather-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '300 900',
},
{
fileName: 'Merriweather-Italic-VariableFont_opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '300 900',
},
],
'Crimson Pro': [
{
fileName: 'CrimsonPro-VariableFont_wght.ttf',
fontStyle: 'normal',
fontWeight: '200 900',
},
{
fileName: 'CrimsonPro-Italic-VariableFont_wght.ttf',
fontStyle: 'italic',
fontWeight: '200 900',
},
],
'Roboto Serif': [
{
fileName: 'RobotoSerif-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'normal',
fontWeight: '100 900',
},
{
fileName: 'RobotoSerif-Italic-VariableFont_GRAD,opsz,wdth,wght.ttf',
fontStyle: 'italic',
fontWeight: '100 900',
},
],
Average: [
{
fileName: 'Average-Regular.ttf',
fontStyle: 'normal',
fontWeight: '400',
},
],
};
const fontDataUrlCache = new Map<string, Promise<string>>();
const blobToDataUrl = (blob: Blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error ?? new Error('Failed to read font file.'));
reader.readAsDataURL(blob);
});
const getBundledFontUrl = (fileName: string) =>
new URL(`./fonts/${fileName}`, window.location.href).toString();
const loadBundledFontDataUrl = async (fileName: string) => {
const cached = fontDataUrlCache.get(fileName);
if (cached) {
return cached;
}
const pending = (async () => {
const response = await fetch(getBundledFontUrl(fileName));
if (!response.ok) {
throw new Error(`Failed to load bundled font ${fileName}: ${response.status}`);
}
return blobToDataUrl(await response.blob());
})();
fontDataUrlCache.set(fileName, pending);
return pending;
};
export const loadPrintFontFaceCss = async (fontFamily: string) => {
const fontAssets = PRINT_FONT_ASSETS[fontFamily];
if (!fontAssets) {
return '';
}
const rules = await Promise.all(
fontAssets.map(async ({ fileName, fontStyle, fontWeight }) => {
try {
const dataUrl = await loadBundledFontDataUrl(fileName);
return `@font-face {
font-family: "${escapeFontFamily(fontFamily)}";
font-style: ${fontStyle};
font-weight: ${fontWeight};
font-display: swap;
src: url("${dataUrl}") format("truetype");
}`;
} catch (error) {
console.error(`Failed to embed preview font "${fontFamily}" from ${fileName}:`, error);
return '';
}
})
);
return rules.filter(Boolean).join('\n');
};
export const buildPrintDocument = (payload: PrintExportPayload) => {
const fontFamily = `"${escapeFontFamily(payload.previewFont)}", Georgia, serif`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="Content-Security-Policy" content="${PRINT_DOCUMENT_CSP}" />
<title>${escapeHtml(payload.fileName)}</title>
<style>
${payload.previewFontFaceCss ?? ''}
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: #ffffff;
color: #0f172a;
}
body {
font-family: ${fontFamily};
font-size: ${payload.previewFontSize}px;
line-height: 1.7;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
@page {
size: A4;
margin: 18mm 16mm 18mm 16mm;
}
article {
color: #0f172a;
}
.print-note h1,
.print-note h2,
.print-note h3 {
color: #020617;
font-weight: 700;
page-break-after: avoid;
}
.print-note h1 {
font-size: 2em;
line-height: 1.15;
margin: 0 0 1.35em;
letter-spacing: -0.015em;
text-align: center;
}
.print-note h2 {
font-size: 1.55em;
line-height: 1.2;
margin: 1.25em 0 0.45em;
}
.print-note h3 {
font-size: 1.25em;
line-height: 1.3;
margin: 1.1em 0 0.4em;
}
.print-note p {
margin: 0 0 0.9em;
}
.print-note ul,
.print-note ol {
margin: 0.75em 0 1em;
padding-left: 1.7em;
list-style-position: outside;
}
.print-note ul {
list-style-type: disc;
}
.print-note ol {
list-style-type: decimal;
}
.print-note li {
margin: 0.28em 0;
padding-left: 0.18em;
}
.print-note li > p {
margin: 0;
}
.print-note li::marker {
color: #334155;
}
.print-note ul:has(> li > input[type="checkbox"]) {
list-style: none;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"]) {
list-style: none;
display: flex;
align-items: flex-start;
gap: 0.55em;
padding-left: 0;
}
.print-note li:has(> input[type="checkbox"])::marker {
content: '';
}
.print-note li > input[type="checkbox"] {
flex-shrink: 0;
margin: 0.3em 0 0;
}
.print-note blockquote {
margin: 1.15em 0;
padding-left: 1em;
border-left: 3px solid #cbd5e1;
color: #475569;
font-style: italic;
}
.print-note blockquote > :first-child {
margin-top: 0;
}
.print-note blockquote > :last-child {
margin-bottom: 0;
}
.print-note pre {
margin: 1.15em 0 1.3em;
padding: 0.95em 1.05em;
overflow-x: auto;
border: 1px solid #dbe4f0;
border-radius: 12px;
background: #f5f7fb;
color: #0f172a;
page-break-inside: avoid;
}
.print-note pre code {
background: transparent;
border: 0;
padding: 0;
border-radius: 0;
font-size: 0.92em;
color: inherit;
}
.print-note code {
font-family: "SFMono-Regular", "SF Mono", "JetBrains Mono", "Fira Code", "Source Code Pro", Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 0.08em 0.38em;
border: 1px solid #dbe4f0;
border-radius: 0.42em;
background: #f5f7fb;
color: #0f172a;
}
.print-note a {
color: #2563eb;
text-decoration: underline;
text-decoration-thickness: 0.08em;
text-underline-offset: 0.14em;
}
.print-note strong {
font-weight: 700;
}
.print-note em {
font-style: italic;
}
.print-note del {
text-decoration: line-through;
}
.print-note hr {
border: 0;
border-top: 1px solid #cbd5e1;
margin: 1.6em 0;
}
.print-note img {
display: block;
max-width: 100%;
height: auto;
margin: 1.2em auto;
border-radius: 12px;
page-break-inside: avoid;
}
.print-note table {
width: 100%;
border-collapse: collapse;
margin: 1em 0 1.2em;
font-size: 0.95em;
}
.print-note th,
.print-note td {
border: 1px solid #dbe4f0;
padding: 0.5em 0.65em;
text-align: left;
vertical-align: top;
}
.print-note th {
background: #f8fafc;
font-weight: 600;
}
</style>
</head>
<body>
<article class="print-note">${payload.html}</article>
</body>
</html>`;
};

139
src/services/desktop.ts Normal file
View File

@@ -0,0 +1,139 @@
import {
buildPrintDocument,
PrintExportPayload,
PRINT_EXPORT_QUERY_PARAM,
} from '../printExport';
export interface DesktopMessageOptions {
title?: string;
kind?: 'info' | 'warning' | 'error';
}
interface ExportPdfResult {
canceled: boolean;
filePath?: string;
}
const isElectronRuntime = () =>
typeof window !== 'undefined' && typeof window.electronDesktop !== 'undefined';
const isTauriRuntime = () =>
typeof window !== 'undefined' &&
(
typeof (window as Window & { __TAURI__?: unknown }).__TAURI__ !== 'undefined' ||
typeof (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== 'undefined'
);
export const getDesktopRuntime = () => {
if (isElectronRuntime()) return 'electron';
if (isTauriRuntime()) return 'tauri';
return 'browser';
};
export const showDesktopMessage = async (
messageText: string,
options: DesktopMessageOptions = {}
) => {
if (window.electronDesktop) {
await window.electronDesktop.showMessage({
message: messageText,
...options,
});
return;
}
if (isTauriRuntime()) {
const { message } = await import('@tauri-apps/plugin-dialog');
await message(messageText, options);
return;
}
window.alert(messageText);
};
export const exportPdfDocument = async (
payload: PrintExportPayload
): Promise<ExportPdfResult> => {
if (window.electronDesktop) {
return window.electronDesktop.exportPdf({
...payload,
documentHtml: buildPrintDocument(payload),
});
}
if (isTauriRuntime()) {
await exportPdfDocumentWithTauri(payload);
return { canceled: false };
}
throw new Error('PDF export is only available in a desktop runtime.');
};
async function exportPdfDocumentWithTauri(payload: PrintExportPayload) {
const [{ emitTo, listen }, { WebviewWindow }] = await Promise.all([
import('@tauri-apps/api/event'),
import('@tauri-apps/api/webviewWindow'),
]);
const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const windowLabel = `print-export-${jobId}`;
let printWindow: any = null;
let unlistenReady: any = null;
try {
await new Promise<void>((resolve, reject) => {
let settled = false;
const timeoutId = window.setTimeout(() => {
if (settled) return;
settled = true;
unlistenReady?.();
reject(new Error('Print view initialization timed out.'));
}, 10000);
listen<{ jobId: string }>('print-export-ready', (event) => {
if (settled || event.payload.jobId !== jobId) return;
settled = true;
window.clearTimeout(timeoutId);
unlistenReady?.();
void emitTo(windowLabel, 'print-export-payload', payload)
.then(() => resolve())
.catch(reject);
})
.then((unlisten) => {
unlistenReady = unlisten;
printWindow = new WebviewWindow(windowLabel, {
url: `/?${PRINT_EXPORT_QUERY_PARAM}=${encodeURIComponent(jobId)}`,
title: 'Opening Print Dialog',
width: 960,
height: 1100,
center: true,
visible: true,
focus: true,
skipTaskbar: true,
resizable: false,
parent: 'main',
});
void printWindow.once('tauri://error', (event: { payload?: unknown }) => {
if (settled) return;
settled = true;
window.clearTimeout(timeoutId);
unlistenReady?.();
reject(new Error(String(event.payload ?? 'Failed to create print window.')));
});
})
.catch(reject);
});
} catch (error) {
if (printWindow) {
void printWindow.close().catch(() => undefined);
}
throw error;
} finally {
if (typeof unlistenReady === 'function') {
unlistenReady();
}
}
}

View File

@@ -0,0 +1,155 @@
interface ElectronHttpRequest {
url: string;
method?: string;
headers?: Record<string, string>;
bodyText?: string;
bodyBase64?: string;
}
interface ElectronHttpResponse {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}
interface RuntimeResponse {
ok: boolean;
status: number;
statusText: string;
headers: {
get(name: string): string | null;
};
text(): Promise<string>;
json<T>(): Promise<T>;
arrayBuffer(): Promise<ArrayBuffer>;
blob(): Promise<Blob>;
}
const textDecoder = new TextDecoder();
const bytesToBase64 = (bytes: Uint8Array) => {
let binary = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
const chunk = bytes.subarray(index, index + 0x8000);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
const base64ToBytes = (value: string) => {
if (!value) {
return new Uint8Array(0);
}
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
};
const normalizeHeaders = (headers: Record<string, string>) => {
const normalized = new Map<string, string>();
for (const [key, value] of Object.entries(headers)) {
normalized.set(key.toLowerCase(), value);
}
return normalized;
};
const createRuntimeResponse = (response: ElectronHttpResponse): RuntimeResponse => {
const headers = normalizeHeaders(response.headers);
const bytes = base64ToBytes(response.bodyBase64);
const contentType = headers.get('content-type') || '';
return {
ok: response.ok,
status: response.status,
statusText: response.statusText,
headers: {
get(name: string) {
return headers.get(name.toLowerCase()) ?? null;
},
},
async text() {
return textDecoder.decode(bytes);
},
async json<T>() {
return JSON.parse(textDecoder.decode(bytes)) as T;
},
async arrayBuffer() {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
},
async blob() {
return new Blob([bytes], { type: contentType });
},
};
};
const headersToObject = (headers?: HeadersInit) => {
if (!headers) {
return {};
}
return Object.fromEntries(new Headers(headers).entries());
};
const serializeBody = async (body?: BodyInit | null) => {
if (body == null) {
return {};
}
if (typeof body === 'string') {
return { bodyText: body };
}
if (body instanceof ArrayBuffer) {
return { bodyBase64: bytesToBase64(new Uint8Array(body)) };
}
if (ArrayBuffer.isView(body)) {
return {
bodyBase64: bytesToBase64(
new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
),
};
}
if (body instanceof Blob) {
return {
bodyBase64: bytesToBase64(new Uint8Array(await body.arrayBuffer())),
};
}
throw new Error('Unsupported request body for Electron runtime.');
};
export const runtimeFetch = async (
url: string,
init: RequestInit = {},
): Promise<Response | RuntimeResponse> => {
if (!window.electronDesktop?.httpRequest) {
return fetch(url, init);
}
const payload: ElectronHttpRequest = {
url,
method: init.method,
headers: headersToObject(init.headers),
...(await serializeBody(init.body)),
};
const response = await window.electronDesktop.httpRequest(payload);
return createRuntimeResponse(response);
};

View File

@@ -4,6 +4,41 @@ import { localDB } from '../db/localDB';
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
const createDraftId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
};
const withLocalNoteFields = (note: Note, existing?: Note): Note => ({
...note,
draftId: note.draftId ?? existing?.draftId ?? createDraftId(),
localOnly: note.localOnly ?? existing?.localOnly ?? false,
pendingSave: note.pendingSave ?? existing?.pendingSave ?? false,
isSaving: false,
saveError: note.saveError ?? existing?.saveError ?? null,
lastSavedAt: note.lastSavedAt ?? existing?.lastSavedAt,
});
const toStoredNote = (note: Note): Note => ({
...note,
isSaving: false,
});
const getCachedNotes = async (): Promise<Note[]> => {
const rawNotes = await localDB.getAllNotes();
const normalizedNotes = rawNotes.map(note => withLocalNoteFields(note));
const needsNormalization = rawNotes.some((note) => !note.draftId || note.isSaving);
if (needsNormalization) {
await localDB.saveNotes(normalizedNotes.map(toStoredNote));
}
return normalizedNotes;
};
export class SyncManager { export class SyncManager {
private api: NextcloudAPI | null = null; private api: NextcloudAPI | null = null;
private isOnline: boolean = navigator.onLine; private isOnline: boolean = navigator.onLine;
@@ -46,7 +81,7 @@ export class SyncManager {
// Load notes: cache-first, then sync in background // Load notes: cache-first, then sync in background
async loadNotes(): Promise<Note[]> { async loadNotes(): Promise<Note[]> {
// Try to load from cache first (instant) // Try to load from cache first (instant)
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await getCachedNotes();
// If we have cached notes and we're offline, return them // If we have cached notes and we're offline, return them
if (!this.isOnline) { if (!this.isOnline) {
@@ -68,7 +103,9 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const notes = await this.fetchAndCacheNotes(); await this.fetchAndCacheNotes();
await this.syncFavoriteStatus();
const notes = await getCachedNotes();
this.notifyStatus('idle', 0); this.notifyStatus('idle', 0);
return notes; return notes;
} catch (error) { } catch (error) {
@@ -87,7 +124,7 @@ export class SyncManager {
// Get metadata for all notes (fast - no content) // Get metadata for all notes (fast - no content)
const serverNotes = await this.api.fetchNotesWebDAV(); const serverNotes = await this.api.fetchNotesWebDAV();
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await getCachedNotes();
// Build maps for comparison // Build maps for comparison
const serverMap = new Map(serverNotes.map(n => [n.id, n])); const serverMap = new Map(serverNotes.map(n => [n.id, n]));
@@ -97,16 +134,23 @@ export class SyncManager {
const notesToFetch: Note[] = []; const notesToFetch: Note[] = [];
for (const serverNote of serverNotes) { for (const serverNote of serverNotes) {
const cached = cachedMap.get(serverNote.id); const cached = cachedMap.get(serverNote.id);
if (cached?.pendingSave) {
continue;
}
if (!cached || cached.etag !== serverNote.etag) { if (!cached || cached.etag !== serverNote.etag) {
notesToFetch.push(serverNote); notesToFetch.push(withLocalNoteFields(serverNote, cached));
} }
} }
// Fetch content for changed notes // Fetch content for changed notes
for (const note of notesToFetch) { for (const note of notesToFetch) {
try { try {
const fullNote = await this.api.fetchNoteContentWebDAV(note); const fullNote = withLocalNoteFields(
await localDB.saveNote(fullNote); await this.api.fetchNoteContentWebDAV(note),
cachedMap.get(note.id)
);
await localDB.saveNote(toStoredNote(fullNote));
} catch (error) { } catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error); console.error(`Failed to fetch note ${note.id}:`, error);
} }
@@ -114,9 +158,13 @@ export class SyncManager {
// Remove deleted notes from cache (but protect recently modified notes) // Remove deleted notes from cache (but protect recently modified notes)
for (const cachedNote of cachedNotes) { for (const cachedNote of cachedNotes) {
if (cachedNote.localOnly) {
continue;
}
if (!serverMap.has(cachedNote.id)) { if (!serverMap.has(cachedNote.id)) {
// Don't delete notes that were recently created/updated (race condition protection) // Don't delete notes that were recently created/updated (race condition protection)
if (!this.recentlyModifiedNotes.has(cachedNote.id)) { if (!cachedNote.pendingSave && !this.recentlyModifiedNotes.has(cachedNote.id)) {
await localDB.deleteNote(cachedNote.id); await localDB.deleteNote(cachedNote.id);
} }
} }
@@ -146,7 +194,7 @@ export class SyncManager {
try { try {
console.log('Syncing favorite status from API...'); console.log('Syncing favorite status from API...');
const apiMetadata = await this.api.fetchNotesMetadata(); const apiMetadata = await this.api.fetchNotesMetadata();
const cachedNotes = await localDB.getAllNotes(); const cachedNotes = await getCachedNotes();
// Map API notes by modified timestamp + category for reliable matching // Map API notes by modified timestamp + category for reliable matching
// (titles can differ between API and WebDAV) // (titles can differ between API and WebDAV)
@@ -163,6 +211,10 @@ export class SyncManager {
// Update favorite status in cache for matching notes // Update favorite status in cache for matching notes
for (const cachedNote of cachedNotes) { for (const cachedNote of cachedNotes) {
if (cachedNote.localOnly) {
continue;
}
// Try timestamp match first (most reliable) // Try timestamp match first (most reliable)
const timestampKey = `${cachedNote.modified}:${cachedNote.category}`; const timestampKey = `${cachedNote.modified}:${cachedNote.category}`;
let apiData = apiByTimestamp.get(timestampKey); let apiData = apiByTimestamp.get(timestampKey);
@@ -176,7 +228,7 @@ export class SyncManager {
if (apiData && cachedNote.favorite !== apiData.favorite) { if (apiData && cachedNote.favorite !== apiData.favorite) {
console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`); console.log(`Updating favorite status for "${cachedNote.title}": ${cachedNote.favorite} -> ${apiData.favorite}`);
cachedNote.favorite = apiData.favorite; cachedNote.favorite = apiData.favorite;
await localDB.saveNote(cachedNote); await localDB.saveNote(toStoredNote(cachedNote));
} }
} }
@@ -191,14 +243,19 @@ export class SyncManager {
private async fetchAndCacheNotes(): Promise<Note[]> { private async fetchAndCacheNotes(): Promise<Note[]> {
if (!this.api) throw new Error('API not initialized'); if (!this.api) throw new Error('API not initialized');
const cachedNotes = await getCachedNotes();
const cachedMap = new Map(cachedNotes.map(note => [note.id, note]));
const serverNotes = await this.api.fetchNotesWebDAV(); const serverNotes = await this.api.fetchNotesWebDAV();
const notesWithContent: Note[] = []; const notesWithContent: Note[] = [];
for (const note of serverNotes) { for (const note of serverNotes) {
try { try {
const fullNote = await this.api.fetchNoteContentWebDAV(note); const fullNote = withLocalNoteFields(
await this.api.fetchNoteContentWebDAV(note),
cachedMap.get(note.id)
);
notesWithContent.push(fullNote); notesWithContent.push(fullNote);
await localDB.saveNote(fullNote); await localDB.saveNote(toStoredNote(fullNote));
} catch (error) { } catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error); console.error(`Failed to fetch note ${note.id}:`, error);
} }
@@ -218,8 +275,8 @@ export class SyncManager {
} }
try { try {
const fullNote = await this.api.fetchNoteContentWebDAV(note); const fullNote = withLocalNoteFields(await this.api.fetchNoteContentWebDAV(note), note);
await localDB.saveNote(fullNote); await localDB.saveNote(toStoredNote(fullNote));
return fullNote; return fullNote;
} catch (error) { } catch (error) {
throw error; throw error;
@@ -239,8 +296,8 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const note = await this.api.createNoteWebDAV(title, content, category); const note = withLocalNoteFields(await this.api.createNoteWebDAV(title, content, category));
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
// Protect this note from being deleted by background sync for a short window // Protect this note from being deleted by background sync for a short window
this.protectNote(note.id); this.protectNote(note.id);
@@ -266,7 +323,7 @@ export class SyncManager {
if (!this.isOnline) { if (!this.isOnline) {
// Update locally, will sync when back online // Update locally, will sync when back online
note.favorite = favorite; note.favorite = favorite;
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
return; return;
} }
@@ -284,12 +341,12 @@ export class SyncManager {
// Update local cache // Update local cache
note.favorite = favorite; note.favorite = favorite;
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
} catch (error) { } catch (error) {
console.error('Failed to update favorite status:', error); console.error('Failed to update favorite status:', error);
// Still update locally // Still update locally
note.favorite = favorite; note.favorite = favorite;
await localDB.saveNote(note); await localDB.saveNote(toStoredNote(note));
} }
} }
@@ -307,14 +364,14 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const oldId = note.id; const oldId = note.id;
const updatedNote = await this.api.updateNoteWebDAV(note); const updatedNote = withLocalNoteFields(await this.api.updateNoteWebDAV(note), note);
// If the note ID changed (due to filename change), delete the old cache entry // If the note ID changed (due to filename change), delete the old cache entry
if (oldId !== updatedNote.id) { if (oldId !== updatedNote.id) {
await localDB.deleteNote(oldId); await localDB.deleteNote(oldId);
} }
await localDB.saveNote(updatedNote); await localDB.saveNote(toStoredNote(updatedNote));
// Protect this note from being deleted by background sync for a short window // Protect this note from being deleted by background sync for a short window
this.protectNote(updatedNote.id); this.protectNote(updatedNote.id);
@@ -366,9 +423,9 @@ export class SyncManager {
try { try {
this.notifyStatus('syncing', 0); this.notifyStatus('syncing', 0);
const movedNote = await this.api.moveNoteWebDAV(note, newCategory); const movedNote = withLocalNoteFields(await this.api.moveNoteWebDAV(note, newCategory), note);
await localDB.deleteNote(note.id); await localDB.deleteNote(note.id);
await localDB.saveNote(movedNote); await localDB.saveNote(toStoredNote(movedNote));
// Protect the moved note from being deleted by background sync // Protect the moved note from being deleted by background sync
this.protectNote(movedNote.id); this.protectNote(movedNote.id);

View File

@@ -9,6 +9,12 @@ export interface Note {
modified: number; modified: number;
filename?: string; // WebDAV: actual filename on server filename?: string; // WebDAV: actual filename on server
path?: string; // WebDAV: full path including category path?: string; // WebDAV: full path including category
draftId?: string; // stable client-side identity across renames/moves
localOnly?: boolean; // exists only in local cache until first successful server create
pendingSave?: boolean; // local-first dirty flag
isSaving?: boolean; // local transient UI state
saveError?: string | null; // last save error, if any
lastSavedAt?: number; // local timestamp for "Saved" feedback
} }
export interface APIConfig { export interface APIConfig {

45
src/vite-env.d.ts vendored
View File

@@ -1 +1,46 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ElectronDesktopMessageOptions {
message: string;
title?: string;
kind?: 'info' | 'warning' | 'error';
}
interface ElectronDesktopExportPayload {
fileName: string;
title: string;
html: string;
previewFont: string;
previewFontSize: number;
documentHtml: string;
}
interface ElectronDesktopExportResult {
canceled: boolean;
filePath?: string;
}
interface ElectronDesktopHttpRequest {
url: string;
method?: string;
headers?: Record<string, string>;
bodyText?: string;
bodyBase64?: string;
}
interface ElectronDesktopHttpResponse {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
bodyBase64: string;
}
interface Window {
electronDesktop?: {
showMessage: (options: ElectronDesktopMessageOptions) => Promise<void>;
exportPdf: (payload: ElectronDesktopExportPayload) => Promise<ElectronDesktopExportResult>;
httpRequest: (payload: ElectronDesktopHttpRequest) => Promise<ElectronDesktopHttpResponse>;
getRuntime: () => 'electron';
};
}

View File

@@ -5,8 +5,9 @@ import react from "@vitejs/plugin-react";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(async () => ({ export default defineConfig(async ({ command }) => ({
plugins: [react()], plugins: [react()],
base: command === "build" ? "./" : "/",
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// //
@@ -17,6 +18,10 @@ export default defineConfig(async () => ({
port: 1420, port: 1420,
strictPort: true, strictPort: true,
host: host || false, host: host || false,
headers: {
"Content-Security-Policy":
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; connect-src 'self' https: http: ws: wss:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
},
hmr: host hmr: host
? { ? {
protocol: "ws", protocol: "ws",