Migrate to Electron and implement native PDF export
Major changes: - Migrate from Tauri to Electron as primary desktop runtime - Implement native print dialog for PDF export via Electron webview - Add desktop runtime abstraction layer (supports both Electron and Tauri) - Implement task list rendering in preview mode - Add favorite notes sorting to display starred notes at top - Add attachment upload functionality with file picker - Improve sync reliability and Unicode filename support - Add category color sync across devices via WebDAV - Update documentation for Electron workflow Technical improvements: - Add Electron main process and preload bridge - Create desktop service layer for runtime-agnostic operations - Implement runtimeFetch for proxying network requests through Electron - Add PrintView component for native print rendering - Extract print/PDF utilities to shared module - Update build configuration for Electron integration
This commit is contained in:
636
src/App.tsx
636
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { LoginView } from './components/LoginView';
|
||||
import { NotesList } from './components/NotesList';
|
||||
import { NoteEditor } from './components/NoteEditor';
|
||||
@@ -9,12 +9,89 @@ import { syncManager, SyncStatus } from './services/syncManager';
|
||||
import { localDB } from './db/localDB';
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||
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 [api, setApi] = useState<NextcloudAPI | null>(null);
|
||||
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 [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
@@ -24,7 +101,6 @@ function App() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
|
||||
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light');
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [editorFont, setEditorFont] = useState('Source Code Pro');
|
||||
const [editorFontSize, setEditorFontSize] = useState(14);
|
||||
const [previewFont, setPreviewFont] = useState('Merriweather');
|
||||
@@ -32,6 +108,89 @@ function App() {
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>('idle');
|
||||
const [pendingSyncCount, setPendingSyncCount] = useState(0);
|
||||
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(() => {
|
||||
const initApp = async () => {
|
||||
@@ -113,7 +272,7 @@ function App() {
|
||||
// Reload notes from cache after background sync completes
|
||||
// Don't call loadNotes() as it triggers another sync - just reload from cache
|
||||
const cachedNotes = await localDB.getAllNotes();
|
||||
setNotes(cachedNotes.sort((a, b) => b.modified - a.modified));
|
||||
applyLoadedNotes(cachedNotes);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -125,13 +284,43 @@ function App() {
|
||||
}
|
||||
}, [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 () => {
|
||||
try {
|
||||
const loadedNotes = await syncManager.loadNotes();
|
||||
setNotes(loadedNotes.sort((a, b) => b.modified - a.modified));
|
||||
if (!selectedNoteId && loadedNotes.length > 0) {
|
||||
setSelectedNoteId(loadedNotes[0].id);
|
||||
}
|
||||
applyLoadedNotes(loadedNotes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load notes:', error);
|
||||
}
|
||||
@@ -139,6 +328,7 @@ function App() {
|
||||
|
||||
const syncNotes = async () => {
|
||||
try {
|
||||
await flushAllPendingSaves();
|
||||
await syncManager.syncWithServer();
|
||||
await loadNotes();
|
||||
} catch (error) {
|
||||
@@ -169,8 +359,11 @@ function App() {
|
||||
categoryColorsSync.setAPI(null);
|
||||
setUsername('');
|
||||
setNotes([]);
|
||||
setSelectedNoteId(null);
|
||||
notesRef.current = [];
|
||||
setSelectedNoteDraftId(null);
|
||||
setIsLoggedIn(false);
|
||||
saveControllersRef.current.clear();
|
||||
savedSnapshotsRef.current.clear();
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => {
|
||||
@@ -199,25 +392,66 @@ function App() {
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async (note: Note, favorite: boolean) => {
|
||||
const draftId = note.draftId;
|
||||
if (!draftId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optimisticNote = {
|
||||
...note,
|
||||
favorite,
|
||||
saveError: null,
|
||||
};
|
||||
|
||||
setSortedNotes(previousNotes =>
|
||||
previousNotes.map(currentNote =>
|
||||
currentNote.draftId === draftId ? optimisticNote : currentNote
|
||||
)
|
||||
);
|
||||
persistNoteToCache(optimisticNote);
|
||||
|
||||
try {
|
||||
await syncManager.updateFavoriteStatus(note, favorite);
|
||||
// Update local state
|
||||
setNotes(prevNotes =>
|
||||
prevNotes.map(n => n.id === note.id ? { ...n, favorite } : n)
|
||||
);
|
||||
await syncManager.updateFavoriteStatus(optimisticNote, favorite);
|
||||
const snapshot = savedSnapshotsRef.current.get(draftId);
|
||||
if (snapshot) {
|
||||
savedSnapshotsRef.current.set(draftId, {
|
||||
...snapshot,
|
||||
favorite,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Toggle favorite failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNote = async () => {
|
||||
try {
|
||||
const note = await syncManager.createNote('New Note', '', selectedCategory);
|
||||
setNotes([note, ...notes]);
|
||||
setSelectedNoteId(note.id);
|
||||
} catch (error) {
|
||||
console.error('Create note failed:', error);
|
||||
}
|
||||
const draftId = createDraftId();
|
||||
const note: Note = {
|
||||
id: `local:${draftId}`,
|
||||
etag: '',
|
||||
readonly: false,
|
||||
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) => {
|
||||
@@ -233,7 +467,19 @@ function App() {
|
||||
for (const note of notesToMove) {
|
||||
try {
|
||||
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) {
|
||||
console.error(`Failed to move note ${note.id}:`, error);
|
||||
}
|
||||
@@ -250,53 +496,280 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateNote = async (updatedNote: Note) => {
|
||||
try {
|
||||
const originalNote = notes.find(n => n.id === updatedNote.id);
|
||||
|
||||
// If category changed, use moveNote instead of updateNote
|
||||
if (originalNote && originalNote.category !== updatedNote.category) {
|
||||
const movedNote = await syncManager.moveNote(originalNote, updatedNote.category);
|
||||
// If content/title also changed, update the moved note
|
||||
if (originalNote.content !== updatedNote.content || originalNote.title !== updatedNote.title || originalNote.favorite !== updatedNote.favorite) {
|
||||
const finalNote = await syncManager.updateNote({
|
||||
...movedNote,
|
||||
title: updatedNote.title,
|
||||
content: updatedNote.content,
|
||||
favorite: updatedNote.favorite,
|
||||
});
|
||||
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));
|
||||
// Update selected note ID if it changed
|
||||
if (selectedNoteId === originalNote.id && movedNote.id !== originalNote.id) {
|
||||
setSelectedNoteId(movedNote.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const updated = await syncManager.updateNote(updatedNote);
|
||||
setNotes(notes.map(n => n.id === updatedNote.id ? updated : n));
|
||||
// Update selected note ID if it changed (e.g., filename changed due to first line edit)
|
||||
if (selectedNoteId === updatedNote.id && updated.id !== updatedNote.id) {
|
||||
setSelectedNoteId(updated.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update note failed:', error);
|
||||
const persistNoteToServer = async (note: Note) => {
|
||||
if (note.localOnly) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const remoteCategory = getRemoteCategory(note);
|
||||
|
||||
if (remoteCategory !== note.category) {
|
||||
const movedNote = await syncManager.moveNote(note, note.category);
|
||||
return syncManager.updateNote({
|
||||
...movedNote,
|
||||
draftId: note.draftId,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
favorite: note.favorite,
|
||||
localOnly: false,
|
||||
pendingSave: false,
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
lastSavedAt: note.lastSavedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return syncManager.updateNote(note);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (controller.inFlight) {
|
||||
await controller.inFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
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 draftId = note.draftId;
|
||||
if (!draftId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await syncManager.deleteNote(note);
|
||||
const remainingNotes = notes.filter(n => n.id !== note.id);
|
||||
setNotes(remainingNotes);
|
||||
if (selectedNoteId === note.id) {
|
||||
setSelectedNoteId(remainingNotes[0]?.id || null);
|
||||
clearSaveTimer(draftId);
|
||||
if (!note.localOnly) {
|
||||
await syncManager.deleteNote(note);
|
||||
} else {
|
||||
await localDB.deleteNote(note.id);
|
||||
}
|
||||
|
||||
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) {
|
||||
console.error('Delete note failed:', error);
|
||||
@@ -315,9 +788,16 @@ function App() {
|
||||
note.content.toLowerCase().includes(search);
|
||||
}
|
||||
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) {
|
||||
return <LoginView onLogin={handleLogin} />;
|
||||
@@ -350,8 +830,8 @@ function App() {
|
||||
/>
|
||||
<NotesList
|
||||
notes={filteredNotes}
|
||||
selectedNoteId={selectedNoteId}
|
||||
onSelectNote={setSelectedNoteId}
|
||||
selectedNoteDraftId={selectedNoteDraftId}
|
||||
onSelectNote={handleSelectNote}
|
||||
onCreateNote={handleCreateNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
onSync={syncNotes}
|
||||
@@ -368,9 +848,10 @@ function App() {
|
||||
)}
|
||||
<NoteEditor
|
||||
note={selectedNote}
|
||||
onUpdateNote={handleUpdateNote}
|
||||
onChangeNote={handleDraftChange}
|
||||
onSaveNote={handleManualSave}
|
||||
onDiscardNote={handleDiscardNote}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
onUnsavedChanges={setHasUnsavedChanges}
|
||||
categories={categories}
|
||||
isFocusMode={isFocusMode}
|
||||
onToggleFocusMode={() => setIsFocusMode(!isFocusMode)}
|
||||
@@ -384,4 +865,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;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
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 {
|
||||
private baseURL: string;
|
||||
@@ -16,7 +33,7 @@ export class NextcloudAPI {
|
||||
}
|
||||
|
||||
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,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -124,7 +141,7 @@ export class NextcloudAPI {
|
||||
const url = `${this.serverURL}${webdavPath}`;
|
||||
console.log(`[Note ${_noteId}] Fetching attachment via WebDAV:`, url);
|
||||
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
},
|
||||
@@ -174,7 +191,7 @@ export class NextcloudAPI {
|
||||
// First, try to create the attachments directory (MKCOL)
|
||||
// This may fail if it already exists, which is fine
|
||||
try {
|
||||
await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
|
||||
await runtimeFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, {
|
||||
method: 'MKCOL',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -188,7 +205,7 @@ export class NextcloudAPI {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
// Upload the file via PUT
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -210,7 +227,7 @@ export class NextcloudAPI {
|
||||
const url = `${this.serverURL}${webdavPath}`;
|
||||
|
||||
try {
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
},
|
||||
@@ -238,7 +255,7 @@ export class NextcloudAPI {
|
||||
|
||||
const content = JSON.stringify(colors, null, 2);
|
||||
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -277,11 +294,77 @@ export class NextcloudAPI {
|
||||
return note.content;
|
||||
}
|
||||
|
||||
private buildNoteWebDAVPath(category: string, filename: string): string {
|
||||
const categoryPath = category ? `/${category}` : '';
|
||||
return `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes`;
|
||||
const url = `${this.serverURL}${webdavPath}`;
|
||||
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -361,7 +444,7 @@ export class NextcloudAPI {
|
||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(filename)}`;
|
||||
const url = `${this.serverURL}${webdavPath}`;
|
||||
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
headers: { 'Authorization': this.authHeader },
|
||||
});
|
||||
|
||||
@@ -383,7 +466,7 @@ export class NextcloudAPI {
|
||||
if (category) {
|
||||
try {
|
||||
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${category}`;
|
||||
await tauriFetch(categoryUrl, {
|
||||
await runtimeFetch(categoryUrl, {
|
||||
method: 'MKCOL',
|
||||
headers: { 'Authorization': this.authHeader },
|
||||
});
|
||||
@@ -392,9 +475,9 @@ export class NextcloudAPI {
|
||||
}
|
||||
}
|
||||
|
||||
const noteContent = `${title}\n${content}`;
|
||||
const noteContent = content ? `${title}\n${content}` : title;
|
||||
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -416,7 +499,7 @@ export class NextcloudAPI {
|
||||
path: category ? `${category}/${filename}` : filename,
|
||||
etag,
|
||||
readonly: false,
|
||||
content,
|
||||
content: noteContent,
|
||||
title,
|
||||
category,
|
||||
favorite: false,
|
||||
@@ -437,21 +520,42 @@ export class NextcloudAPI {
|
||||
// Rename the file first, then update content
|
||||
const renamedNote = await this.renameNoteWebDAV(note, newFilename);
|
||||
// Now update the content of the renamed file
|
||||
return this.updateNoteContentWebDAV(renamedNote);
|
||||
return this.updateNoteContentWithRetryWebDAV(await this.refreshNoteWebDAVMetadata(renamedNote));
|
||||
} else {
|
||||
// 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> {
|
||||
const categoryPath = note.category ? `/${note.category}` : '';
|
||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||
const webdavPath = this.buildNoteWebDAVPath(note.category, note.filename!);
|
||||
const url = `${this.serverURL}${webdavPath}`;
|
||||
|
||||
const noteContent = this.formatNoteContent(note);
|
||||
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -463,9 +567,12 @@ export class NextcloudAPI {
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
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;
|
||||
@@ -478,23 +585,39 @@ export class NextcloudAPI {
|
||||
}
|
||||
|
||||
private async renameNoteWebDAV(note: Note, newFilename: string): Promise<Note> {
|
||||
const categoryPath = note.category ? `/${note.category}` : '';
|
||||
const oldPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||
const newPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(newFilename)}`;
|
||||
const oldPath = this.buildNoteWebDAVPath(note.category, note.filename!);
|
||||
const newPath = this.buildNoteWebDAVPath(note.category, newFilename);
|
||||
|
||||
const response = await tauriFetch(`${this.serverURL}${oldPath}`, {
|
||||
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||
method: 'MOVE',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
'Destination': `${this.serverURL}${newPath}`,
|
||||
'If-Match': note.etag,
|
||||
},
|
||||
});
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw createHttpStatusError(`Failed to rename note: ${response.status}`, response.status);
|
||||
}
|
||||
|
||||
// Also rename attachment folder if it exists
|
||||
const categoryPath = note.category ? `/${note.category}` : '';
|
||||
const oldNoteIdStr = String(note.id);
|
||||
const oldJustFilename = oldNoteIdStr.split('/').pop() || oldNoteIdStr;
|
||||
const oldFilenameWithoutExt = oldJustFilename.replace(/\.(md|txt)$/, '');
|
||||
@@ -509,7 +632,7 @@ export class NextcloudAPI {
|
||||
const newAttachmentPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${newAttachmentFolder}`;
|
||||
|
||||
try {
|
||||
await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||
await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||
method: 'MOVE',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -520,6 +643,7 @@ export class NextcloudAPI {
|
||||
// Attachment folder might not exist, that's ok
|
||||
}
|
||||
|
||||
const refreshedMetadata = await this.tryFetchNoteMetadataWebDAV(note.category, newFilename);
|
||||
const newId = note.category ? `${note.category}/${newFilename}` : newFilename;
|
||||
|
||||
return {
|
||||
@@ -527,6 +651,8 @@ export class NextcloudAPI {
|
||||
id: newId,
|
||||
filename: newFilename,
|
||||
path: note.category ? `${note.category}/${newFilename}` : newFilename,
|
||||
etag: refreshedMetadata?.etag || note.etag,
|
||||
modified: refreshedMetadata?.modified || Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -535,13 +661,13 @@ export class NextcloudAPI {
|
||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes${categoryPath}/${encodeURIComponent(note.filename!)}`;
|
||||
const url = `${this.serverURL}${webdavPath}`;
|
||||
|
||||
const response = await tauriFetch(url, {
|
||||
const response = await runtimeFetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': this.authHeader },
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
throw new Error(`Failed to delete note: ${response.status}`);
|
||||
if (!response.ok && response.status !== 204 && response.status !== 404) {
|
||||
throw createHttpStatusError(`Failed to delete note: ${response.status}`, response.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +686,7 @@ export class NextcloudAPI {
|
||||
currentPath += (currentPath ? '/' : '') + part;
|
||||
try {
|
||||
const categoryUrl = `${this.serverURL}/remote.php/dav/files/${this.username}/Notes/${currentPath}`;
|
||||
await tauriFetch(categoryUrl, {
|
||||
await runtimeFetch(categoryUrl, {
|
||||
method: 'MKCOL',
|
||||
headers: { 'Authorization': this.authHeader },
|
||||
});
|
||||
@@ -570,7 +696,7 @@ export class NextcloudAPI {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await tauriFetch(`${this.serverURL}${oldPath}`, {
|
||||
const response = await runtimeFetch(`${this.serverURL}${oldPath}`, {
|
||||
method: 'MOVE',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
@@ -597,7 +723,7 @@ export class NextcloudAPI {
|
||||
console.log(` To: ${newAttachmentPath}`);
|
||||
|
||||
try {
|
||||
const attachmentResponse = await tauriFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||
const attachmentResponse = await runtimeFetch(`${this.serverURL}${oldAttachmentPath}`, {
|
||||
method: 'MOVE',
|
||||
headers: {
|
||||
'Authorization': this.authHeader,
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import jsPDF from 'jspdf';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { Note } from '../types';
|
||||
import { NextcloudAPI } from '../api/nextcloud';
|
||||
import { FloatingToolbar } from './FloatingToolbar';
|
||||
import { InsertToolbar } from './InsertToolbar';
|
||||
import {
|
||||
exportPdfDocument,
|
||||
getDesktopRuntime,
|
||||
showDesktopMessage,
|
||||
} from '../services/desktop';
|
||||
import {
|
||||
getNoteTitleFromContent,
|
||||
loadPrintFontFaceCss,
|
||||
PrintExportPayload,
|
||||
sanitizeFileName,
|
||||
} from '../printExport';
|
||||
|
||||
interface NoteEditorProps {
|
||||
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;
|
||||
onUnsavedChanges?: (hasChanges: boolean) => void;
|
||||
categories: string[];
|
||||
isFocusMode?: boolean;
|
||||
onToggleFocusMode?: () => void;
|
||||
@@ -24,26 +34,30 @@ interface NoteEditorProps {
|
||||
|
||||
const imageCache = new Map<string, string>();
|
||||
|
||||
// Configure marked to support task lists
|
||||
marked.use({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) {
|
||||
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 [localCategory, setLocalCategory] = useState('');
|
||||
const [localFavorite, setLocalFavorite] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [isExportingPDF, setIsExportingPDF] = useState(false);
|
||||
const [isPreviewMode, setIsPreviewMode] = useState(false);
|
||||
const [processedContent, setProcessedContent] = useState('');
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const previousNoteIdRef = useRef<number | string | null>(null);
|
||||
const previousNoteContentRef = useRef<string>('');
|
||||
const previousDraftIdRef = useRef<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
onUnsavedChanges?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onUnsavedChanges]);
|
||||
const desktopRuntime = getDesktopRuntime();
|
||||
const exportActionLabel = desktopRuntime === 'electron' ? 'Export PDF' : 'Print Note';
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -87,8 +101,8 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
|
||||
// Guard: Only process if localContent has been updated for the current note
|
||||
// This prevents processing stale content from the previous note
|
||||
if (previousNoteIdRef.current !== note.id) {
|
||||
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousNoteIdRef: ${previousNoteIdRef.current})`);
|
||||
if (previousDraftIdRef.current !== note.draftId) {
|
||||
console.log(`[Note ${note.id}] Skipping image processing - waiting for content to sync (previousDraftIdRef: ${previousDraftIdRef.current})`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,93 +148,56 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
};
|
||||
|
||||
processImages();
|
||||
}, [isPreviewMode, localContent, note?.id, api]);
|
||||
}, [isPreviewMode, localContent, note?.draftId, note?.id, api]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadNewNote = () => {
|
||||
if (note) {
|
||||
setLocalContent(note.content);
|
||||
setLocalCategory(note.category || '');
|
||||
setLocalFavorite(note.favorite);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
previousNoteIdRef.current = note.id;
|
||||
previousNoteContentRef.current = note.content;
|
||||
}
|
||||
};
|
||||
if (!note) {
|
||||
setLocalContent('');
|
||||
setLocalCategory('');
|
||||
setLocalFavorite(false);
|
||||
previousDraftIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Switching to a different note
|
||||
if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) {
|
||||
console.log(`Switching from note ${previousNoteIdRef.current} to note ${note?.id}`);
|
||||
if (previousDraftIdRef.current !== note.draftId) {
|
||||
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();
|
||||
previousDraftIdRef.current = note.draftId ?? null;
|
||||
}
|
||||
// Initial load
|
||||
else if (!note || previousNoteIdRef.current === null) {
|
||||
loadNewNote();
|
||||
|
||||
if (note.content !== localContent) {
|
||||
setLocalContent(note.content);
|
||||
}
|
||||
// Favorite status changed (e.g., from sync)
|
||||
else if (note && note.favorite !== localFavorite) {
|
||||
if ((note.category || '') !== localCategory) {
|
||||
setLocalCategory(note.category || '');
|
||||
}
|
||||
if (note.favorite !== localFavorite) {
|
||||
setLocalFavorite(note.favorite);
|
||||
}
|
||||
}, [note?.id, note?.content, note?.modified, note?.favorite]);
|
||||
}, [note?.draftId, note?.content, note?.category, note?.favorite, localCategory, localContent, localFavorite]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!note || !hasUnsavedChanges) return;
|
||||
|
||||
console.log('Saving note content length:', localContent.length);
|
||||
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({
|
||||
const emitNoteChange = (content: string, category: string, favorite: boolean) => {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChangeNote({
|
||||
...note,
|
||||
title,
|
||||
content: localContent,
|
||||
category: localCategory,
|
||||
favorite: localFavorite,
|
||||
title: getNoteTitleFromContent(content),
|
||||
content,
|
||||
category,
|
||||
favorite,
|
||||
});
|
||||
setTimeout(() => setIsSaving(false), 500);
|
||||
};
|
||||
|
||||
const handleContentChange = (value: string) => {
|
||||
setLocalContent(value);
|
||||
setHasUnsavedChanges(true);
|
||||
emitNoteChange(value, localCategory, localFavorite);
|
||||
};
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (!note) return;
|
||||
|
||||
setLocalContent(note.content);
|
||||
setLocalCategory(note.category || '');
|
||||
setLocalFavorite(note.favorite);
|
||||
setHasUnsavedChanges(false);
|
||||
};
|
||||
if (!note?.draftId) return;
|
||||
|
||||
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);
|
||||
});
|
||||
onDiscardNote(note.draftId);
|
||||
};
|
||||
|
||||
const handleExportPDF = async () => {
|
||||
@@ -229,66 +206,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
setIsExportingPDF(true);
|
||||
|
||||
try {
|
||||
// Create PDF
|
||||
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;
|
||||
let contentForPrint = localContent;
|
||||
if (api) {
|
||||
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const matches = [...localContent.matchAll(imageRegex)];
|
||||
@@ -305,100 +223,46 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
const cacheKey = `${note.id}:${imagePath}`;
|
||||
if (imageCache.has(cacheKey)) {
|
||||
const dataUrl = imageCache.get(cacheKey)!;
|
||||
contentForPDF = contentForPDF.replace(fullMatch, ``);
|
||||
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category);
|
||||
imageCache.set(cacheKey, dataUrl);
|
||||
contentForPDF = contentForPDF.replace(fullMatch, ``);
|
||||
contentForPrint = contentForPrint.replace(fullMatch, ``);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch attachment for PDF: ${imagePath}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.fontFamily = `"${previewFont}", Georgia, serif`;
|
||||
container.style.fontSize = `${previewFontSize}px`;
|
||||
container.style.lineHeight = '1.6';
|
||||
container.style.color = '#000000';
|
||||
|
||||
const titleElement = document.createElement('h1');
|
||||
const firstLine = localContent.split('\n')[0].replace(/^#+\s*/, '').trim();
|
||||
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);
|
||||
const title = getNoteTitleFromContent(localContent);
|
||||
const fileName = `${sanitizeFileName(title)}.pdf`;
|
||||
const noteHtml = marked.parse(contentForPrint || '', { async: false }) as string;
|
||||
const payload: PrintExportPayload = {
|
||||
fileName,
|
||||
title,
|
||||
html: noteHtml,
|
||||
previewFont,
|
||||
previewFontSize,
|
||||
previewFontFaceCss: await loadPrintFontFaceCss(previewFont),
|
||||
};
|
||||
|
||||
// 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,
|
||||
await exportPdfDocument({
|
||||
...payload,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error);
|
||||
try {
|
||||
await message('Failed to export PDF. Please try again.', {
|
||||
await showDesktopMessage('Failed to export the PDF. Please try again.', {
|
||||
title: 'Export Failed',
|
||||
kind: 'error',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Could not show error dialog');
|
||||
}
|
||||
} finally {
|
||||
setIsExportingPDF(false);
|
||||
}
|
||||
};
|
||||
@@ -408,26 +272,13 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
setLocalFavorite(newFavorite);
|
||||
|
||||
if (note && onToggleFavorite) {
|
||||
// Use dedicated favorite toggle callback if provided
|
||||
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) => {
|
||||
setLocalCategory(category);
|
||||
setHasUnsavedChanges(true);
|
||||
emitNoteChange(localContent, category, localFavorite);
|
||||
};
|
||||
|
||||
const handleAttachmentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -450,7 +301,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
||||
setLocalContent(newContent);
|
||||
setHasUnsavedChanges(true);
|
||||
emitNoteChange(newContent, localCategory, localFavorite);
|
||||
|
||||
// Move cursor after inserted text
|
||||
setTimeout(() => {
|
||||
@@ -460,17 +311,18 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
}, 0);
|
||||
} else {
|
||||
// Append to end
|
||||
setLocalContent(localContent + '\n' + markdownLink);
|
||||
setHasUnsavedChanges(true);
|
||||
const newContent = `${localContent}\n${markdownLink}`;
|
||||
setLocalContent(newContent);
|
||||
emitNoteChange(newContent, localCategory, localFavorite);
|
||||
}
|
||||
|
||||
await message(`Attachment uploaded successfully!`, {
|
||||
await showDesktopMessage('Attachment uploaded successfully!', {
|
||||
title: 'Upload Complete',
|
||||
kind: 'info',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error);
|
||||
await message(`Failed to upload attachment: ${error}`, {
|
||||
await showDesktopMessage(`Failed to upload attachment: ${error}`, {
|
||||
title: 'Upload Failed',
|
||||
kind: 'error',
|
||||
});
|
||||
@@ -491,7 +343,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
const markdownLink = `[${text}](${url})`;
|
||||
const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos);
|
||||
setLocalContent(newContent);
|
||||
setHasUnsavedChanges(true);
|
||||
emitNoteChange(newContent, localCategory, localFavorite);
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
@@ -631,7 +483,7 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
|
||||
const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end);
|
||||
setLocalContent(newContent);
|
||||
setHasUnsavedChanges(true);
|
||||
emitNoteChange(newContent, localCategory, localFavorite);
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
@@ -717,13 +569,17 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status */}
|
||||
{(hasUnsavedChanges || isSaving) && (
|
||||
{(hasUnsavedChanges || isSaving || saveError || hasSavedState) && (
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||
isSaving
|
||||
? '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'
|
||||
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'
|
||||
: 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>
|
||||
)}
|
||||
|
||||
@@ -745,8 +601,12 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
onClick={() => {
|
||||
if (note?.draftId) {
|
||||
void onSaveNote(note.draftId);
|
||||
}
|
||||
}}
|
||||
disabled={!hasUnsavedChanges || isSaving || !note?.draftId}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
hasUnsavedChanges && !isSaving
|
||||
? 'text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30'
|
||||
@@ -782,16 +642,26 @@ export function NoteEditor({ note, onUpdateNote, onToggleFavorite, onUnsavedChan
|
||||
? 'text-blue-500 cursor-wait'
|
||||
: '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 ? (
|
||||
<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>
|
||||
<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>
|
||||
) : 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">
|
||||
<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>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||
|
||||
interface NotesListProps {
|
||||
notes: Note[];
|
||||
selectedNoteId: number | string | null;
|
||||
onSelectNote: (id: number | string) => void;
|
||||
selectedNoteDraftId: string | null;
|
||||
onSelectNote: (draftId: string) => void | Promise<void>;
|
||||
onCreateNote: () => void;
|
||||
onDeleteNote: (note: Note) => void;
|
||||
onSync: () => void;
|
||||
@@ -22,7 +22,7 @@ interface NotesListProps {
|
||||
|
||||
export function NotesList({
|
||||
notes,
|
||||
selectedNoteId,
|
||||
selectedNoteDraftId,
|
||||
onSelectNote,
|
||||
onCreateNote,
|
||||
onDeleteNote,
|
||||
@@ -93,11 +93,6 @@ export function NotesList({
|
||||
const handleDeleteClick = (note: Note, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Prevent deletion if there are unsaved changes on a different note
|
||||
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteClickedId === note.id) {
|
||||
// Second click - actually delete
|
||||
onDeleteNote(note);
|
||||
@@ -249,22 +244,18 @@ export function NotesList({
|
||||
) : (
|
||||
notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
key={note.draftId ?? note.id}
|
||||
onClick={() => {
|
||||
// Prevent switching if current note has unsaved changes
|
||||
if (hasUnsavedChanges && note.id !== selectedNoteId) {
|
||||
return;
|
||||
if (note.draftId) {
|
||||
void onSelectNote(note.draftId);
|
||||
}
|
||||
onSelectNote(note.id);
|
||||
}}
|
||||
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'
|
||||
: 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-center flex-1 min-w-0">
|
||||
|
||||
366
src/components/PrintView.tsx
Normal file
366
src/components/PrintView.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
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 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -213,3 +213,29 @@ code {
|
||||
.dark .ProseMirror hr {
|
||||
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: default;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prose input[type="checkbox"]:checked {
|
||||
accent-color: #16a34a;
|
||||
}
|
||||
|
||||
369
src/printExport.ts
Normal file
369
src/printExport.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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 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
139
src/services/desktop.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
155
src/services/runtimeFetch.ts
Normal file
155
src/services/runtimeFetch.ts
Normal 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);
|
||||
};
|
||||
@@ -4,6 +4,41 @@ import { localDB } from '../db/localDB';
|
||||
|
||||
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 {
|
||||
private api: NextcloudAPI | null = null;
|
||||
private isOnline: boolean = navigator.onLine;
|
||||
@@ -46,7 +81,7 @@ export class SyncManager {
|
||||
// Load notes: cache-first, then sync in background
|
||||
async loadNotes(): Promise<Note[]> {
|
||||
// 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 (!this.isOnline) {
|
||||
@@ -68,7 +103,9 @@ export class SyncManager {
|
||||
|
||||
try {
|
||||
this.notifyStatus('syncing', 0);
|
||||
const notes = await this.fetchAndCacheNotes();
|
||||
await this.fetchAndCacheNotes();
|
||||
await this.syncFavoriteStatus();
|
||||
const notes = await getCachedNotes();
|
||||
this.notifyStatus('idle', 0);
|
||||
return notes;
|
||||
} catch (error) {
|
||||
@@ -87,7 +124,7 @@ export class SyncManager {
|
||||
|
||||
// Get metadata for all notes (fast - no content)
|
||||
const serverNotes = await this.api.fetchNotesWebDAV();
|
||||
const cachedNotes = await localDB.getAllNotes();
|
||||
const cachedNotes = await getCachedNotes();
|
||||
|
||||
// Build maps for comparison
|
||||
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
|
||||
@@ -97,16 +134,23 @@ export class SyncManager {
|
||||
const notesToFetch: Note[] = [];
|
||||
for (const serverNote of serverNotes) {
|
||||
const cached = cachedMap.get(serverNote.id);
|
||||
if (cached?.pendingSave) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!cached || cached.etag !== serverNote.etag) {
|
||||
notesToFetch.push(serverNote);
|
||||
notesToFetch.push(withLocalNoteFields(serverNote, cached));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch content for changed notes
|
||||
for (const note of notesToFetch) {
|
||||
try {
|
||||
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
||||
await localDB.saveNote(fullNote);
|
||||
const fullNote = withLocalNoteFields(
|
||||
await this.api.fetchNoteContentWebDAV(note),
|
||||
cachedMap.get(note.id)
|
||||
);
|
||||
await localDB.saveNote(toStoredNote(fullNote));
|
||||
} catch (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)
|
||||
for (const cachedNote of cachedNotes) {
|
||||
if (cachedNote.localOnly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!serverMap.has(cachedNote.id)) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -146,7 +194,7 @@ export class SyncManager {
|
||||
try {
|
||||
console.log('Syncing favorite status from API...');
|
||||
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
|
||||
// (titles can differ between API and WebDAV)
|
||||
@@ -163,6 +211,10 @@ export class SyncManager {
|
||||
|
||||
// Update favorite status in cache for matching notes
|
||||
for (const cachedNote of cachedNotes) {
|
||||
if (cachedNote.localOnly) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try timestamp match first (most reliable)
|
||||
const timestampKey = `${cachedNote.modified}:${cachedNote.category}`;
|
||||
let apiData = apiByTimestamp.get(timestampKey);
|
||||
@@ -176,7 +228,7 @@ export class SyncManager {
|
||||
if (apiData && cachedNote.favorite !== apiData.favorite) {
|
||||
console.log(`Updating favorite status for "${cachedNote.title}": ${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[]> {
|
||||
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 notesWithContent: Note[] = [];
|
||||
|
||||
for (const note of serverNotes) {
|
||||
try {
|
||||
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
||||
const fullNote = withLocalNoteFields(
|
||||
await this.api.fetchNoteContentWebDAV(note),
|
||||
cachedMap.get(note.id)
|
||||
);
|
||||
notesWithContent.push(fullNote);
|
||||
await localDB.saveNote(fullNote);
|
||||
await localDB.saveNote(toStoredNote(fullNote));
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch note ${note.id}:`, error);
|
||||
}
|
||||
@@ -218,8 +275,8 @@ export class SyncManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const fullNote = await this.api.fetchNoteContentWebDAV(note);
|
||||
await localDB.saveNote(fullNote);
|
||||
const fullNote = withLocalNoteFields(await this.api.fetchNoteContentWebDAV(note), note);
|
||||
await localDB.saveNote(toStoredNote(fullNote));
|
||||
return fullNote;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
@@ -239,8 +296,8 @@ export class SyncManager {
|
||||
|
||||
try {
|
||||
this.notifyStatus('syncing', 0);
|
||||
const note = await this.api.createNoteWebDAV(title, content, category);
|
||||
await localDB.saveNote(note);
|
||||
const note = withLocalNoteFields(await this.api.createNoteWebDAV(title, content, category));
|
||||
await localDB.saveNote(toStoredNote(note));
|
||||
|
||||
// Protect this note from being deleted by background sync for a short window
|
||||
this.protectNote(note.id);
|
||||
@@ -266,7 +323,7 @@ export class SyncManager {
|
||||
if (!this.isOnline) {
|
||||
// Update locally, will sync when back online
|
||||
note.favorite = favorite;
|
||||
await localDB.saveNote(note);
|
||||
await localDB.saveNote(toStoredNote(note));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,12 +341,12 @@ export class SyncManager {
|
||||
|
||||
// Update local cache
|
||||
note.favorite = favorite;
|
||||
await localDB.saveNote(note);
|
||||
await localDB.saveNote(toStoredNote(note));
|
||||
} catch (error) {
|
||||
console.error('Failed to update favorite status:', error);
|
||||
// Still update locally
|
||||
note.favorite = favorite;
|
||||
await localDB.saveNote(note);
|
||||
await localDB.saveNote(toStoredNote(note));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,14 +364,14 @@ export class SyncManager {
|
||||
try {
|
||||
this.notifyStatus('syncing', 0);
|
||||
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 (oldId !== updatedNote.id) {
|
||||
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
|
||||
this.protectNote(updatedNote.id);
|
||||
@@ -366,9 +423,9 @@ export class SyncManager {
|
||||
|
||||
try {
|
||||
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.saveNote(movedNote);
|
||||
await localDB.saveNote(toStoredNote(movedNote));
|
||||
|
||||
// Protect the moved note from being deleted by background sync
|
||||
this.protectNote(movedNote.id);
|
||||
|
||||
@@ -9,6 +9,12 @@ export interface Note {
|
||||
modified: number;
|
||||
filename?: string; // WebDAV: actual filename on server
|
||||
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 {
|
||||
|
||||
45
src/vite-env.d.ts
vendored
45
src/vite-env.d.ts
vendored
@@ -1 +1,46 @@
|
||||
/// <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';
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user