diff --git a/apps/desktop/electron-vite.config.ts b/apps/desktop/electron-vite.config.ts index 66d1830..94b5467 100644 --- a/apps/desktop/electron-vite.config.ts +++ b/apps/desktop/electron-vite.config.ts @@ -52,7 +52,6 @@ export default defineConfig({ rollupOptions: { input: { index: resolve(__dirname, 'src/renderer/index.html'), - settings: resolve(__dirname, 'src/renderer/settings.html'), }, }, }, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6ef6136..033d446 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -4,7 +4,7 @@ * Exposes a typed API to the renderer process via contextBridge. */ -import { contextBridge, ipcRenderer } from 'electron'; +import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'; /** Result type from operations */ export type Result = @@ -311,6 +311,16 @@ export interface ReadiedAPI { /** Open the settings window */ openSettings: () => Promise<{ ok: boolean }>; }; + settings: { + /** Notify other windows of settings change */ + notifyChange: (settings: unknown) => void; + /** Listen for settings sync from other windows */ + onSync: (callback: (settings: unknown) => void) => () => void; + }; + updates: { + /** Check for updates manually */ + checkNow: () => Promise<{ available: boolean; version?: string }>; + }; } // Expose the API @@ -403,6 +413,19 @@ const api: ReadiedAPI = { openNote: (noteId, noteTitle) => ipcRenderer.invoke('window:openNote', noteId, noteTitle), openSettings: () => ipcRenderer.invoke('window:openSettings'), }, + settings: { + notifyChange: settings => ipcRenderer.send('settings:changed', settings), + onSync: callback => { + const handler = (_event: IpcRendererEvent, settings: unknown) => callback(settings); + ipcRenderer.on('settings:sync', handler); + return () => { + ipcRenderer.removeListener('settings:sync', handler); + }; + }, + }, + updates: { + checkNow: () => ipcRenderer.invoke('updates:checkNow'), + }, }; contextBridge.exposeInMainWorld('readied', api); diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index b7a390d..7eec6eb 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -24,8 +24,10 @@ import { useDebouncedSearch } from './hooks/useDebouncedSearch'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useEditorPreferencesStore } from './stores/editorPreferencesStore'; import { useTagColorsStore } from './stores/tagColorsStore'; +import { useSettingsStore, selectGeneral } from './stores/settings'; import { usePerformanceMode } from './hooks/usePerformanceMode'; import { useResizableLayout } from './hooks/useResizableLayout'; +import { useTheme } from './hooks/useTheme'; const queryClient = new QueryClient({ defaultOptions: { @@ -41,6 +43,7 @@ const queryClient = new QueryClient({ */ function NotesApp() { usePerformanceMode(); + useTheme(); // Resizable layout const { sidebarWidth, notelistWidth, startResizeSidebar, startResizeNotelist } = @@ -59,6 +62,9 @@ function NotesApp() { // Editor preferences const cycleViewMode = useEditorPreferencesStore(state => state.cycleViewMode); + // General settings (for default notebook) + const generalSettings = useSettingsStore(selectGeneral); + // Load tag colors on mount (once) useEffect(() => { useTagColorsStore.getState().loadColors(); @@ -99,15 +105,16 @@ function NotesApp() { // Determine selected quick filter for NoteList header const selectedQuickFilter = navigation.kind === 'global' ? navigation.filter : null; - // Create new note (respects current navigation context) + // Create new note (respects current navigation context, falls back to default notebook) const handleNewNote = useCallback(async () => { + const notebookId = selectedNotebookId ?? generalSettings.defaultNotebookId ?? undefined; const newNote = await createNote.mutateAsync({ content: '# Untitled\n\n', - notebookId: selectedNotebookId ?? undefined, + notebookId, }); setSelectedNote(newNote); clearSearch(); - }, [createNote, selectedNotebookId, clearSearch]); + }, [createNote, selectedNotebookId, generalSettings.defaultNotebookId, clearSearch]); // Select note const handleSelectNote = useCallback(async (id: string) => { @@ -119,13 +126,14 @@ function NotesApp() { // Handle wikilink click - best-effort navigation by title const handleWikilinkClick = useCallback( - async (title: string) => { + async (title: string, _anchor?: string) => { const notes = await window.readied.notes.search(title); if (notes.length > 0) { // Find exact match (case-insensitive) const match = notes.find(n => n.title.toLowerCase() === title.toLowerCase()); if (match) { handleSelectNote(match.id); + // TODO: scroll to anchor after navigation (requires editor/preview scroll API) } } // No-op if note doesn't exist (future: could show toast or create note) diff --git a/apps/desktop/src/renderer/components/ColorPicker/ColorPicker.module.css b/apps/desktop/src/renderer/components/ColorPicker/ColorPicker.module.css index 5774dc5..e7ec6a0 100644 --- a/apps/desktop/src/renderer/components/ColorPicker/ColorPicker.module.css +++ b/apps/desktop/src/renderer/components/ColorPicker/ColorPicker.module.css @@ -7,7 +7,6 @@ background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); padding: 8px; min-width: 120px; } diff --git a/apps/desktop/src/renderer/components/NoteEditor.tsx b/apps/desktop/src/renderer/components/NoteEditor.tsx index a6f4a66..d94fd50 100644 --- a/apps/desktop/src/renderer/components/NoteEditor.tsx +++ b/apps/desktop/src/renderer/components/NoteEditor.tsx @@ -42,7 +42,7 @@ interface NoteEditorProps { onStatusChange?: (status: NoteStatus) => void; onDuplicate?: () => void; onDelete?: () => void; - onWikilinkClick?: (target: string) => void; + onWikilinkClick?: (target: string, anchor?: string) => void; onNavigateToNote?: (noteId: string) => void; /** Called when note is updated (e.g., tags changed) */ onNoteUpdate?: (note: NoteSnapshot) => void; diff --git a/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.module.css b/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.module.css index 3dc2d9e..1febce5 100644 --- a/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.module.css +++ b/apps/desktop/src/renderer/components/NoteListContextMenu/NoteListContextMenu.module.css @@ -13,9 +13,6 @@ -webkit-backdrop-filter: blur(24px) saturate(150%); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; - box-shadow: - 0 12px 48px rgba(0, 0, 0, 0.5), - 0 0 1px rgba(0, 0, 0, 0.2); padding: 4px; animation: fadeIn 0.1s ease-out; } @@ -119,9 +116,6 @@ -webkit-backdrop-filter: blur(24px) saturate(150%); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; - box-shadow: - 0 12px 48px rgba(0, 0, 0, 0.5), - 0 0 1px rgba(0, 0, 0, 0.2); padding: 4px; } diff --git a/apps/desktop/src/renderer/components/NotebookPicker/NotebookPicker.module.css b/apps/desktop/src/renderer/components/NotebookPicker/NotebookPicker.module.css index 881e881..fdc1c16 100644 --- a/apps/desktop/src/renderer/components/NotebookPicker/NotebookPicker.module.css +++ b/apps/desktop/src/renderer/components/NotebookPicker/NotebookPicker.module.css @@ -22,8 +22,7 @@ max-width: 560px; background: var(--bg-inset, #1a1d23); border-radius: 8px; - border: none; - box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5); + border: 1px solid var(--border); overflow: hidden; animation: slideIn 0.15s ease-out; } diff --git a/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.module.css b/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.module.css index d40f67a..99552ff 100644 --- a/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.module.css +++ b/apps/desktop/src/renderer/components/editor/ActionsPanel/ActionsPanel.module.css @@ -41,7 +41,6 @@ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border-left: 1px solid var(--glass-border); - box-shadow: var(--glass-shadow); transform: translateX(100%); transition: transform var(--transition-normal); z-index: 50; @@ -61,7 +60,6 @@ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border-left: 1px solid var(--glass-border); - box-shadow: var(--glass-shadow); transform: translateX(0); transition: transform var(--transition-normal); z-index: 50; diff --git a/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.module.css b/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.module.css index 5f276bd..e3ec155 100644 --- a/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.module.css +++ b/apps/desktop/src/renderer/components/editor/BacklinksPanel/BacklinksPanel.module.css @@ -42,7 +42,6 @@ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border-left: 1px solid var(--glass-border); - box-shadow: var(--glass-shadow); transform: translateX(100%); transition: transform var(--transition-normal); z-index: 50; @@ -62,7 +61,6 @@ backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate)); border-left: 1px solid var(--glass-border); - box-shadow: var(--glass-shadow); transform: translateX(0); transition: transform var(--transition-normal); z-index: 50; diff --git a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx index 61e1010..542b776 100644 --- a/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx +++ b/apps/desktop/src/renderer/components/editor/MarkdownPreview.tsx @@ -20,7 +20,7 @@ interface MarkdownPreviewProps { readonly createdAt?: string; readonly updatedAt?: string; readonly onReady?: () => void; - readonly onWikilinkClick?: (target: string) => void; + readonly onWikilinkClick?: (target: string, anchor?: string) => void; readonly onEmbedClick?: (target: string, url: string) => void; /** Optional pre-resolved embeds from parent (for sharing with editor) */ readonly resolvedEmbeds?: Record; @@ -116,9 +116,10 @@ export const MarkdownPreview = forwardRef('email'); + const [error, setError] = useState(null); + + if (!showLoginModal) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setStep('checking'); + + try { + // Call IPC to request magic link + await window.readied.sync?.requestMagicLink(email); + setStep('sent'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send magic link'); + setStep('email'); + } + }; + + const handleClose = () => { + setStep('email'); + setEmail(''); + setError(null); + closeLoginModal(); + }; + + return ( +
+
e.stopPropagation()}> + + +
+

Sign in to sync

+

+ Sync your notes across devices with Readied Pro. +

+ + {step === 'email' && ( +
+ + + {error &&

{error}

} + + +
+ )} + + {step === 'checking' && ( +
+
+

Sending magic link...

+
+ )} + + {step === 'sent' && ( +
+ + + +

Check your email

+

+ We sent a magic link to {email} +

+

Click the link in the email to sign in.

+ +
+ )} +
+ +
+

+ By signing in, you agree to our{' '} + + Terms + {' '} + and{' '} + + Privacy Policy + + . +

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.module.css b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.module.css new file mode 100644 index 0000000..35d4851 --- /dev/null +++ b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.module.css @@ -0,0 +1,66 @@ +.container { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + background: transparent; + border-radius: var(--radius-md, 6px); + cursor: pointer; + color: var(--color-text-secondary, #666); + transition: all 0.15s ease; + position: relative; +} + +.container:hover { + background: var(--color-bg-hover, rgba(0, 0, 0, 0.05)); + color: var(--color-text-primary, #333); +} + +.container:active { + transform: scale(0.95); +} + +.icon { + width: 18px; + height: 18px; +} + +.icon.spinning { + animation: spin 1s linear infinite; +} + +.icon.error { + color: var(--color-error, #dc2626); +} + +.icon.warning { + color: var(--color-warning, #f59e0b); +} + +.badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 14px; + height: 14px; + padding: 0 4px; + font-size: 10px; + font-weight: 600; + line-height: 14px; + text-align: center; + color: white; + background: var(--color-primary, #3b82f6); + border-radius: 7px; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx new file mode 100644 index 0000000..e9f118b --- /dev/null +++ b/apps/desktop/src/renderer/components/sync/SyncStatusIndicator.tsx @@ -0,0 +1,131 @@ +/** + * Sync Status Indicator + * + * Shows sync status in the sidebar/toolbar. + * Clicking opens sync settings or triggers manual sync. + */ + +import { useSyncStore, selectSyncStatus, selectPendingChanges } from '../../stores/syncStore'; +import styles from './SyncStatusIndicator.module.css'; + +export function SyncStatusIndicator() { + const syncStatus = useSyncStore(selectSyncStatus); + const pendingChanges = useSyncStore(selectPendingChanges); + const { openLoginModal, canSync } = useSyncStore(); + + const handleClick = async () => { + if (syncStatus.status === 'disabled') { + openLoginModal(); + return; + } + + if (canSync()) { + // Trigger sync via IPC + await window.readied.sync?.triggerSync(); + } + }; + + const getStatusIcon = () => { + switch (syncStatus.status) { + case 'disabled': + return ( + + + + + ); + case 'idle': + return ( + + + + + ); + case 'syncing': + return ( + + + + ); + case 'error': + return ( + + + + ! + + + ); + case 'conflict': + return ( + + + + ); + } + }; + + const getStatusText = () => { + switch (syncStatus.status) { + case 'disabled': + return 'Sync disabled'; + case 'idle': + return pendingChanges > 0 + ? `${pendingChanges} pending` + : syncStatus.lastSyncedAt + ? `Synced ${formatRelativeTime(syncStatus.lastSyncedAt)}` + : 'Synced'; + case 'syncing': + return `Syncing ${syncStatus.progress}%`; + case 'error': + return syncStatus.message; + case 'conflict': + return `${syncStatus.conflicts.length} conflict(s)`; + } + }; + + return ( + + ); +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} diff --git a/apps/desktop/src/renderer/components/sync/index.ts b/apps/desktop/src/renderer/components/sync/index.ts new file mode 100644 index 0000000..befb1e0 --- /dev/null +++ b/apps/desktop/src/renderer/components/sync/index.ts @@ -0,0 +1,8 @@ +/** + * Sync Components + * + * UI components for cloud sync functionality. + */ + +export { SyncStatusIndicator } from './SyncStatusIndicator'; +export { LoginModal } from './LoginModal'; diff --git a/apps/desktop/src/renderer/hooks/useTheme.ts b/apps/desktop/src/renderer/hooks/useTheme.ts new file mode 100644 index 0000000..871a03b --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useTheme.ts @@ -0,0 +1,126 @@ +/** + * Theme Hook + * + * Applies theme and accent color to document based on settings. + * Supports: 'dark', 'light', 'system' + custom accentColor + */ + +import { useEffect } from 'react'; +import { useSettingsStore, selectAppearance } from '../stores/settings'; + +type Theme = 'dark' | 'light' | 'system'; + +/** + * Get the resolved theme (dark or light) based on preference + */ +function resolveTheme(preference: Theme): 'dark' | 'light' { + if (preference === 'system') { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return preference; +} + +/** + * Apply theme to document root + */ +function applyTheme(theme: 'dark' | 'light') { + document.documentElement.setAttribute('data-theme', theme); + + // Also update meta theme-color for native UI + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + const color = theme === 'dark' ? '#0a0b0d' : '#ffffff'; + if (metaThemeColor) { + metaThemeColor.setAttribute('content', color); + } +} + +/** + * Parse hex color to RGB components + */ +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result + ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } + : null; +} + +/** + * Darken a hex color by a percentage + */ +function darkenHex(hex: string, percent: number): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const factor = 1 - percent / 100; + const r = Math.round(rgb.r * factor); + const g = Math.round(rgb.g * factor); + const b = Math.round(rgb.b * factor); + return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`; +} + +/** + * Apply accent color to CSS custom properties + */ +function applyAccentColor(hex: string, theme: 'dark' | 'light') { + const root = document.documentElement; + const rgb = hexToRgb(hex); + + if (!rgb) return; + + // Main accent color + root.style.setProperty('--accent', hex); + + // Muted version (for backgrounds) + const mutedOpacity = theme === 'dark' ? 0.15 : 0.12; + root.style.setProperty('--accent-muted', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${mutedOpacity})`); + + // Strong version (darker for buttons on hover) + root.style.setProperty('--accent-strong', darkenHex(hex, 15)); + + // Also update CodeMirror accent-related tokens + root.style.setProperty('--cm-heading', hex); + root.style.setProperty('--cm-cursor', hex); + root.style.setProperty('--cm-selection', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.2)`); + root.style.setProperty('--cm-bracket-match', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.3)`); + root.style.setProperty('--cm-quote-border', `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.5)`); +} + +/** + * Hook to manage theme and accent color based on settings + */ +export function useTheme() { + const appearance = useSettingsStore(selectAppearance); + const { theme: themePreference, accentColor } = appearance; + + // Apply theme + useEffect(() => { + const resolved = resolveTheme(themePreference); + applyTheme(resolved); + + // If system preference, listen for changes + if (themePreference === 'system') { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e: MediaQueryListEvent) => { + applyTheme(e.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } + }, [themePreference]); + + // Apply accent color + useEffect(() => { + const resolved = resolveTheme(themePreference); + applyAccentColor(accentColor, resolved); + }, [accentColor, themePreference]); + + return { + theme: themePreference, + resolvedTheme: resolveTheme(themePreference), + }; +} diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 97d715f..fc1c3c0 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -1,15 +1,58 @@ -import React from 'react'; +import React, { Suspense, lazy } from 'react'; import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './styles/global.css'; +// Detect which view to render based on query param +const params = new URLSearchParams(window.location.search); +const view = params.get('view') || 'main'; + +// Lazy load components +const App = lazy(() => import('./App').then((m) => ({ default: m.App }))); +const SettingsApp = lazy(() => + import('./pages/settings/SettingsApp').then((m) => ({ default: m.SettingsApp })) +); + +// QueryClient for settings (and potentially other views) +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60, // 1 minute + retry: 1, + }, + }, +}); + const container = document.getElementById('root'); if (!container) { throw new Error('Root element not found'); } +// Simple loading fallback +const LoadingFallback = () => ( +
+ Loading... +
+); + +// Render the appropriate view +const RootComponent = view === 'settings' ? SettingsApp : App; + createRoot(container).render( - + + }> + + + ); diff --git a/apps/desktop/src/renderer/pages/settings/SettingsApp.module.css b/apps/desktop/src/renderer/pages/settings/SettingsApp.module.css index e6893e4..80113af 100644 --- a/apps/desktop/src/renderer/pages/settings/SettingsApp.module.css +++ b/apps/desktop/src/renderer/pages/settings/SettingsApp.module.css @@ -1,7 +1,7 @@ .container { display: flex; height: 100vh; - background: var(--bg-primary); + background: var(--bg-base); color: var(--text-primary); } diff --git a/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx b/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx index 844aade..0872ffc 100644 --- a/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx +++ b/apps/desktop/src/renderer/pages/settings/SettingsApp.tsx @@ -4,11 +4,15 @@ import { SettingsSidebar } from './components/SettingsSidebar'; import { GeneralSection } from './sections/GeneralSection'; import { EditorSection } from './sections/EditorSection'; import { AppearanceSection } from './sections/AppearanceSection'; +import { BackupSection } from './sections/BackupSection'; +import { UpdatesSection } from './sections/UpdatesSection'; import { AboutSection } from './sections/AboutSection'; +import { useTheme } from '../../hooks/useTheme'; -export type SettingsSection = 'general' | 'editor' | 'appearance' | 'about'; +export type SettingsSection = 'general' | 'editor' | 'appearance' | 'backup' | 'updates' | 'about'; export function SettingsApp() { + useTheme(); const [activeSection, setActiveSection] = useState('general'); const renderSection = () => { @@ -19,6 +23,10 @@ export function SettingsApp() { return ; case 'appearance': return ; + case 'backup': + return ; + case 'updates': + return ; case 'about': return ; default: diff --git a/apps/desktop/src/renderer/pages/settings/components/SettingGroup.module.css b/apps/desktop/src/renderer/pages/settings/components/SettingGroup.module.css new file mode 100644 index 0000000..77fab90 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/SettingGroup.module.css @@ -0,0 +1,26 @@ +/** + * SettingGroup CSS + */ + +.group { + margin-bottom: 32px; +} + +.group:last-child { + margin-bottom: 0; +} + +.title { + margin: 0 0 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.content { + /* Container for SettingRow components */ +} diff --git a/apps/desktop/src/renderer/pages/settings/components/SettingGroup.tsx b/apps/desktop/src/renderer/pages/settings/components/SettingGroup.tsx new file mode 100644 index 0000000..91231bd --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/SettingGroup.tsx @@ -0,0 +1,24 @@ +/** + * SettingGroup Component + * + * Groups related settings with a header. + */ + +import { ReactNode } from 'react'; +import styles from './SettingGroup.module.css'; + +interface SettingGroupProps { + /** Group title */ + title: string; + /** Setting rows */ + children: ReactNode; +} + +export function SettingGroup({ title, children }: SettingGroupProps) { + return ( +
+

{title}

+
{children}
+
+ ); +} diff --git a/apps/desktop/src/renderer/pages/settings/components/SettingRow.module.css b/apps/desktop/src/renderer/pages/settings/components/SettingRow.module.css new file mode 100644 index 0000000..46839a5 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/SettingRow.module.css @@ -0,0 +1,42 @@ +/** + * SettingRow CSS + */ + +.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + padding: 12px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.row:last-child { + border-bottom: none; +} + +.labelContainer { + flex: 1; + min-width: 0; +} + +.label { + display: block; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: default; +} + +.description { + margin: 4px 0 0; + color: var(--text-muted); + font-size: 12px; + line-height: 1.4; +} + +.control { + flex-shrink: 0; + display: flex; + align-items: center; +} diff --git a/apps/desktop/src/renderer/pages/settings/components/SettingRow.tsx b/apps/desktop/src/renderer/pages/settings/components/SettingRow.tsx new file mode 100644 index 0000000..b767278 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/SettingRow.tsx @@ -0,0 +1,33 @@ +/** + * SettingRow Component + * + * A single row in the settings UI with label, description, and control. + */ + +import { ReactNode } from 'react'; +import styles from './SettingRow.module.css'; + +interface SettingRowProps { + /** Setting label (main text) */ + label: string; + /** Optional description (smaller text below label) */ + description?: string; + /** The control element (Toggle, Select, NumberInput, etc.) */ + children: ReactNode; + /** HTML id for accessibility (links label to control) */ + htmlFor?: string; +} + +export function SettingRow({ label, description, children, htmlFor }: SettingRowProps) { + return ( +
+
+ + {description &&

{description}

} +
+
{children}
+
+ ); +} diff --git a/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.module.css b/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.module.css index 78a1257..46ab48c 100644 --- a/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.module.css +++ b/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.module.css @@ -1,6 +1,6 @@ .sidebar { width: 200px; - background: var(--bg-secondary); + background: var(--bg-surface); border-right: 1px solid var(--border-subtle); display: flex; flex-direction: column; @@ -42,13 +42,13 @@ } .navItem:hover { - background: var(--bg-hover); + background: var(--bg-elevated); color: var(--text-primary); } .navItem.active { - background: var(--bg-active); - color: var(--text-primary); + background: var(--accent-muted); + color: var(--accent); } .label { diff --git a/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx b/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx index c473770..ec9b587 100644 --- a/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx +++ b/apps/desktop/src/renderer/pages/settings/components/SettingsSidebar.tsx @@ -10,6 +10,8 @@ const sections: { id: SettingsSection; label: string; icon: string }[] = [ { id: 'general', label: 'General', icon: 'cog' }, { id: 'editor', label: 'Editor', icon: 'edit' }, { id: 'appearance', label: 'Appearance', icon: 'palette' }, + { id: 'backup', label: 'Backup', icon: 'archive' }, + { id: 'updates', label: 'Updates', icon: 'download' }, { id: 'about', label: 'About', icon: 'info' }, ]; diff --git a/apps/desktop/src/renderer/pages/settings/components/controls/Controls.module.css b/apps/desktop/src/renderer/pages/settings/components/controls/Controls.module.css new file mode 100644 index 0000000..64c691e --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/controls/Controls.module.css @@ -0,0 +1,153 @@ +/** + * Settings Controls CSS + * + * Inkdrop-style: dark slate, clean inputs. + */ + +/* Toggle Switch */ +.toggle { + position: relative; + width: 44px; + height: 24px; + background: var(--bg-inset); + border: none; + border-radius: 12px; + cursor: pointer; + transition: background 0.2s ease; + flex-shrink: 0; + padding: 0; +} + +.toggle:hover { + background: var(--bg-elevated); +} + +.toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toggleOn { + background: var(--accent); +} + +.toggleOn:hover { + background: var(--accent-strong); +} + +.toggleThumb { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: #ffffff; + border-radius: 50%; + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.toggleOn .toggleThumb { + transform: translateX(20px); +} + +/* Select Dropdown */ +.select { + min-width: 140px; + padding: 6px 28px 6px 10px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + cursor: pointer; + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M3 4.5L6 8l3-3.5H3z'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + transition: border-color 0.15s ease; +} + +.select:hover { + border-color: var(--border-strong); +} + +.select:focus { + outline: none; + border-color: var(--accent); +} + +.select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Number Input */ +.numberInput { + width: 80px; + padding: 6px 10px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + text-align: right; + transition: border-color 0.15s ease; +} + +.numberInput:hover { + border-color: var(--border-strong); +} + +.numberInput:focus { + outline: none; + border-color: var(--accent); +} + +.numberInput:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Hide number input spinners */ +.numberInput::-webkit-inner-spin-button, +.numberInput::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.numberInput[type='number'] { + -moz-appearance: textfield; +} + +/* Text Input */ +.textInput { + min-width: 200px; + padding: 6px 10px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + transition: border-color 0.15s ease; +} + +.textInput:hover { + border-color: var(--border-strong); +} + +.textInput:focus { + outline: none; + border-color: var(--accent); +} + +.textInput::placeholder { + color: var(--text-faint); +} + +.textInput:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/apps/desktop/src/renderer/pages/settings/components/controls/NumberInput.tsx b/apps/desktop/src/renderer/pages/settings/components/controls/NumberInput.tsx new file mode 100644 index 0000000..659f9d5 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/controls/NumberInput.tsx @@ -0,0 +1,52 @@ +/** + * NumberInput Control + * + * A number input with optional min/max/step constraints. + */ + +import styles from './Controls.module.css'; + +interface NumberInputProps { + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + disabled?: boolean; + id?: string; +} + +export function NumberInput({ + value, + onChange, + min, + max, + step = 1, + disabled = false, + id, +}: NumberInputProps) { + const handleChange = (e: React.ChangeEvent) => { + const newValue = parseFloat(e.target.value); + if (!isNaN(newValue)) { + // Clamp to min/max if specified + let clamped = newValue; + if (min !== undefined) clamped = Math.max(min, clamped); + if (max !== undefined) clamped = Math.min(max, clamped); + onChange(clamped); + } + }; + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/pages/settings/components/controls/Select.tsx b/apps/desktop/src/renderer/pages/settings/components/controls/Select.tsx new file mode 100644 index 0000000..3fc248a --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/controls/Select.tsx @@ -0,0 +1,44 @@ +/** + * Select Control + * + * A dropdown for selecting from predefined options. + */ + +import styles from './Controls.module.css'; + +interface SelectOption { + value: T; + label: string; +} + +interface SelectProps { + value: T; + onChange: (value: T) => void; + options: SelectOption[]; + disabled?: boolean; + id?: string; +} + +export function Select({ + value, + onChange, + options, + disabled = false, + id, +}: SelectProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/pages/settings/components/controls/TextInput.tsx b/apps/desktop/src/renderer/pages/settings/components/controls/TextInput.tsx new file mode 100644 index 0000000..3c2feeb --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/controls/TextInput.tsx @@ -0,0 +1,35 @@ +/** + * TextInput Control + * + * A text input for string settings. + */ + +import styles from './Controls.module.css'; + +interface TextInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + id?: string; +} + +export function TextInput({ + value, + onChange, + placeholder, + disabled = false, + id, +}: TextInputProps) { + return ( + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + /> + ); +} diff --git a/apps/desktop/src/renderer/pages/settings/components/controls/Toggle.tsx b/apps/desktop/src/renderer/pages/settings/components/controls/Toggle.tsx new file mode 100644 index 0000000..4996bb9 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/controls/Toggle.tsx @@ -0,0 +1,30 @@ +/** + * Toggle Control + * + * A switch/checkbox for boolean settings. + */ + +import styles from './Controls.module.css'; + +interface ToggleProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + id?: string; +} + +export function Toggle({ checked, onChange, disabled = false, id }: ToggleProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/pages/settings/components/controls/index.ts b/apps/desktop/src/renderer/pages/settings/components/controls/index.ts new file mode 100644 index 0000000..e442ddc --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/components/controls/index.ts @@ -0,0 +1,4 @@ +export { Toggle } from './Toggle'; +export { Select } from './Select'; +export { NumberInput } from './NumberInput'; +export { TextInput } from './TextInput'; diff --git a/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx index 9a4056b..d0cd0ad 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx +++ b/apps/desktop/src/renderer/pages/settings/sections/AppearanceSection.tsx @@ -1,10 +1,96 @@ +/** + * Appearance Settings Section + * + * Theme selection and accent color. + */ + +import { Monitor, Moon, Sun } from 'lucide-react'; +import { useSettingsStore, selectAppearance } from '../../../stores/settings'; +import { SettingGroup } from '../components/SettingGroup'; +import { SettingRow } from '../components/SettingRow'; import styles from './Section.module.css'; +type ThemeOption = 'dark' | 'light' | 'system'; + +const themeOptions: { value: ThemeOption; label: string; icon: React.ReactNode }[] = [ + { value: 'light', label: 'Light', icon: }, + { value: 'dark', label: 'Dark', icon: }, + { value: 'system', label: 'System', icon: }, +]; + +/** Preset accent colors */ +const accentPresets = [ + { value: '#5eead4', label: 'Teal' }, + { value: '#3b82f6', label: 'Blue' }, + { value: '#8b5cf6', label: 'Purple' }, + { value: '#f43f5e', label: 'Rose' }, + { value: '#f97316', label: 'Orange' }, + { value: '#22c55e', label: 'Green' }, +]; + export function AppearanceSection() { + const appearance = useSettingsStore(selectAppearance); + const updateAppearance = useSettingsStore((s) => s.updateAppearance); + return (

Appearance

-

Appearance settings coming soon...

+ + + +
+ {themeOptions.map((option) => ( + + ))} +
+
+
+ + + +
+ {accentPresets.map((preset) => ( +
+
+
); } diff --git a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css index 1466fc9..b3dc85c 100644 --- a/apps/desktop/src/renderer/pages/settings/sections/Section.module.css +++ b/apps/desktop/src/renderer/pages/settings/sections/Section.module.css @@ -10,7 +10,7 @@ } .placeholder { - color: var(--text-tertiary); + color: var(--text-muted); font-size: var(--text-sm); } @@ -37,13 +37,144 @@ } .link { - color: var(--accent-primary); + color: var(--accent); text-decoration: none; font-size: var(--text-sm); transition: color var(--transition-fast); } .link:hover { - color: var(--accent-hover); + color: var(--accent-strong); text-decoration: underline; } + +/* Action button (e.g., Open Folder) */ +.actionButton { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.actionButton:hover { + background: var(--bg-surface); + border-color: var(--border-strong); +} + +.actionButton:active { + background: var(--bg-inset); +} + +/* Theme selector (segmented control) */ +.themeSelector { + display: flex; + gap: 4px; + background: var(--bg-inset); + padding: 4px; + border-radius: 8px; +} + +.themeOption { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: transparent; + border: none; + border-radius: 6px; + color: var(--text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.themeOption:hover { + color: var(--text-secondary); + background: var(--bg-elevated); +} + +.themeOptionActive { + background: var(--bg-elevated); + color: var(--text-primary); +} + +.themeOptionActive:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +/* Spinning animation */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.spinning { + animation: spin 1s linear infinite; +} + +/* Check result message */ +.checkResult { + padding: 8px 12px; + margin-top: 8px; + background: var(--bg-inset); + border-radius: 6px; + font-size: 13px; + color: var(--text-secondary); +} + +/* Color picker */ +.colorPicker { + display: flex; + align-items: center; + gap: 6px; +} + +.colorSwatch { + width: 24px; + height: 24px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: all 0.15s ease; +} + +.colorSwatch:hover { + transform: scale(1.1); +} + +.colorSwatchActive { + border-color: var(--text-primary); +} + +.colorInput { + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 50%; + cursor: pointer; + background: transparent; +} + +.colorInput::-webkit-color-swatch-wrapper { + padding: 0; +} + +.colorInput::-webkit-color-swatch { + border: 1px dashed var(--border-strong); + border-radius: 50%; +} diff --git a/apps/desktop/src/renderer/pages/settings/sections/UpdatesSection.tsx b/apps/desktop/src/renderer/pages/settings/sections/UpdatesSection.tsx new file mode 100644 index 0000000..05e65e8 --- /dev/null +++ b/apps/desktop/src/renderer/pages/settings/sections/UpdatesSection.tsx @@ -0,0 +1,100 @@ +/** + * Updates Settings Section + * + * Auto-update preferences and manual check. + */ + +import { useState, useCallback } from 'react'; +import { RefreshCw, Download } from 'lucide-react'; +import { useSettingsStore, selectUpdates } from '../../../stores/settings'; +import { SettingGroup } from '../components/SettingGroup'; +import { SettingRow } from '../components/SettingRow'; +import { Toggle } from '../components/controls'; +import styles from './Section.module.css'; + +export function UpdatesSection() { + const updates = useSettingsStore(selectUpdates); + const updateUpdates = useSettingsStore((s) => s.updateUpdates); + const [isChecking, setIsChecking] = useState(false); + const [checkResult, setCheckResult] = useState(null); + + const handleCheckForUpdates = useCallback(async () => { + setIsChecking(true); + setCheckResult(null); + + try { + const result = await window.readied.updates.checkNow(); + if (result.available) { + setCheckResult(`Update available: v${result.version}`); + } else { + setCheckResult('You are on the latest version'); + } + // Update last checked timestamp + updateUpdates({ lastCheckedAt: Date.now() }); + } catch { + setCheckResult('Failed to check for updates'); + } finally { + setIsChecking(false); + } + }, [updateUpdates]); + + const formatLastChecked = (timestamp: number | null) => { + if (!timestamp) return 'Never'; + const date = new Date(timestamp); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( +
+

Updates

+ + + + updateUpdates({ autoCheck: checked })} + /> + + + + + + + + {checkResult && ( +
{checkResult}
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/settings.html b/apps/desktop/src/renderer/settings.html deleted file mode 100644 index 5c93200..0000000 --- a/apps/desktop/src/renderer/settings.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - Settings - Readied - - -
- - - diff --git a/apps/desktop/src/renderer/settings.tsx b/apps/desktop/src/renderer/settings.tsx deleted file mode 100644 index ee07ae7..0000000 --- a/apps/desktop/src/renderer/settings.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import { SettingsApp } from './pages/settings/SettingsApp'; -import './styles/global.css'; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - -); diff --git a/apps/desktop/src/renderer/stores/settings/index.ts b/apps/desktop/src/renderer/stores/settings/index.ts new file mode 100644 index 0000000..2365374 --- /dev/null +++ b/apps/desktop/src/renderer/stores/settings/index.ts @@ -0,0 +1,8 @@ +/** + * Settings Module + * + * Re-exports schema and store for convenient imports. + */ + +export * from './schema'; +export * from './settingsStore'; diff --git a/apps/desktop/src/renderer/stores/settings/schema.ts b/apps/desktop/src/renderer/stores/settings/schema.ts new file mode 100644 index 0000000..42dee57 --- /dev/null +++ b/apps/desktop/src/renderer/stores/settings/schema.ts @@ -0,0 +1,152 @@ +/** + * Settings Schema + * + * Layer 1: Data model with versioning. + * This file defines the structure of all settings and their defaults. + * + * IMPORTANT: When adding new settings: + * 1. Add the type to the appropriate interface + * 2. Add the default value to DEFAULT_SETTINGS + * 3. Bump SETTINGS_VERSION and add migration logic in settingsStore.ts + */ + +// ============================================================================ +// Version +// ============================================================================ + +export const SETTINGS_VERSION = 1; + +// ============================================================================ +// Section Types +// ============================================================================ + +/** General application settings */ +export interface GeneralSettings { + /** Default notebook for new notes */ + defaultNotebookId: string; + /** Remember window position and size on startup */ + rememberWindowPosition: boolean; +} + +/** Update checker settings (stateful, not just a boolean) */ +export interface UpdatesSettings { + /** Auto-check for updates on startup */ + autoCheck: boolean; + /** Timestamp of last check (null if never checked) */ + lastCheckedAt: number | null; +} + +/** Appearance and theme settings */ +export interface AppearanceSettings { + /** Color theme: dark, light, or follow system */ + theme: 'dark' | 'light' | 'system'; + /** Accent color for highlights (hex) */ + accentColor: string; + /** @deprecated No longer used - kept for schema compatibility */ + acrylicBackground: boolean; +} + +/** Backup settings */ +export interface BackupSettings { + /** Enable automatic backups */ + autoBackup: boolean; + /** Backup interval in days */ + backupIntervalDays: number; + /** Timestamp of last backup (null if never) */ + lastBackupAt: number | null; +} + +/** Editor settings for CodeMirror */ +export interface EditorSettings { + /** Font size in pixels */ + fontSize: number; + /** Font family for editor text */ + fontFamily: string; + /** Line height multiplier */ + lineHeight: number; + /** Show line numbers gutter */ + lineNumbers: boolean; + /** Highlight the current line */ + highlightActiveLine: boolean; + /** Wrap long lines */ + lineWrapping: boolean; + /** Show inline image previews */ + inlineImages: boolean; + /** Allow scrolling past the end of document */ + scrollPastEnd: boolean; + /** Tab size in spaces */ + tabSize: number; + /** Use tabs instead of spaces for indentation */ + indentWithTabs: boolean; + /** Enable spell check in editor */ + spellCheck: boolean; +} + +// ============================================================================ +// Full Schema (Versioned) +// ============================================================================ + +export interface SettingsSchemaV1 { + version: 1; + general: GeneralSettings; + updates: UpdatesSettings; + appearance: AppearanceSettings; + editor: EditorSettings; + backup: BackupSettings; +} + +/** Current settings schema type */ +export type SettingsSchema = SettingsSchemaV1; + +/** Section keys (excluding version) */ +export type SettingsSection = keyof Omit; + +// ============================================================================ +// Default Values +// ============================================================================ + +export const DEFAULT_GENERAL: GeneralSettings = { + defaultNotebookId: 'inbox', + rememberWindowPosition: true, +}; + +export const DEFAULT_UPDATES: UpdatesSettings = { + autoCheck: true, + lastCheckedAt: null, +}; + +export const DEFAULT_APPEARANCE: AppearanceSettings = { + theme: 'dark', + accentColor: '#5eead4', + acrylicBackground: false, +}; + +export const DEFAULT_EDITOR: EditorSettings = { + fontSize: 14, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + lineHeight: 1.6, + lineNumbers: false, + highlightActiveLine: false, + lineWrapping: true, + inlineImages: true, + scrollPastEnd: true, + tabSize: 2, + indentWithTabs: false, + spellCheck: true, +}; + +export const DEFAULT_BACKUP: BackupSettings = { + autoBackup: false, + backupIntervalDays: 7, + lastBackupAt: null, +}; + +/** Complete default settings */ +export const DEFAULT_SETTINGS: SettingsSchema = { + version: 1, + general: DEFAULT_GENERAL, + updates: DEFAULT_UPDATES, + appearance: DEFAULT_APPEARANCE, + editor: DEFAULT_EDITOR, + backup: DEFAULT_BACKUP, +}; diff --git a/apps/desktop/src/renderer/stores/settings/settingsStore.ts b/apps/desktop/src/renderer/stores/settings/settingsStore.ts new file mode 100644 index 0000000..986b7f0 --- /dev/null +++ b/apps/desktop/src/renderer/stores/settings/settingsStore.ts @@ -0,0 +1,222 @@ +/** + * Settings Store + * + * Layer 2: State management with Zustand + persist. + * Handles persistence to localStorage and migrations between versions. + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { + SettingsSchema, + SettingsSection, + GeneralSettings, + UpdatesSettings, + AppearanceSettings, + EditorSettings, + BackupSettings, + DEFAULT_SETTINGS, + DEFAULT_GENERAL, + DEFAULT_UPDATES, + DEFAULT_APPEARANCE, + DEFAULT_EDITOR, + DEFAULT_BACKUP, + SETTINGS_VERSION, +} from './schema'; + +// ============================================================================ +// Store Interface +// ============================================================================ + +interface SettingsStore { + /** Current settings state */ + settings: SettingsSchema; + + // Granular update actions (immutable updates) + updateGeneral: (updates: Partial) => void; + updateUpdates: (updates: Partial) => void; + updateAppearance: (updates: Partial) => void; + updateEditor: (updates: Partial) => void; + updateBackup: (updates: Partial) => void; + + // Reset actions + resetSection: (section: SettingsSection) => void; + resetAll: () => void; +} + +// ============================================================================ +// Migration Logic +// ============================================================================ + +/** + * Migrate settings from older versions to current version. + * Add cases here when bumping SETTINGS_VERSION. + */ +function migrateSettings( + persisted: unknown, + version: number +): { settings: SettingsSchema } { + // Type guard for persisted state + const state = persisted as { settings?: Partial } | undefined; + + // If no persisted state or no settings, return defaults + if (!state?.settings) { + return { settings: DEFAULT_SETTINGS }; + } + + let settings = state.settings as SettingsSchema; + + // Migration: v0 (or undefined) -> v1 + if (version < 1) { + settings = { + ...DEFAULT_SETTINGS, + ...settings, + version: 1, + // Ensure all sections exist with defaults + general: { ...DEFAULT_GENERAL, ...settings.general }, + updates: { ...DEFAULT_UPDATES, ...settings.updates }, + appearance: { ...DEFAULT_APPEARANCE, ...settings.appearance }, + editor: { ...DEFAULT_EDITOR, ...settings.editor }, + backup: { ...DEFAULT_BACKUP, ...settings.backup }, + }; + } + + // Future migrations go here: + // if (version < 2) { ... migrate to v2 ... } + + return { settings }; +} + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useSettingsStore = create()( + persist( + (set) => ({ + settings: DEFAULT_SETTINGS, + + // Update general settings + updateGeneral: (updates) => + set((state) => ({ + settings: { + ...state.settings, + general: { ...state.settings.general, ...updates }, + }, + })), + + // Update updates settings + updateUpdates: (updates) => + set((state) => ({ + settings: { + ...state.settings, + updates: { ...state.settings.updates, ...updates }, + }, + })), + + // Update appearance settings + updateAppearance: (updates) => + set((state) => ({ + settings: { + ...state.settings, + appearance: { ...state.settings.appearance, ...updates }, + }, + })), + + // Update editor settings + updateEditor: (updates) => + set((state) => ({ + settings: { + ...state.settings, + editor: { ...state.settings.editor, ...updates }, + }, + })), + + // Update backup settings + updateBackup: (updates) => + set((state) => ({ + settings: { + ...state.settings, + backup: { ...state.settings.backup, ...updates }, + }, + })), + + // Reset a specific section to defaults + resetSection: (section) => + set((state) => { + const defaults: Record = { + general: DEFAULT_GENERAL, + updates: DEFAULT_UPDATES, + appearance: DEFAULT_APPEARANCE, + editor: DEFAULT_EDITOR, + backup: DEFAULT_BACKUP, + }; + return { + settings: { + ...state.settings, + [section]: defaults[section], + }, + }; + }), + + // Reset all settings to defaults + resetAll: () => set({ settings: DEFAULT_SETTINGS }), + }), + { + name: 'readied-settings', + version: SETTINGS_VERSION, + migrate: migrateSettings, + } + ) +); + +// ============================================================================ +// Selectors (for convenience) +// ============================================================================ + +export const selectSettings = (state: SettingsStore) => state.settings; +export const selectGeneral = (state: SettingsStore) => state.settings.general; +export const selectUpdates = (state: SettingsStore) => state.settings.updates; +export const selectAppearance = (state: SettingsStore) => state.settings.appearance; +export const selectEditor = (state: SettingsStore) => state.settings.editor; +export const selectBackup = (state: SettingsStore) => state.settings.backup; + +// Individual editor settings selectors (for CodeMirror integration) +export const selectFontSize = (state: SettingsStore) => state.settings.editor.fontSize; +export const selectFontFamily = (state: SettingsStore) => state.settings.editor.fontFamily; +export const selectLineHeight = (state: SettingsStore) => state.settings.editor.lineHeight; +export const selectLineNumbers = (state: SettingsStore) => state.settings.editor.lineNumbers; +export const selectHighlightActiveLine = (state: SettingsStore) => + state.settings.editor.highlightActiveLine; +export const selectLineWrapping = (state: SettingsStore) => state.settings.editor.lineWrapping; +export const selectInlineImages = (state: SettingsStore) => state.settings.editor.inlineImages; +export const selectScrollPastEnd = (state: SettingsStore) => state.settings.editor.scrollPastEnd; +export const selectTabSize = (state: SettingsStore) => state.settings.editor.tabSize; +export const selectIndentWithTabs = (state: SettingsStore) => state.settings.editor.indentWithTabs; +export const selectSpellCheck = (state: SettingsStore) => state.settings.editor.spellCheck; + +// ============================================================================ +// Cross-Window Sync via IPC +// ============================================================================ + +// Anti-loop flag: prevents re-emitting when receiving sync from another window +let isRemoteUpdate = false; + +// Setup sync listeners (only in browser environment with preload API available) +if (typeof window !== 'undefined' && window.readied?.settings) { + // Listen for settings sync from other windows + window.readied.settings.onSync((settings: unknown) => { + isRemoteUpdate = true; + useSettingsStore.setState({ settings: settings as SettingsSchema }); + isRemoteUpdate = false; + }); + + // Emit changes to other windows when settings change locally + let prevSettings = useSettingsStore.getState().settings; + useSettingsStore.subscribe((state) => { + if (!isRemoteUpdate && state.settings !== prevSettings) { + window.readied.settings.notifyChange(state.settings); + } + prevSettings = state.settings; + }); +} diff --git a/apps/desktop/src/renderer/stores/syncStore.ts b/apps/desktop/src/renderer/stores/syncStore.ts new file mode 100644 index 0000000..177fbb6 --- /dev/null +++ b/apps/desktop/src/renderer/stores/syncStore.ts @@ -0,0 +1,152 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { SyncStatus, SyncConflict, SyncUser } from '@readied/sync-core'; + +// ============================================================================ +// Types +// ============================================================================ + +/** Auth state */ +export type AuthState = + | { status: 'logged_out' } + | { status: 'logging_in' } + | { status: 'logged_in'; user: SyncUser }; + +// ============================================================================ +// Store Interface +// ============================================================================ + +interface SyncStore { + // Auth State + auth: AuthState; + authToken: string | null; + + // Sync State + syncStatus: SyncStatus; + pendingChanges: number; + lastSyncedAt: string | null; + + // UI State + showLoginModal: boolean; + showConflictModal: boolean; + conflicts: SyncConflict[]; + + // Auth Actions + setAuthToken: (token: string | null) => void; + setUser: (user: SyncUser | null) => void; + startLogin: () => void; + logout: () => void; + + // Sync Actions + setSyncStatus: (status: SyncStatus) => void; + setPendingChanges: (count: number) => void; + setLastSyncedAt: (timestamp: string | null) => void; + setConflicts: (conflicts: SyncConflict[]) => void; + + // UI Actions + openLoginModal: () => void; + closeLoginModal: () => void; + openConflictModal: () => void; + closeConflictModal: () => void; + + // Computed + isLoggedIn: () => boolean; + isSyncEnabled: () => boolean; + canSync: () => boolean; +} + +// ============================================================================ +// Store Implementation +// ============================================================================ + +export const useSyncStore = create()( + persist( + (set, get) => ({ + // Initial State + auth: { status: 'logged_out' }, + authToken: null, + syncStatus: { status: 'disabled' }, + pendingChanges: 0, + lastSyncedAt: null, + showLoginModal: false, + showConflictModal: false, + conflicts: [], + + // Auth Actions + setAuthToken: token => set({ authToken: token }), + + setUser: user => + set({ + auth: user ? { status: 'logged_in', user } : { status: 'logged_out' }, + }), + + startLogin: () => set({ auth: { status: 'logging_in' } }), + + logout: () => + set({ + auth: { status: 'logged_out' }, + authToken: null, + syncStatus: { status: 'disabled' }, + pendingChanges: 0, + conflicts: [], + }), + + // Sync Actions + setSyncStatus: status => { + set({ syncStatus: status }); + if (status.status === 'idle' && status.lastSyncedAt) { + set({ lastSyncedAt: status.lastSyncedAt }); + } + if (status.status === 'conflict') { + set({ conflicts: status.conflicts, showConflictModal: true }); + } + }, + + setPendingChanges: count => set({ pendingChanges: count }), + + setLastSyncedAt: timestamp => set({ lastSyncedAt: timestamp }), + + setConflicts: conflicts => set({ conflicts }), + + // UI Actions + openLoginModal: () => set({ showLoginModal: true }), + closeLoginModal: () => set({ showLoginModal: false }), + openConflictModal: () => set({ showConflictModal: true }), + closeConflictModal: () => set({ showConflictModal: false }), + + // Computed + isLoggedIn: () => get().auth.status === 'logged_in', + + isSyncEnabled: () => { + const { auth, syncStatus } = get(); + return auth.status === 'logged_in' && syncStatus.status !== 'disabled'; + }, + + canSync: () => { + const { auth, syncStatus } = get(); + return ( + auth.status === 'logged_in' && + syncStatus.status !== 'disabled' && + syncStatus.status !== 'syncing' + ); + }, + }), + { + name: 'readied-sync', + // Only persist auth token and last synced time + partialize: state => ({ + authToken: state.authToken, + lastSyncedAt: state.lastSyncedAt, + }), + } + ) +); + +// ============================================================================ +// Selectors (for performance) +// ============================================================================ + +export const selectSyncStatus = (state: SyncStore) => state.syncStatus; +export const selectAuth = (state: SyncStore) => state.auth; +export const selectPendingChanges = (state: SyncStore) => state.pendingChanges; +export const selectConflicts = (state: SyncStore) => state.conflicts; diff --git a/apps/desktop/src/renderer/styles/editor-header.css b/apps/desktop/src/renderer/styles/editor-header.css index 489e820..a05775a 100644 --- a/apps/desktop/src/renderer/styles/editor-header.css +++ b/apps/desktop/src/renderer/styles/editor-header.css @@ -76,7 +76,6 @@ background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: var(--radius-md); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 100; padding: 4px 0; } @@ -302,7 +301,6 @@ background: var(--surface-elevated); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); z-index: 100; animation: tags-suggestions-enter var(--transition-fast) ease-out; } diff --git a/apps/desktop/src/renderer/styles/global.css b/apps/desktop/src/renderer/styles/global.css index c71db83..9b1194b 100644 --- a/apps/desktop/src/renderer/styles/global.css +++ b/apps/desktop/src/renderer/styles/global.css @@ -48,7 +48,6 @@ body { --glass-blur: 0px; --glass-saturate: 100%; --glass-bg: var(--glass-bg-fallback); - --glass-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); --glass-border: rgba(255, 255, 255, 0.12); } @@ -429,7 +428,6 @@ input:focus-visible { display: flex; flex-direction: column; gap: 6px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); } .floating-view-toggle .editor-view-toggle { @@ -526,10 +524,10 @@ input:focus-visible { @keyframes sidebar-subtle-glow { 0%, 100% { - box-shadow: 0 0 0 0 transparent; + opacity: 1; } 50% { - box-shadow: 0 0 8px -2px var(--accent); + opacity: 0.8; } } diff --git a/apps/desktop/src/renderer/styles/lightbox.css b/apps/desktop/src/renderer/styles/lightbox.css index 61e053e..2984a0f 100644 --- a/apps/desktop/src/renderer/styles/lightbox.css +++ b/apps/desktop/src/renderer/styles/lightbox.css @@ -25,7 +25,6 @@ padding: 8px; background: var(--bg-elevated, #1a1b1e); border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } .lightbox-btn { @@ -91,5 +90,4 @@ color: var(--text-secondary, #a0a0a0); background: var(--bg-elevated, #1a1b1e); border-radius: 6px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } diff --git a/apps/desktop/src/renderer/styles/note-list.css b/apps/desktop/src/renderer/styles/note-list.css index 310f0d3..7ae3032 100644 --- a/apps/desktop/src/renderer/styles/note-list.css +++ b/apps/desktop/src/renderer/styles/note-list.css @@ -72,7 +72,6 @@ background: var(--bg-elevated); border: 1px solid var(--border-strong); border-radius: var(--radius-md); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 100; padding: 4px 0; } @@ -203,7 +202,7 @@ border-bottom: 1px solid var(--border-subtle); cursor: pointer; position: relative; - transition: background var(--transition-fast); + transition: all var(--transition-fast); } .note-list-item:hover { diff --git a/apps/desktop/src/renderer/styles/tokens.css b/apps/desktop/src/renderer/styles/tokens.css index 837febc..3f7df23 100644 --- a/apps/desktop/src/renderer/styles/tokens.css +++ b/apps/desktop/src/renderer/styles/tokens.css @@ -79,6 +79,91 @@ --glass-blur: 28px; --glass-saturate: 180%; --glass-border: rgba(255, 255, 255, 0.08); - --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); --glass-bg-fallback: rgba(20, 22, 26, 0.95); + + /* ===== CODEMIRROR / EDITOR ===== */ + --cm-text: #f4f4f5; + --cm-cursor: #5eead4; + --cm-selection: rgba(94, 234, 212, 0.2); + --cm-active-line: rgba(255, 255, 255, 0.03); + --cm-gutter-border: rgba(255, 255, 255, 0.06); + --cm-gutter-text: rgba(255, 255, 255, 0.25); + --cm-tooltip-bg: rgba(24, 24, 27, 0.98); + --cm-tooltip-border: rgba(255, 255, 255, 0.1); + --cm-tooltip-text: #a1a1aa; + --cm-bracket-match: rgba(94, 234, 212, 0.3); + + /* Syntax highlighting */ + --cm-heading: #5eead4; + --cm-emphasis: #fbbf24; + --cm-strong: #f4f4f5; + --cm-strikethrough: rgba(255, 255, 255, 0.5); + --cm-code-bg: rgba(255, 255, 255, 0.08); + --cm-link: #60a5fa; + --cm-list: #a78bfa; + --cm-quote: rgba(255, 255, 255, 0.6); + --cm-quote-border: rgba(94, 234, 212, 0.5); + --cm-meta: rgba(255, 255, 255, 0.4); +} + +/* ===== LIGHT THEME ===== */ +[data-theme="light"] { + /* Backgrounds */ + --bg-base: #ffffff; + --bg-surface: #f8f9fa; + --bg-elevated: #f1f3f4; + --bg-inset: #e8eaed; + + /* Borders */ + --border: rgba(0, 0, 0, 0.1); + --border-subtle: rgba(0, 0, 0, 0.05); + --border-strong: rgba(0, 0, 0, 0.15); + + /* Text */ + --text-primary: #1a1a1a; + --text-secondary: rgba(0, 0, 0, 0.7); + --text-muted: rgba(0, 0, 0, 0.5); + --text-faint: rgba(0, 0, 0, 0.3); + + /* Accent (teal - works in both themes) */ + --accent: #0d9488; + --accent-muted: rgba(13, 148, 136, 0.12); + --accent-strong: #0f766e; + + /* Semantic */ + --danger: #dc2626; + --danger-muted: rgba(220, 38, 38, 0.1); + --warning: #d97706; + --warning-muted: rgba(217, 119, 6, 0.1); + --success: #16a34a; + --success-muted: rgba(22, 163, 74, 0.1); + + /* Glass / Blur for light mode */ + --glass-bg: rgba(255, 255, 255, 0.85); + --glass-border: rgba(0, 0, 0, 0.08); + --glass-bg-fallback: rgba(255, 255, 255, 0.95); + + /* CodeMirror / Editor for light mode */ + --cm-text: #1a1a1a; + --cm-cursor: #0d9488; + --cm-selection: rgba(13, 148, 136, 0.2); + --cm-active-line: rgba(0, 0, 0, 0.03); + --cm-gutter-border: rgba(0, 0, 0, 0.06); + --cm-gutter-text: rgba(0, 0, 0, 0.35); + --cm-tooltip-bg: rgba(255, 255, 255, 0.98); + --cm-tooltip-border: rgba(0, 0, 0, 0.1); + --cm-tooltip-text: #525252; + --cm-bracket-match: rgba(13, 148, 136, 0.3); + + /* Syntax highlighting for light mode */ + --cm-heading: #0d9488; + --cm-emphasis: #b45309; + --cm-strong: #1a1a1a; + --cm-strikethrough: rgba(0, 0, 0, 0.5); + --cm-code-bg: rgba(0, 0, 0, 0.05); + --cm-link: #2563eb; + --cm-list: #7c3aed; + --cm-quote: rgba(0, 0, 0, 0.6); + --cm-quote-border: rgba(13, 148, 136, 0.5); + --cm-meta: rgba(0, 0, 0, 0.4); } diff --git a/packages/storage-sqlite/src/migrations/008_fts5_index.ts b/packages/storage-sqlite/src/migrations/008_fts5_index.ts new file mode 100644 index 0000000..eb5ca19 --- /dev/null +++ b/packages/storage-sqlite/src/migrations/008_fts5_index.ts @@ -0,0 +1,56 @@ +/** + * FTS5 Full-Text Search Migration + * + * Creates FTS5 virtual table for fast, ranked full-text search. + * + * Design decisions: + * - Uses contentless FTS5 with explicit id column (notes table uses TEXT PRIMARY KEY) + * - Triggers keep FTS index in sync on INSERT/UPDATE/DELETE + * - bm25() provides relevance ranking for search results + */ + +import type { Migration } from '@readied/storage-core'; + +export const addFts5Index: Migration = { + version: 20260106000001, + name: 'fts5_index', + up: ` + -- FTS5 virtual table for full-text search + -- Using contentless mode with explicit id for TEXT PRIMARY KEY compatibility + CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5( + id UNINDEXED, + title, + content, + tokenize='porter unicode61' + ); + + -- Populate FTS table with existing notes (excluding deleted) + INSERT INTO notes_fts(id, title, content) + SELECT id, title, content FROM notes WHERE is_deleted = 0 OR is_deleted IS NULL; + + -- Trigger: Add to FTS on INSERT + CREATE TRIGGER IF NOT EXISTS notes_fts_insert AFTER INSERT ON notes + WHEN NEW.is_deleted = 0 OR NEW.is_deleted IS NULL + BEGIN + INSERT INTO notes_fts(id, title, content) + VALUES (NEW.id, NEW.title, NEW.content); + END; + + -- Trigger: Update FTS on UPDATE (delete + re-insert for FTS5) + CREATE TRIGGER IF NOT EXISTS notes_fts_update AFTER UPDATE ON notes + BEGIN + -- Remove old entry + DELETE FROM notes_fts WHERE id = OLD.id; + -- Re-insert if not deleted + INSERT INTO notes_fts(id, title, content) + SELECT NEW.id, NEW.title, NEW.content + WHERE NEW.is_deleted = 0 OR NEW.is_deleted IS NULL; + END; + + -- Trigger: Remove from FTS on DELETE + CREATE TRIGGER IF NOT EXISTS notes_fts_delete AFTER DELETE ON notes + BEGIN + DELETE FROM notes_fts WHERE id = OLD.id; + END; + `, +}; diff --git a/packages/storage-sqlite/src/migrations/009_link_anchors.ts b/packages/storage-sqlite/src/migrations/009_link_anchors.ts new file mode 100644 index 0000000..995b1f0 --- /dev/null +++ b/packages/storage-sqlite/src/migrations/009_link_anchors.ts @@ -0,0 +1,27 @@ +/** + * Link Anchors Migration + * + * Adds support for heading anchors in wikilinks: [[Note#Heading]] + * + * Design decisions: + * - target_anchor stores the heading text (e.g., "Section One" from [[Note#Section One]]) + * - Unique constraint includes anchor to allow same target with different anchors + */ + +import type { Migration } from '@readied/storage-core'; + +export const addLinkAnchors: Migration = { + version: 20260107000001, + name: 'link_anchors', + up: ` + -- Add anchor column to links table + ALTER TABLE links ADD COLUMN target_anchor TEXT; + + -- Drop old unique constraint and create new one including anchor + DROP INDEX IF EXISTS idx_links_source_target; + + -- Recreate unique index to include anchor (COALESCE handles NULL) + CREATE UNIQUE INDEX IF NOT EXISTS idx_links_source_target_anchor + ON links(source_note_id, target_ref, COALESCE(target_anchor, '')); + `, +}; diff --git a/packages/storage-sqlite/src/migrations/010_sync_fields.ts b/packages/storage-sqlite/src/migrations/010_sync_fields.ts new file mode 100644 index 0000000..fcbd322 --- /dev/null +++ b/packages/storage-sqlite/src/migrations/010_sync_fields.ts @@ -0,0 +1,67 @@ +/** + * Sync Fields Migration + * + * Adds fields required for cloud sync: + * - device_id: which device last modified the entity + * - sync_version: increments on each remote sync + * - last_synced_at: when entity was last synced + * + * Also creates: + * - sync_queue: offline changes waiting to be synced + * - sync_metadata: key-value store for sync state (cursor, device_id, etc.) + */ + +import type { Migration } from '@readied/storage-core'; + +export const addSyncFields: Migration = { + version: 20260107000002, + name: 'sync_fields', + up: ` + -- Add sync fields to notes table + ALTER TABLE notes ADD COLUMN device_id TEXT; + ALTER TABLE notes ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0; + ALTER TABLE notes ADD COLUMN last_synced_at TEXT; + + -- Add sync fields to notebooks table + ALTER TABLE notebooks ADD COLUMN device_id TEXT; + ALTER TABLE notebooks ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 0; + ALTER TABLE notebooks ADD COLUMN last_synced_at TEXT; + + -- Sync queue for offline changes + CREATE TABLE IF NOT EXISTS sync_queue ( + id TEXT PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + operation TEXT NOT NULL, + data TEXT, + timestamp TEXT NOT NULL, + synced INTEGER NOT NULL DEFAULT 0, + retry_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT + ); + + -- Index for finding pending sync items + CREATE INDEX IF NOT EXISTS idx_sync_queue_pending + ON sync_queue(synced, timestamp); + + -- Index for finding by entity + CREATE INDEX IF NOT EXISTS idx_sync_queue_entity + ON sync_queue(entity_type, entity_id); + + -- Sync metadata (key-value store) + CREATE TABLE IF NOT EXISTS sync_metadata ( + key TEXT PRIMARY KEY, + value TEXT + ); + + -- Initialize metadata + INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('device_id', NULL); + INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('note_cursor', NULL); + INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('notebook_cursor', NULL); + INSERT OR IGNORE INTO sync_metadata (key, value) VALUES ('last_synced_at', NULL); + + -- Index on sync_version for finding modified notes + CREATE INDEX IF NOT EXISTS idx_notes_sync_version ON notes(sync_version); + CREATE INDEX IF NOT EXISTS idx_notebooks_sync_version ON notebooks(sync_version); + `, +}; diff --git a/packages/storage-sqlite/src/migrations/index.ts b/packages/storage-sqlite/src/migrations/index.ts index f9abac9..6282412 100644 --- a/packages/storage-sqlite/src/migrations/index.ts +++ b/packages/storage-sqlite/src/migrations/index.ts @@ -10,6 +10,9 @@ import { addNoteFields } from './004_note_fields.js'; import { addManualTags } from './005_manual_tags.js'; import { addTagColors } from './006_tag_colors.js'; import { addLinks } from './007_links.js'; +import { addFts5Index } from './008_fts5_index.js'; +import { addLinkAnchors } from './009_link_anchors.js'; +import { addSyncFields } from './010_sync_fields.js'; /** All migrations in order */ export const allMigrations: Migration[] = [ @@ -20,6 +23,9 @@ export const allMigrations: Migration[] = [ addManualTags, addTagColors, addLinks, + addFts5Index, + addLinkAnchors, + addSyncFields, ]; export { @@ -30,4 +36,7 @@ export { addManualTags, addTagColors, addLinks, + addFts5Index, + addLinkAnchors, + addSyncFields, }; diff --git a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts index 0f18220..6381a7a 100644 --- a/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts +++ b/packages/storage-sqlite/src/repositories/SQLiteNoteRepository.ts @@ -175,25 +175,33 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { }); } - /** Search notes by content (basic LIKE search, excludes archived by default) */ + /** Search notes using FTS5 full-text search with relevance ranking */ async search( query: string, limit: number = 20, includeArchived: boolean = false ): Promise { - const archivedCondition = includeArchived ? '' : 'AND archived_at IS NULL'; + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + return []; + } + + const archivedCondition = includeArchived ? '' : 'AND n.archived_at IS NULL'; + + // Prepare FTS5 query: escape special chars, add prefix matching + const ftsQuery = this.prepareFtsQuery(trimmedQuery); const stmt = this.db.prepare(` - SELECT id, notebook_id, content, title, created_at, updated_at, word_count, archived_at, - is_pinned, is_deleted, status - FROM notes - WHERE (content LIKE ? OR title LIKE ?) ${archivedCondition} - ORDER BY updated_at DESC + SELECT n.id, n.notebook_id, n.content, n.title, n.created_at, n.updated_at, + n.word_count, n.archived_at, n.is_pinned, n.is_deleted, n.status + FROM notes_fts fts + JOIN notes n ON fts.id = n.id + WHERE notes_fts MATCH ? ${archivedCondition} + ORDER BY bm25(notes_fts) LIMIT ? `); - const pattern = `%${query}%`; - const rows = stmt.all(pattern, pattern, limit) as NoteRow[]; + const rows = stmt.all(ftsQuery, limit) as NoteRow[]; return rows.map(row => { const tags = this.getTagsForNote(createNoteId(row.id)); @@ -201,6 +209,23 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { }); } + /** Prepare query string for FTS5 MATCH syntax */ + private prepareFtsQuery(query: string): string { + // Escape FTS5 special characters: " * ^ - OR AND NOT ( ) + const escaped = query.replace(/["\*\^()]/g, ' ').trim(); + + // Split into terms and add prefix matching for partial word search + const terms = escaped.split(/\s+/).filter(t => t.length > 0); + + if (terms.length === 0) { + return '""'; // Empty search + } + + // Use OR between terms with prefix matching + // Each term becomes "term"* for prefix matching + return terms.map(t => `"${t}"*`).join(' OR '); + } + /** Get total count of notes */ async count(includeArchived: boolean = false): Promise { const condition = includeArchived ? '' : 'WHERE archived_at IS NULL'; @@ -497,21 +522,22 @@ export class SQLiteNoteRepository implements ExtendedNoteRepository { `); const insertLink = this.db.prepare(` - INSERT INTO links (source_note_id, target_ref, target_note_id) - VALUES (?, ?, ?) - ON CONFLICT(source_note_id, target_ref) DO UPDATE SET + INSERT INTO links (source_note_id, target_ref, target_note_id, target_anchor) + VALUES (?, ?, ?, ?) + ON CONFLICT(source_note_id, target_ref, COALESCE(target_anchor, '')) DO UPDATE SET target_note_id = excluded.target_note_id `); // Insert each link for (const wikilink of wikilinks) { const targetRef = wikilink.target; + const targetAnchor = wikilink.anchor ?? null; // Try to resolve target by title (case-insensitive) const targetNote = findNoteByTitle.get(targetRef) as { id: string } | undefined; const targetNoteId = targetNote?.id ?? null; - insertLink.run(noteId, targetRef, targetNoteId); + insertLink.run(noteId, targetRef, targetNoteId, targetAnchor); } }); } diff --git a/packages/sync-core/package.json b/packages/sync-core/package.json new file mode 100644 index 0000000..5f22185 --- /dev/null +++ b/packages/sync-core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@readied/sync-core", + "version": "0.1.0", + "private": true, + "description": "Core sync logic for Readied - pure TypeScript, no platform deps", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@readied/core": "workspace:*", + "zod": "^3.24.1" + }, + "devDependencies": { + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/packages/sync-core/src/client.ts b/packages/sync-core/src/client.ts new file mode 100644 index 0000000..41e407d --- /dev/null +++ b/packages/sync-core/src/client.ts @@ -0,0 +1,178 @@ +/** + * Sync Client Interface + * + * Platform-agnostic interface for sync operations. + * Implementations connect to the backend API. + */ + +import type { + SyncableNote, + SyncableNotebook, + PushResult, + PullResult, + AuthTokens, + SyncUser, + ConflictResolution, + DeviceId, +} from './types.js'; + +/** + * Payload for pushing notes to server. + */ +export interface NotePushPayload { + id: string; + title: string; + content: string; + notebookId: string | null; + createdAt: string; + updatedAt: string; + archivedAt: string | null; + isPinned: boolean; + isDeleted: boolean; + status: string; + wordCount: number; + localVersion: number; +} + +/** + * Interface for sync API client. + * Implementations handle HTTP requests to the sync server. + */ +export interface SyncClient { + // ========================================================================= + // Auth + // ========================================================================= + + /** + * Request magic link email. + */ + requestMagicLink(email: string): Promise; + + /** + * Verify magic link token and get auth tokens. + */ + verifyMagicLink(token: string): Promise<{ + tokens: AuthTokens; + user: SyncUser; + }>; + + /** + * Refresh access token using refresh token. + */ + refreshToken(refreshToken: string): Promise; + + /** + * Get current user info. + */ + getCurrentUser(): Promise; + + /** + * Logout (revoke tokens). + */ + logout(): Promise; + + // ========================================================================= + // Sync - Notes + // ========================================================================= + + /** + * Push local note changes to server. + */ + pushNotes( + notes: NotePushPayload[], + deviceId: DeviceId + ): Promise; + + /** + * Pull note changes from server. + */ + pullNotes( + cursor: string | null, + deviceId: DeviceId, + limit?: number + ): Promise<{ + notes: SyncableNote[]; + cursor: string; + hasMore: boolean; + }>; + + /** + * Resolve a note conflict. + */ + resolveNoteConflict( + noteId: string, + resolution: ConflictResolution + ): Promise; + + // ========================================================================= + // Sync - Notebooks + // ========================================================================= + + /** + * Push local notebook changes to server. + */ + pushNotebooks( + notebooks: SyncableNotebook[], + deviceId: DeviceId + ): Promise; + + /** + * Pull notebook changes from server. + */ + pullNotebooks( + cursor: string | null, + deviceId: DeviceId, + limit?: number + ): Promise<{ + notebooks: SyncableNotebook[]; + cursor: string; + hasMore: boolean; + }>; + + // ========================================================================= + // Device Management + // ========================================================================= + + /** + * Register this device for sync. + */ + registerDevice(deviceInfo: { + name: string; + platform: string; + version: string; + }): Promise; + + /** + * List all registered devices. + */ + listDevices(): Promise< + Array<{ + id: DeviceId; + name: string; + platform: string; + lastSeenAt: string; + isCurrentDevice: boolean; + }> + >; + + /** + * Revoke a device's access. + */ + revokeDevice(deviceId: DeviceId): Promise; +} + +/** + * Configuration for sync client. + */ +export interface SyncClientConfig { + /** Base URL for sync API */ + baseUrl: string; + /** Get current access token */ + getAccessToken: () => Promise; + /** Called when token needs refresh */ + onTokenRefresh?: (tokens: AuthTokens) => Promise; + /** Called when auth fails (e.g., redirect to login) */ + onAuthError?: () => void; + /** Request timeout in ms */ + timeout?: number; +} diff --git a/packages/sync-core/src/engine.ts b/packages/sync-core/src/engine.ts new file mode 100644 index 0000000..fcb5fcd --- /dev/null +++ b/packages/sync-core/src/engine.ts @@ -0,0 +1,285 @@ +/** + * Sync Engine + * + * Orchestrates sync operations between local storage and remote server. + * Handles offline queue, conflict resolution, and sync state. + */ + +import type { + SyncStatus, + SyncConflict, + ConflictStrategy, + PushResult, + DeviceId, + SyncChange, +} from './types.js'; +import type { SyncClient, NotePushPayload } from './client.js'; +import type { SyncQueue } from './queue.js'; + +/** + * Local storage interface for sync engine. + * Implementations provide platform-specific storage. + */ +export interface SyncStorage { + /** Get device ID (or null if not registered) */ + getDeviceId(): Promise; + + /** Store device ID */ + setDeviceId(id: DeviceId): Promise; + + /** Get last sync cursor for entity type */ + getCursor(entityType: 'note' | 'notebook'): Promise; + + /** Store sync cursor */ + setCursor(entityType: 'note' | 'notebook', cursor: string): Promise; + + /** Get notes that have local changes since last sync */ + getModifiedNotes(): Promise; + + /** Apply remote note changes to local storage */ + applyRemoteNotes(notes: Array<{ + id: string; + title: string; + content: string; + notebookId: string | null; + createdAt: string; + updatedAt: string; + archivedAt: string | null; + isPinned: boolean; + isDeleted: boolean; + status: string; + wordCount: number; + syncVersion: number; + }>): Promise; + + /** Mark notes as synced (update sync metadata) */ + markNotesSynced(ids: string[], syncVersion: number): Promise; + + /** Get last sync timestamp */ + getLastSyncedAt(): Promise; + + /** Set last sync timestamp */ + setLastSyncedAt(timestamp: string): Promise; +} + +/** + * Sync engine configuration. + */ +export interface SyncEngineConfig { + /** Sync client for API calls */ + client: SyncClient; + /** Local storage for sync state */ + storage: SyncStorage; + /** Offline change queue */ + queue: SyncQueue; + /** Default conflict resolution strategy */ + defaultConflictStrategy?: ConflictStrategy; + /** Callback for status changes */ + onStatusChange?: (status: SyncStatus) => void; + /** Callback for conflicts that need manual resolution */ + onConflict?: (conflicts: SyncConflict[]) => void; +} + +/** + * Sync engine - orchestrates sync between local and remote. + */ +export class SyncEngine { + private status: SyncStatus = { status: 'disabled' }; + private isSyncing = false; + + constructor(private config: SyncEngineConfig) {} + + /** + * Get current sync status. + */ + getStatus(): SyncStatus { + return this.status; + } + + /** + * Enable sync (user logged in with Pro subscription). + */ + async enable(): Promise { + const lastSyncedAt = await this.config.storage.getLastSyncedAt(); + this.updateStatus({ status: 'idle', lastSyncedAt }); + } + + /** + * Disable sync (user logged out or downgraded). + */ + async disable(): Promise { + this.updateStatus({ status: 'disabled' }); + await this.config.queue.clear(); + } + + /** + * Run a full sync cycle: push local changes, then pull remote changes. + */ + async sync(): Promise { + if (this.isSyncing) return; + if (this.status.status === 'disabled') return; + + this.isSyncing = true; + const lastSyncedAt = await this.config.storage.getLastSyncedAt(); + this.updateStatus({ status: 'syncing', progress: 0 }); + + try { + const deviceId = await this.ensureDeviceId(); + + // Phase 1: Push local changes (40% of progress) + this.updateStatus({ status: 'syncing', progress: 10 }); + const pushResult = await this.pushChanges(deviceId); + + if (pushResult.conflicts.length > 0) { + // Handle conflicts based on strategy + const unresolved = await this.handleConflicts(pushResult.conflicts); + if (unresolved.length > 0) { + this.updateStatus({ status: 'conflict', conflicts: unresolved }); + this.isSyncing = false; + return; + } + } + + this.updateStatus({ status: 'syncing', progress: 40 }); + + // Phase 2: Pull remote changes (60% of progress) + await this.pullChanges(deviceId); + + // Done + const now = new Date().toISOString(); + await this.config.storage.setLastSyncedAt(now); + this.updateStatus({ status: 'idle', lastSyncedAt: now }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Sync failed'; + this.updateStatus({ status: 'error', message, lastSyncedAt }); + } finally { + this.isSyncing = false; + } + } + + /** + * Queue a local change for sync. + */ + async queueChange( + entityType: 'note' | 'notebook' | 'tag', + entityId: string, + operation: 'create' | 'update' | 'delete', + data: unknown + ): Promise { + await this.config.queue.queueChange(entityType, entityId, operation, data); + } + + /** + * Resolve a conflict manually. + */ + async resolveConflict( + entityId: string, + strategy: 'local' | 'remote' + ): Promise { + await this.config.client.resolveNoteConflict(entityId, { + entityId, + strategy: strategy === 'local' ? 'local-wins' : 'remote-wins', + }); + + // Re-sync after resolution + await this.sync(); + } + + // ========================================================================= + // Private Methods + // ========================================================================= + + private updateStatus(status: SyncStatus): void { + this.status = status; + this.config.onStatusChange?.(status); + } + + private async ensureDeviceId(): Promise { + let deviceId = await this.config.storage.getDeviceId(); + if (!deviceId) { + deviceId = await this.config.client.registerDevice({ + name: 'Readied Desktop', + platform: process.platform ?? 'unknown', + version: '0.1.0', // TODO: get from package + }); + await this.config.storage.setDeviceId(deviceId); + } + return deviceId; + } + + private async pushChanges(deviceId: DeviceId): Promise { + const modifiedNotes = await this.config.storage.getModifiedNotes(); + if (modifiedNotes.length === 0) { + return { synced: [], conflicts: [], errors: [] }; + } + + const result = await this.config.client.pushNotes(modifiedNotes, deviceId); + + // Mark synced notes + if (result.synced.length > 0) { + await this.config.storage.markNotesSynced(result.synced, Date.now()); + } + + return result; + } + + private async pullChanges(deviceId: DeviceId): Promise { + let cursor = await this.config.storage.getCursor('note'); + let hasMore = true; + let progress = 40; + + while (hasMore) { + const result = await this.config.client.pullNotes(cursor, deviceId, 100); + + if (result.notes.length > 0) { + await this.config.storage.applyRemoteNotes(result.notes); + } + + cursor = result.cursor; + hasMore = result.hasMore; + + // Update progress + progress = Math.min(progress + 10, 90); + this.updateStatus({ status: 'syncing', progress }); + + await this.config.storage.setCursor('note', cursor); + } + } + + private async handleConflicts( + conflicts: SyncConflict[] + ): Promise { + const strategy = this.config.defaultConflictStrategy ?? 'latest-wins'; + + if (strategy === 'manual') { + this.config.onConflict?.(conflicts); + return conflicts; + } + + // Auto-resolve based on strategy + const unresolved: SyncConflict[] = []; + + for (const conflict of conflicts) { + try { + const resolution: 'local-wins' | 'remote-wins' = + strategy === 'local-wins' + ? 'local-wins' + : strategy === 'remote-wins' + ? 'remote-wins' + : // latest-wins: compare timestamps + conflict.localUpdatedAt > conflict.remoteUpdatedAt + ? 'local-wins' + : 'remote-wins'; + + await this.config.client.resolveNoteConflict(conflict.entityId, { + entityId: conflict.entityId, + strategy: resolution, + }); + } catch { + unresolved.push(conflict); + } + } + + return unresolved; + } +} diff --git a/packages/sync-core/src/index.ts b/packages/sync-core/src/index.ts new file mode 100644 index 0000000..0bfd334 --- /dev/null +++ b/packages/sync-core/src/index.ts @@ -0,0 +1,62 @@ +/** + * @readied/sync-core + * + * Core sync logic for Readied. Pure TypeScript, no platform dependencies. + * + * This package provides: + * - Type definitions for sync entities and operations + * - Sync queue management for offline changes + * - Sync engine to orchestrate push/pull operations + * - Interfaces for platform-specific implementations + * + * Platform-specific code (API client, SQLite storage) should implement + * the interfaces defined here. + */ + +// Types +export type { + DeviceId, + SyncVersion, + UserId, + SyncStatus, + ConnectionStatus, + SyncableFields, + SyncableNote, + SyncableNotebook, + EntityType, + SyncOperation, + SyncChange, + PushResult, + PullResult, + ConflictType, + SyncConflict, + ConflictStrategy, + ConflictResolution, + SyncError, + SyncErrorCode, + SyncUser, + SubscriptionStatus, + AuthTokens, +} from './types.js'; + +// Zod schemas for validation +export { + SyncChangeSchema, + PushResultSchema, + PullResultSchema, +} from './types.js'; + +// Queue +export type { SyncQueueStorage } from './queue.js'; +export { SyncQueue, createSyncChangeId } from './queue.js'; + +// Client interface +export type { + SyncClient, + SyncClientConfig, + NotePushPayload, +} from './client.js'; + +// Engine +export type { SyncStorage, SyncEngineConfig } from './engine.js'; +export { SyncEngine } from './engine.js'; diff --git a/packages/sync-core/src/queue.ts b/packages/sync-core/src/queue.ts new file mode 100644 index 0000000..78d4863 --- /dev/null +++ b/packages/sync-core/src/queue.ts @@ -0,0 +1,119 @@ +/** + * Sync Queue + * + * Manages offline changes queue for eventual sync. + * Pure interface - storage implementation provided by platform. + */ + +import type { SyncChange, EntityType, SyncOperation } from './types.js'; + +/** + * Interface for sync queue storage. + * Platform-specific implementations (SQLite, IndexedDB, etc.) + */ +export interface SyncQueueStorage { + /** Add a change to the queue */ + enqueue(change: Omit): Promise; + + /** Get all pending (unsynced) changes */ + getPending(): Promise; + + /** Mark changes as synced */ + markSynced(ids: string[]): Promise; + + /** Update retry count and error for a change */ + markFailed(id: string, error: string): Promise; + + /** Remove synced changes older than given date */ + cleanup(before: Date): Promise; + + /** Clear all pending changes (for logout) */ + clear(): Promise; + + /** Get count of pending changes */ + getPendingCount(): Promise; +} + +/** + * Sync queue manager. + * Handles queuing changes and preparing batches for sync. + */ +export class SyncQueue { + constructor(private storage: SyncQueueStorage) {} + + /** + * Queue a change for sync. + * Deduplicates: if entity already queued, updates existing entry. + */ + async queueChange( + entityType: EntityType, + entityId: string, + operation: SyncOperation, + data: unknown + ): Promise { + await this.storage.enqueue({ + entityType, + entityId, + operation, + data, + timestamp: new Date().toISOString(), + synced: false, + retryCount: 0, + lastError: null, + }); + } + + /** + * Get pending changes for sync. + * Returns oldest first, limited to batch size. + */ + async getPendingChanges(limit: number = 50): Promise { + const pending = await this.storage.getPending(); + return pending.slice(0, limit); + } + + /** + * Mark changes as successfully synced. + */ + async markSynced(ids: string[]): Promise { + if (ids.length === 0) return; + await this.storage.markSynced(ids); + } + + /** + * Mark a change as failed (increment retry, store error). + */ + async markFailed(id: string, error: string): Promise { + await this.storage.markFailed(id, error); + } + + /** + * Get count of pending changes. + */ + async getPendingCount(): Promise { + return this.storage.getPendingCount(); + } + + /** + * Clear all pending changes (for logout). + */ + async clear(): Promise { + await this.storage.clear(); + } + + /** + * Cleanup old synced changes. + */ + async cleanup(olderThanDays: number = 7): Promise { + const before = new Date(); + before.setDate(before.getDate() - olderThanDays); + return this.storage.cleanup(before); + } +} + +/** + * Create unique ID for sync change. + */ +export function createSyncChangeId(): string { + return `sc_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} diff --git a/packages/sync-core/src/types.ts b/packages/sync-core/src/types.ts new file mode 100644 index 0000000..fe98989 --- /dev/null +++ b/packages/sync-core/src/types.ts @@ -0,0 +1,246 @@ +/** + * Sync Core Types + * + * Core types for the sync system. Platform-agnostic, pure TypeScript. + */ + +import { z } from 'zod'; + +// ============================================================================ +// Branded Types +// ============================================================================ + +/** Unique device identifier */ +export type DeviceId = string & { readonly __brand: 'DeviceId' }; + +/** Sync version number (monotonically increasing per entity) */ +export type SyncVersion = number; + +/** User ID from auth system */ +export type UserId = string & { readonly __brand: 'UserId' }; + +// ============================================================================ +// Sync Status +// ============================================================================ + +/** Overall sync status for UI display */ +export type SyncStatus = + | { status: 'disabled' } + | { status: 'idle'; lastSyncedAt: string | null } + | { status: 'syncing'; progress: number } + | { status: 'error'; message: string; lastSyncedAt: string | null } + | { status: 'conflict'; conflicts: SyncConflict[] }; + +/** Connection status */ +export type ConnectionStatus = 'online' | 'offline' | 'connecting'; + +// ============================================================================ +// Syncable Entities +// ============================================================================ + +/** Base fields required for sync on any entity */ +export interface SyncableFields { + /** Device that last modified this entity */ + deviceId: DeviceId | null; + /** Sync version (increments on each remote update) */ + syncVersion: SyncVersion; + /** Last time this entity was synced with server */ + lastSyncedAt: string | null; +} + +/** A note with sync metadata */ +export interface SyncableNote extends SyncableFields { + id: string; + title: string; + content: string; + notebookId: string | null; + createdAt: string; + updatedAt: string; + archivedAt: string | null; + isPinned: boolean; + isDeleted: boolean; + status: string; + wordCount: number; +} + +/** A notebook with sync metadata */ +export interface SyncableNotebook extends SyncableFields { + id: string; + name: string; + parentId: string | null; + depth: number; + createdAt: string; + updatedAt: string; +} + +// ============================================================================ +// Sync Operations +// ============================================================================ + +/** Type of entity being synced */ +export type EntityType = 'note' | 'notebook' | 'tag'; + +/** Type of sync operation */ +export type SyncOperation = 'create' | 'update' | 'delete'; + +/** A change record in the sync queue */ +export interface SyncChange { + id: string; + entityType: EntityType; + entityId: string; + operation: SyncOperation; + data: unknown; + timestamp: string; + synced: boolean; + retryCount: number; + lastError: string | null; +} + +/** Result of a push operation */ +export interface PushResult { + /** IDs of successfully synced entities */ + synced: string[]; + /** Conflicts that need resolution */ + conflicts: SyncConflict[]; + /** Errors that occurred */ + errors: SyncError[]; +} + +/** Result of a pull operation */ +export interface PullResult { + /** Entities that were updated locally */ + updated: string[]; + /** New cursor for next pull */ + cursor: string; + /** Whether there are more changes to pull */ + hasMore: boolean; +} + +// ============================================================================ +// Conflicts +// ============================================================================ + +/** Type of conflict */ +export type ConflictType = 'update-update' | 'delete-update' | 'update-delete'; + +/** A sync conflict requiring resolution */ +export interface SyncConflict { + entityType: EntityType; + entityId: string; + conflictType: ConflictType; + localVersion: unknown; + remoteVersion: unknown; + localUpdatedAt: string; + remoteUpdatedAt: string; +} + +/** Strategy for resolving conflicts */ +export type ConflictStrategy = 'local-wins' | 'remote-wins' | 'latest-wins' | 'manual'; + +/** Resolution for a conflict */ +export interface ConflictResolution { + entityId: string; + strategy: ConflictStrategy; + /** If manual, the resolved data */ + resolvedData?: unknown; +} + +// ============================================================================ +// Errors +// ============================================================================ + +/** A sync error */ +export interface SyncError { + entityId: string; + entityType: EntityType; + message: string; + code: SyncErrorCode; + retryable: boolean; +} + +/** Error codes for sync operations */ +export type SyncErrorCode = + | 'NETWORK_ERROR' + | 'AUTH_ERROR' + | 'CONFLICT' + | 'NOT_FOUND' + | 'VALIDATION_ERROR' + | 'QUOTA_EXCEEDED' + | 'SERVER_ERROR' + | 'UNKNOWN'; + +// ============================================================================ +// Auth +// ============================================================================ + +/** User info from auth */ +export interface SyncUser { + id: UserId; + email: string; + createdAt: string; + /** Subscription status */ + subscription: SubscriptionStatus; +} + +/** Subscription status */ +export interface SubscriptionStatus { + status: 'active' | 'trialing' | 'past_due' | 'canceled' | 'inactive'; + plan: 'free' | 'pro'; + /** For trialing: when trial ends */ + trialEndsAt?: string; + /** For active: when subscription renews/ends */ + currentPeriodEnd?: string; +} + +/** Auth tokens */ +export interface AuthTokens { + accessToken: string; + refreshToken?: string; + expiresAt: string; +} + +// ============================================================================ +// Zod Schemas (for validation) +// ============================================================================ + +export const SyncChangeSchema = z.object({ + id: z.string(), + entityType: z.enum(['note', 'notebook', 'tag']), + entityId: z.string(), + operation: z.enum(['create', 'update', 'delete']), + data: z.unknown(), + timestamp: z.string(), + synced: z.boolean(), + retryCount: z.number(), + lastError: z.string().nullable(), +}); + +export const PushResultSchema = z.object({ + synced: z.array(z.string()), + conflicts: z.array( + z.object({ + entityType: z.enum(['note', 'notebook', 'tag']), + entityId: z.string(), + conflictType: z.enum(['update-update', 'delete-update', 'update-delete']), + localVersion: z.unknown(), + remoteVersion: z.unknown(), + localUpdatedAt: z.string(), + remoteUpdatedAt: z.string(), + }) + ), + errors: z.array( + z.object({ + entityId: z.string(), + entityType: z.enum(['note', 'notebook', 'tag']), + message: z.string(), + code: z.string(), + retryable: z.boolean(), + }) + ), +}); + +export const PullResultSchema = z.object({ + updated: z.array(z.string()), + cursor: z.string(), + hasMore: z.boolean(), +}); diff --git a/packages/sync-core/tests/types.test.ts b/packages/sync-core/tests/types.test.ts new file mode 100644 index 0000000..873d2f8 --- /dev/null +++ b/packages/sync-core/tests/types.test.ts @@ -0,0 +1,64 @@ +/** + * Sync Core Types Tests + */ + +import { describe, it, expect } from 'vitest'; +import { SyncChangeSchema, PushResultSchema, PullResultSchema } from '../src/types'; + +describe('SyncChangeSchema', () => { + it('validates valid sync change', () => { + const validChange = { + id: 'sc_123', + entityType: 'note', + entityId: 'note_456', + operation: 'update', + data: { title: 'Test' }, + timestamp: '2024-01-07T00:00:00Z', + synced: false, + retryCount: 0, + lastError: null, + }; + + const result = SyncChangeSchema.safeParse(validChange); + expect(result.success).toBe(true); + }); + + it('rejects invalid entity type', () => { + const invalid = { + id: 'sc_123', + entityType: 'invalid', + entityId: 'note_456', + operation: 'update', + data: {}, + timestamp: '2024-01-07T00:00:00Z', + synced: false, + retryCount: 0, + lastError: null, + }; + + const result = SyncChangeSchema.safeParse(invalid); + expect(result.success).toBe(false); + }); +}); + +describe('PushResultSchema', () => { + it('validates push result', () => { + const result = PushResultSchema.safeParse({ + synced: ['id1', 'id2'], + conflicts: [], + errors: [], + }); + expect(result.success).toBe(true); + }); +}); + +describe('PullResultSchema', () => { + it('validates pull result', () => { + const result = PullResultSchema.safeParse({ + updated: ['id1'], + cursor: 'abc123', + hasMore: false, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/sync-core/tsconfig.json b/packages/sync-core/tsconfig.json new file mode 100644 index 0000000..e66c4fe --- /dev/null +++ b/packages/sync-core/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/wikilinks/src/adapters/remark/remark-wikilink.ts b/packages/wikilinks/src/adapters/remark/remark-wikilink.ts index 40c7eab..f74640f 100644 --- a/packages/wikilinks/src/adapters/remark/remark-wikilink.ts +++ b/packages/wikilinks/src/adapters/remark/remark-wikilink.ts @@ -1,7 +1,12 @@ /** * Remark plugin for wikilink [[note]] syntax in Markdown preview * - * Transforms [[target]] and [[target|display]] into clickable spans. + * Transforms wikilinks into clickable spans: + * - [[target]] + * - [[target#anchor]] + * - [[target|display]] + * - [[target#anchor|display]] + * * Navigation is handled by parent component via click delegation. * * Uses mdast text nodes with data.hName/hProperties for proper inline rendering. @@ -11,9 +16,10 @@ import { visit } from 'unist-util-visit'; import type { Root, Text, Parent } from 'mdast'; -// Pattern: [[target]] or [[target|display]] +// Pattern: [[target]] or [[target#anchor]] or [[target|display]] or [[target#anchor|display]] // Negative lookbehind (? = { + className: 'wikilink', + 'data-target': target, + }; + + // Add anchor if present + if (anchor) { + hProperties['data-anchor'] = anchor; + } + children.push({ type: 'text', value: display, data: { hName: 'span', - hProperties: { - className: 'wikilink', - 'data-target': target, - }, + hProperties, }, }); diff --git a/packages/wikilinks/src/core/headings.ts b/packages/wikilinks/src/core/headings.ts new file mode 100644 index 0000000..15bd817 --- /dev/null +++ b/packages/wikilinks/src/core/headings.ts @@ -0,0 +1,119 @@ +/** + * Heading Utilities + * + * Functions for extracting headings from markdown and generating slugs + * for anchor navigation. + */ + +/** Represents a heading extracted from markdown */ +export interface Heading { + /** The heading text (without # prefix) */ + text: string; + /** Heading level (1-6) */ + level: number; + /** Slug for URL anchor (e.g., "my-heading") */ + slug: string; +} + +/** + * Extract all headings from markdown content. + * + * @param content - Markdown content to parse + * @returns Array of headings with text, level, and slug + * + * @example + * extractHeadings("# Title\n\n## Section One\n\nText\n\n### Sub-section") + * // Returns: [ + * // { text: "Title", level: 1, slug: "title" }, + * // { text: "Section One", level: 2, slug: "section-one" }, + * // { text: "Sub-section", level: 3, slug: "sub-section" } + * // ] + */ +export function extractHeadings(content: string): Heading[] { + const headingPattern = /^(#{1,6})\s+(.+)$/gm; + const headings: Heading[] = []; + let match: RegExpExecArray | null; + + while ((match = headingPattern.exec(content)) !== null) { + const level = match[1]!.length; + const text = match[2]!.trim(); + + if (text) { + headings.push({ + text, + level, + slug: headingToSlug(text), + }); + } + } + + return headings; +} + +/** + * Extract just the heading text strings from content. + * + * @param content - Markdown content to parse + * @returns Array of heading text strings + */ +export function extractHeadingTexts(content: string): string[] { + return extractHeadings(content).map(h => h.text); +} + +/** + * Generate a URL-safe slug from heading text. + * Matches GitHub's heading anchor generation algorithm. + * + * @param heading - Heading text to convert + * @returns URL-safe slug + * + * @example + * headingToSlug("Hello World!") // "hello-world" + * headingToSlug("API Reference") // "api-reference" + * headingToSlug("1. Introduction") // "1-introduction" + */ +export function headingToSlug(heading: string): string { + return heading + .toLowerCase() + .trim() + // Remove special characters except alphanumeric, spaces, and hyphens + .replace(/[^\w\s-]/g, '') + // Replace whitespace with hyphens + .replace(/\s+/g, '-') + // Remove consecutive hyphens + .replace(/-+/g, '-') + // Remove leading/trailing hyphens + .replace(/^-|-$/g, ''); +} + +/** + * Find a heading in content that matches a given anchor/slug. + * Tries exact match first, then slug match. + * + * @param content - Markdown content to search + * @param anchor - Anchor text or slug to find + * @returns Matching heading or undefined + */ +export function findHeadingByAnchor( + content: string, + anchor: string +): Heading | undefined { + const headings = extractHeadings(content); + const normalizedAnchor = anchor.toLowerCase().trim(); + + // Try exact text match first (case-insensitive) + const exactMatch = headings.find( + h => h.text.toLowerCase() === normalizedAnchor + ); + if (exactMatch) return exactMatch; + + // Try slug match + const slugMatch = headings.find(h => h.slug === headingToSlug(normalizedAnchor)); + if (slugMatch) return slugMatch; + + // Try partial match (anchor is contained in heading) + return headings.find(h => + h.text.toLowerCase().includes(normalizedAnchor) || + h.slug.includes(headingToSlug(normalizedAnchor)) + ); +} diff --git a/packages/wikilinks/src/core/parsing.ts b/packages/wikilinks/src/core/parsing.ts index 3ff6a5b..79f2a3e 100644 --- a/packages/wikilinks/src/core/parsing.ts +++ b/packages/wikilinks/src/core/parsing.ts @@ -7,22 +7,37 @@ import type { WikilinkRef } from './types.js'; -/** Pattern for matching wikilinks: [[target]] or [[target|display]] */ -// Exclude [ ] | from target to avoid matching nested/unclosed brackets -const WIKILINK_PATTERN = /\[\[([^[\]|]+)(?:\|([^\]]+))?\]\]/g; +/** + * Pattern for matching wikilinks with optional heading anchor: + * - [[target]] + * - [[target#heading]] + * - [[target|display]] + * - [[target#heading|display]] + * + * Groups: [1]=target, [2]=anchor (optional), [3]=display (optional) + */ +const WIKILINK_PATTERN = /\[\[([^[\]|#]+)(?:#([^[\]|]+))?(?:\|([^\]]+))?\]\]/g; /** * Extract all wikilinks from markdown content. * - * Supports both simple [[Target]] and aliased [[Target|display text]] formats. - * Returns unique targets only (deduplicated). + * Supports: + * - Simple: [[Target]] + * - With anchor: [[Target#Heading]] + * - Aliased: [[Target|display text]] + * - Combined: [[Target#Heading|display text]] + * + * Returns unique targets only (deduplicated by target+anchor). * * @param content - Markdown content to parse * @returns Array of unique wikilink references * * @example - * extractWikilinks("See [[Note A]] and [[Note B|my note]]") - * // Returns: [{ target: "Note A" }, { target: "Note B", display: "my note" }] + * extractWikilinks("See [[Note A]] and [[Note B#Section|my note]]") + * // Returns: [ + * // { target: "Note A" }, + * // { target: "Note B", anchor: "Section", display: "my note" } + * // ] */ export function extractWikilinks(content: string): WikilinkRef[] { const seen = new Set(); @@ -33,12 +48,18 @@ export function extractWikilinks(content: string): WikilinkRef[] { const target = match[1]?.trim(); if (!target) continue; - // Deduplicate by target - if (seen.has(target.toLowerCase())) continue; - seen.add(target.toLowerCase()); + const anchor = match[2]?.trim(); + const display = match[3]?.trim(); + + // Deduplicate by target + anchor combination + const key = `${target.toLowerCase()}#${anchor?.toLowerCase() ?? ''}`; + if (seen.has(key)) continue; + seen.add(key); - const display = match[2]?.trim(); - links.push(display ? { target, display } : { target }); + const link: WikilinkRef = { target }; + if (anchor) link.anchor = anchor; + if (display) link.display = display; + links.push(link); } return links; diff --git a/packages/wikilinks/src/core/types.ts b/packages/wikilinks/src/core/types.ts index c3068d2..8f3f58e 100644 --- a/packages/wikilinks/src/core/types.ts +++ b/packages/wikilinks/src/core/types.ts @@ -6,8 +6,10 @@ /** Represents a parsed wikilink from content */ export interface WikilinkRef { - /** The target reference (what's inside [[...]]) */ + /** The target reference (note title, what's before # or |) */ target: string; + /** Optional heading anchor (after # in [[target#heading]]) */ + anchor?: string; /** Optional display text (after | in [[target|display]]) */ display?: string; } diff --git a/packages/wikilinks/src/index.ts b/packages/wikilinks/src/index.ts index 7414fad..6bdb2ef 100644 --- a/packages/wikilinks/src/index.ts +++ b/packages/wikilinks/src/index.ts @@ -19,6 +19,15 @@ export type { WikilinkRef } from './core/types.js'; // Core - parsing (pure, no deps) export { extractWikilinks, extractWikilinkTargets } from './core/parsing.js'; +// Core - headings (for [[Note#Heading]] support) +export type { Heading } from './core/headings.js'; +export { + extractHeadings, + extractHeadingTexts, + headingToSlug, + findHeadingByAnchor, +} from './core/headings.js'; + // Adapters (for advanced use only) export { createWikilinkAutocomplete, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6f84e7..20191d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -316,6 +316,22 @@ importers: specifier: ^2.1.8 version: 2.1.9(@types/node@22.19.3) + packages/sync-core: + dependencies: + '@readied/core': + specifier: workspace:* + version: link:../core + zod: + specifier: ^3.24.1 + version: 3.25.76 + devDependencies: + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.3) + packages/tasks: devDependencies: typescript: