Major UX improvements: remove title field, auto-sync, fix image uploads

- Remove separate title input field - first line of content is now the title (standard Markdown behavior)
- Update note parsing to extract title from first line while keeping full content
- Move favorite star button to toolbar to save vertical space
- Fix image upload attachment directory path sanitization
- Add automatic background sync after save operations (create, update, move)
- Add rotating sync icon animation during sync operations
- Fix infinite sync loop by preventing sync complete callback from triggering another sync
- Bump IndexedDB version to 2 to clear old cached notes with stripped first lines
- Remove dialog permission errors in attachment upload (use console.log and alert instead)
- Add detailed debug logging for attachment upload troubleshooting
This commit is contained in:
drelich
2026-03-25 23:31:27 +01:00
parent dfc0e644eb
commit cb7a8d8276
6 changed files with 367 additions and 347 deletions

View File

@@ -1,6 +1,6 @@
import { Note } from '../types';
import { NextcloudAPI } from '../api/nextcloud';
import { localDB, SyncOperation } from '../db/localDB';
import { localDB } from '../db/localDB';
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline';
@@ -9,12 +9,12 @@ export class SyncManager {
private isOnline: boolean = navigator.onLine;
private syncInProgress: boolean = false;
private statusCallback: ((status: SyncStatus, pendingCount: number) => void) | null = null;
private syncCompleteCallback: (() => void) | null = null;
constructor() {
window.addEventListener('online', () => {
this.isOnline = true;
this.notifyStatus('idle', 0);
this.processSyncQueue();
});
window.addEventListener('offline', () => {
@@ -31,259 +31,253 @@ export class SyncManager {
this.statusCallback = callback;
}
setSyncCompleteCallback(callback: () => void) {
this.syncCompleteCallback = callback;
}
private notifyStatus(status: SyncStatus, pendingCount: number) {
if (this.statusCallback) {
this.statusCallback(status, pendingCount);
}
}
private async getPendingCount(): Promise<number> {
const queue = await localDB.getSyncQueue();
return queue.length;
}
// Load notes from local DB first, then sync with server
// Load notes: cache-first, then sync in background
async loadNotes(): Promise<Note[]> {
const localNotes = await localDB.getAllNotes();
// Try to load from cache first (instant)
const cachedNotes = await localDB.getAllNotes();
if (this.isOnline && this.api) {
try {
await this.syncWithServer();
return await localDB.getAllNotes();
} catch (error) {
console.error('Failed to sync with server, using local data:', error);
return localNotes;
}
// If we have cached notes and we're offline, return them
if (!this.isOnline) {
this.notifyStatus('offline', 0);
return cachedNotes;
}
return localNotes;
}
// Sync with server: fetch remote notes and merge with local
async syncWithServer(): Promise<void> {
if (!this.api || !this.isOnline || this.syncInProgress) return;
// If we have cached notes, return them immediately
// Then sync in background
if (cachedNotes.length > 0) {
this.syncInBackground();
return cachedNotes;
}
this.syncInProgress = true;
this.notifyStatus('syncing', await this.getPendingCount());
// No cache - must fetch from server
if (!this.api) {
throw new Error('API not initialized');
}
try {
// First, process any pending operations
await this.processSyncQueue();
this.notifyStatus('syncing', 0);
const notes = await this.fetchAndCacheNotes();
this.notifyStatus('idle', 0);
return notes;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Fetch notes directly from WebDAV (file system)
const serverNotes = await this.api.fetchNotesWebDAV();
const localNotes = await localDB.getAllNotes();
// Background sync: compare etags and only fetch changed content
private async syncInBackground(): Promise<void> {
if (!this.api || this.syncInProgress) return;
// Merge strategy: use file path as unique identifier
const mergedNotes = this.mergeNotesWebDAV(localNotes, serverNotes);
this.syncInProgress = true;
try {
this.notifyStatus('syncing', 0);
// Update local DB with merged notes (no clearNotes - safer!)
for (const note of mergedNotes) {
await localDB.saveNote(note);
}
// Remove notes that no longer exist on server
const serverIds = new Set(serverNotes.map(n => n.id));
for (const localNote of localNotes) {
if (!serverIds.has(localNote.id) && typeof localNote.id === 'string') {
await localDB.deleteNote(localNote.id);
// Get metadata for all notes (fast - no content)
const serverNotes = await this.api.fetchNotesWebDAV();
const cachedNotes = await localDB.getAllNotes();
// Build maps for comparison
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
const cachedMap = new Map(cachedNotes.map(n => [n.id, n]));
// Find notes that need content fetched (new or changed etag)
const notesToFetch: Note[] = [];
for (const serverNote of serverNotes) {
const cached = cachedMap.get(serverNote.id);
if (!cached || cached.etag !== serverNote.etag) {
notesToFetch.push(serverNote);
}
}
// Fetch content for changed notes
for (const note of notesToFetch) {
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(fullNote);
} catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error);
}
}
// Remove deleted notes from cache
for (const cachedNote of cachedNotes) {
if (!serverMap.has(cachedNote.id)) {
await localDB.deleteNote(cachedNote.id);
}
}
this.notifyStatus('idle', 0);
// Notify that sync is complete so UI can reload
if (this.syncCompleteCallback) {
this.syncCompleteCallback();
}
} catch (error) {
console.error('Sync failed:', error);
this.notifyStatus('error', await this.getPendingCount());
throw error;
console.error('Background sync failed:', error);
this.notifyStatus('error', 0);
} finally {
this.syncInProgress = false;
}
}
private mergeNotesWebDAV(localNotes: Note[], serverNotes: Note[]): Note[] {
const serverMap = new Map(serverNotes.map(n => [n.id, n]));
const localMap = new Map(localNotes.map(n => [n.id, n]));
const merged: Note[] = [];
// Add all server notes (they are the source of truth for existing files)
serverNotes.forEach(serverNote => {
const localNote = localMap.get(serverNote.id);
// If local version is newer, keep it (will be synced later)
if (localNote && localNote.modified > serverNote.modified) {
merged.push(localNote);
} else {
merged.push(serverNote);
}
});
// Add local-only notes (not yet synced to server)
localNotes.forEach(localNote => {
if (!serverMap.has(localNote.id)) {
merged.push(localNote);
}
});
return merged;
}
// Create note (offline-first)
async createNote(title: string, content: string, category: string): Promise<Note> {
// Generate filename-based ID
const filename = `${title.replace(/[^a-zA-Z0-9\s-]/g, '').replace(/\s+/g, ' ').trim()}.txt`;
const tempNote: Note = {
id: `${category}/${filename}`,
filename,
path: category ? `${category}/${filename}` : filename,
etag: '',
readonly: false,
content,
title,
category,
favorite: false,
modified: Math.floor(Date.now() / 1000),
};
// Save to local DB immediately
await localDB.saveNote(tempNote);
// Queue for sync
const operation: SyncOperation = {
id: `create-${tempNote.id}-${Date.now()}`,
type: 'create',
noteId: tempNote.id,
note: tempNote,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
return tempNote;
}
// Update note (offline-first)
async updateNote(note: Note): Promise<Note> {
// Update local DB immediately
await localDB.saveNote(note);
// Queue for sync
const operation: SyncOperation = {
id: `update-${note.id}-${Date.now()}`,
type: 'update',
noteId: note.id,
note,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
return note;
}
// Delete note (offline-first)
async deleteNote(id: number | string): Promise<void> {
// Delete from local DB immediately
await localDB.deleteNote(id);
// Queue for sync
const operation: SyncOperation = {
id: `delete-${id}-${Date.now()}`,
type: 'delete',
noteId: id,
timestamp: Date.now(),
retryCount: 0,
};
await localDB.addToSyncQueue(operation);
// Try to sync immediately if online
if (this.isOnline && this.api) {
this.processSyncQueue().catch(console.error);
} else {
this.notifyStatus('offline', await this.getPendingCount());
}
}
// Process sync queue
async processSyncQueue(): Promise<void> {
if (!this.api || !this.isOnline || this.syncInProgress) return;
const queue = await localDB.getSyncQueue();
if (queue.length === 0) return;
this.syncInProgress = true;
this.notifyStatus('syncing', queue.length);
for (const operation of queue) {
try {
await this.processOperation(operation);
await localDB.removeFromSyncQueue(operation.id);
} catch (error) {
console.error(`Failed to process operation ${operation.id}:`, error);
// Increment retry count
operation.retryCount++;
if (operation.retryCount > 5) {
console.error(`Operation ${operation.id} failed after 5 retries, removing from queue`);
await localDB.removeFromSyncQueue(operation.id);
} else {
await localDB.addToSyncQueue(operation);
}
}
}
this.syncInProgress = false;
const remainingCount = await this.getPendingCount();
this.notifyStatus(remainingCount > 0 ? 'error' : 'idle', remainingCount);
}
private async processOperation(operation: SyncOperation): Promise<void> {
// Fetch all notes and cache them
private async fetchAndCacheNotes(): Promise<Note[]> {
if (!this.api) throw new Error('API not initialized');
switch (operation.type) {
case 'create':
if (operation.note) {
const serverNote = await this.api.createNoteWebDAV(
operation.note.title,
operation.note.content,
operation.note.category
);
// Update local note with server response (etag, etc.)
await localDB.saveNote(serverNote);
}
break;
case 'update':
if (operation.note) {
const serverNote = await this.api.updateNoteWebDAV(operation.note);
await localDB.saveNote(serverNote);
}
break;
case 'delete':
if (operation.noteId) {
// For delete, we need the note object to know the filename
const note = await localDB.getNote(operation.noteId);
if (note) {
await this.api.deleteNoteWebDAV(note);
}
}
break;
const serverNotes = await this.api.fetchNotesWebDAV();
const notesWithContent: Note[] = [];
for (const note of serverNotes) {
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
notesWithContent.push(fullNote);
await localDB.saveNote(fullNote);
} catch (error) {
console.error(`Failed to fetch note ${note.id}:`, error);
}
}
return notesWithContent;
}
// Fetch content for a specific note on-demand
async fetchNoteContent(note: Note): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
throw new Error('Cannot fetch note content while offline');
}
try {
const fullNote = await this.api.fetchNoteContentWebDAV(note);
await localDB.saveNote(fullNote);
return fullNote;
} catch (error) {
throw error;
}
}
// Create note on server and cache
async createNote(title: string, content: string, category: string): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot create note while offline');
}
try {
this.notifyStatus('syncing', 0);
const note = await this.api.createNoteWebDAV(title, content, category);
await localDB.saveNote(note);
this.notifyStatus('idle', 0);
// Trigger background sync to fetch any other changes
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
return note;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Update note on server and cache
async updateNote(note: Note): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot update note while offline');
}
try {
this.notifyStatus('syncing', 0);
const updatedNote = await this.api.updateNoteWebDAV(note);
await localDB.saveNote(updatedNote);
this.notifyStatus('idle', 0);
// Trigger background sync to fetch any other changes
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
return updatedNote;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Delete note from server and cache
async deleteNote(note: Note): Promise<void> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot delete note while offline');
}
try {
this.notifyStatus('syncing', 0);
await this.api.deleteNoteWebDAV(note);
await localDB.deleteNote(note.id);
this.notifyStatus('idle', 0);
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Move note to different category on server and cache
async moveNote(note: Note, newCategory: string): Promise<Note> {
if (!this.api) {
throw new Error('API not initialized');
}
if (!this.isOnline) {
this.notifyStatus('offline', 0);
throw new Error('Cannot move note while offline');
}
try {
this.notifyStatus('syncing', 0);
const movedNote = await this.api.moveNoteWebDAV(note, newCategory);
await localDB.deleteNote(note.id);
await localDB.saveNote(movedNote);
this.notifyStatus('idle', 0);
// Trigger background sync to fetch any other changes
this.syncInBackground().catch(err => console.error('Background sync failed:', err));
return movedNote;
} catch (error) {
this.notifyStatus('error', 0);
throw error;
}
}
// Manual sync with server
async syncWithServer(): Promise<void> {
if (!this.api || !this.isOnline || this.syncInProgress) return;
await this.syncInBackground();
}
getOnlineStatus(): boolean {