mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-13 04:06:57 +02:00
feat: Crumbs by Bretzel — self-hostable notes app MVP
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
interface ChecklistItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
}
|
||||
|
||||
let { content, onChange }: Props = $props();
|
||||
|
||||
let items = $state<ChecklistItem[]>(parseChecklist(content));
|
||||
|
||||
function parseChecklist(text: string): ChecklistItem[] {
|
||||
if (!text.trim()) return [{ text: '', checked: false }];
|
||||
const lines = text.split('\n');
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith('- [x] ')) return { text: line.slice(6), checked: true };
|
||||
if (line.startsWith('- [ ] ')) return { text: line.slice(6), checked: false };
|
||||
return { text: line, checked: false };
|
||||
});
|
||||
}
|
||||
|
||||
function serializeChecklist(list: ChecklistItem[]): string {
|
||||
return list
|
||||
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
onChange(serializeChecklist(items));
|
||||
}
|
||||
|
||||
function toggleItem(index: number) {
|
||||
items[index].checked = !items[index].checked;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function updateText(index: number, text: string) {
|
||||
items[index].text = text;
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function addItem(afterIndex: number) {
|
||||
items.splice(afterIndex + 1, 0, { text: '', checked: false });
|
||||
items = [...items];
|
||||
emitChange();
|
||||
// Focus new input on next tick
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[afterIndex + 1]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
if (items.length <= 1) return;
|
||||
items.splice(index, 1);
|
||||
items = [...items];
|
||||
emitChange();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addItem(index);
|
||||
} else if (e.key === 'Backspace' && items[index].text === '' && items.length > 1) {
|
||||
e.preventDefault();
|
||||
removeItem(index);
|
||||
setTimeout(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>('[data-testid="checklist-input"]');
|
||||
inputs[Math.max(0, index - 1)]?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1" data-testid="checklist">
|
||||
{#each items as item, index}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.checked}
|
||||
onchange={() => toggleItem(index)}
|
||||
class="h-4 w-4 rounded border-gray-300 text-amber-600 focus:ring-amber-500"
|
||||
data-testid="checklist-checkbox"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={item.text}
|
||||
oninput={(e) => updateText(index, (e.target as HTMLInputElement).value)}
|
||||
onkeydown={(e) => handleKeydown(e, index)}
|
||||
class="flex-1 bg-transparent text-sm outline-none {item.checked ? 'text-gray-400 line-through' : 'text-gray-800 dark:text-gray-200'}"
|
||||
placeholder="List item"
|
||||
data-testid="checklist-input"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeItem(index)}
|
||||
class="opacity-0 hover:opacity-100 focus:opacity-100"
|
||||
aria-label="Remove item"
|
||||
data-testid="checklist-remove"
|
||||
>
|
||||
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
onclick={() => addItem(items.length - 1)}
|
||||
class="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
data-testid="checklist-add"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add item
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS, COLOR_OPTIONS } from '$lib/utils/colors.js';
|
||||
import { effectiveTheme } from '$lib/stores/theme.js';
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
selected: NoteColor;
|
||||
onSelect: (color: NoteColor) => void;
|
||||
}
|
||||
|
||||
let { selected, onSelect }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap gap-1" data-testid="color-picker">
|
||||
{#each COLOR_OPTIONS as option}
|
||||
{@const colors = NOTE_COLORS[option.value]}
|
||||
<button
|
||||
onclick={() => onSelect(option.value)}
|
||||
class="h-8 w-8 rounded-full border-2 transition-transform hover:scale-110 {selected === option.value ? 'border-gray-800 dark:border-white' : 'border-transparent'}"
|
||||
style="background-color: {$effectiveTheme === 'dark' ? colors.dark : colors.light}"
|
||||
title={option.label}
|
||||
data-testid="color-{option.value}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import type { Attachment } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
attachments: Attachment[];
|
||||
onUpload: (attachment: Attachment) => void;
|
||||
onRemove: (attachmentId: string) => void;
|
||||
}
|
||||
|
||||
let { noteId, attachments = [], onUpload, onRemove }: Props = $props();
|
||||
|
||||
let uploading = $state(false);
|
||||
let dragOver = $state(false);
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || !noteId) return;
|
||||
|
||||
uploading = true;
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (res.ok) {
|
||||
const attachment = await res.json();
|
||||
onUpload(attachment);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
}
|
||||
}
|
||||
uploading = false;
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
handleFiles(e.dataTransfer?.files ?? null);
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div data-testid="image-upload">
|
||||
<!-- Existing attachments -->
|
||||
{#if attachments.length > 0}
|
||||
<div class="mb-2 flex flex-wrap gap-2">
|
||||
{#each attachments as attachment}
|
||||
<div class="group relative">
|
||||
<img
|
||||
src="/api/notes/{noteId}/attachments/{attachment.id}"
|
||||
alt={attachment.filename}
|
||||
class="h-20 w-20 rounded object-cover"
|
||||
data-testid="attachment-thumbnail"
|
||||
/>
|
||||
<button
|
||||
onclick={() => onRemove(attachment.id)}
|
||||
class="absolute -right-1 -top-1 hidden rounded-full bg-red-500 p-0.5 text-white group-hover:block"
|
||||
aria-label="Remove attachment"
|
||||
data-testid="remove-attachment"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload area -->
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed p-4 text-center transition-colors {dragOver ? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20' : 'border-gray-300 dark:border-gray-600'}"
|
||||
ondrop={handleDrop}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="upload-dropzone"
|
||||
>
|
||||
{#if uploading}
|
||||
<p class="text-sm text-gray-500">Uploading...</p>
|
||||
{:else}
|
||||
<label class="cursor-pointer">
|
||||
<span class="text-sm text-gray-500">Drop images here or <span class="text-amber-600 underline">browse</span></span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
|
||||
data-testid="file-input"
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import SearchBar from '../SearchBar.svelte';
|
||||
import ThemeToggle from '../ThemeToggle.svelte';
|
||||
import SyncIndicator from '../SyncIndicator.svelte';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle: () => void;
|
||||
}
|
||||
|
||||
let { onMenuToggle }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b border-gray-200 bg-white px-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<button
|
||||
onclick={onMenuToggle}
|
||||
class="rounded-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<svg class="h-6 w-6 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥨</span>
|
||||
<h1 class="text-xl font-semibold text-gray-800 dark:text-gray-100">Crumbs</h1>
|
||||
</div>
|
||||
|
||||
<div class="mx-4 flex-1">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<SyncIndicator />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import { currentFilter, loadNotes, allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
import type { NoteFilter } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open }: Props = $props();
|
||||
|
||||
function setFilter(filter: NoteFilter) {
|
||||
selectedTag.set(null);
|
||||
loadNotes(filter);
|
||||
}
|
||||
|
||||
function selectTag(tag: string | null) {
|
||||
selectedTag.set(tag);
|
||||
loadNotes('all');
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="fixed left-0 top-16 z-20 h-[calc(100vh-4rem)] w-64 transform border-r border-gray-200 bg-white transition-transform duration-200 dark:border-gray-700 dark:bg-gray-900 {open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0"
|
||||
>
|
||||
<nav class="p-2">
|
||||
<ul class="space-y-1">
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('all')}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'all' && !$selectedTag ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('archived')}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'archived' ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
Archive
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
onclick={() => setFilter('trashed')}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm transition-colors {$currentFilter === 'trashed' ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Trash
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Tags</h3>
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each $allTags as tag}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectTag($selectedTag === tag ? null : tag)}
|
||||
class="flex w-full items-center gap-3 rounded-full px-6 py-2 text-left text-sm transition-colors {$selectedTag === tag ? 'bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200' : 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800'}"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
#{tag}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
interface ToastMessage {
|
||||
id: number;
|
||||
text: string;
|
||||
type: 'info' | 'success' | 'error';
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
export const toasts = writable<ToastMessage[]>([]);
|
||||
|
||||
export function showToast(
|
||||
text: string,
|
||||
type: 'info' | 'success' | 'error' = 'info',
|
||||
action?: { label: string; handler: () => void },
|
||||
duration = 4000
|
||||
) {
|
||||
const id = ++counter;
|
||||
toasts.update((t) => [...t, { id, text, type, action }]);
|
||||
if (!action) {
|
||||
setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
toasts.update((t) => t.filter((toast) => toast.id !== id));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg {toast.type === 'error' ? 'bg-red-600 text-white' : toast.type === 'success' ? 'bg-green-600 text-white' : 'bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900'}"
|
||||
>
|
||||
<span class="text-sm">{toast.text}</span>
|
||||
{#if toast.action}
|
||||
<button
|
||||
onclick={() => { toast.action?.handler(); dismiss(toast.id); }}
|
||||
class="text-sm font-semibold underline"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => dismiss(toast.id)} class="ml-2 opacity-70 hover:opacity-100">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { effectiveTheme } from '$lib/stores/theme.js';
|
||||
import { stripMarkdown } from '$lib/utils/markdown.js';
|
||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, currentFilter } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
note: Note;
|
||||
onEdit: (note: Note) => void;
|
||||
}
|
||||
|
||||
let { note, onEdit }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[note.color];
|
||||
const bg = $effectiveTheme === 'dark' ? colors.dark : colors.light;
|
||||
cardStyle = `background-color: ${bg}`;
|
||||
});
|
||||
|
||||
let cardStyle = $state('');
|
||||
|
||||
const preview = $derived(stripMarkdown(note.content).slice(0, 200));
|
||||
|
||||
function stop(fn: () => void) {
|
||||
return (e: Event) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="group relative cursor-pointer rounded-lg border border-gray-200 p-4 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700"
|
||||
style={cardStyle}
|
||||
onclick={() => onEdit(note)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-testid="note-card"
|
||||
data-note-id={note.id}
|
||||
>
|
||||
{#if note.title}
|
||||
<h3 class="mb-2 text-sm font-semibold text-gray-900 dark:text-gray-100">{note.title}</h3>
|
||||
{/if}
|
||||
|
||||
{#if preview}
|
||||
<p class="line-clamp-6 text-sm text-gray-700 dark:text-gray-300">{preview}</p>
|
||||
{/if}
|
||||
|
||||
{#if note.tags && note.tags.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each note.tags as tag}
|
||||
<span class="rounded-full bg-gray-200/60 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700/60 dark:text-gray-400">
|
||||
#{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Action buttons - show on hover -->
|
||||
<div class="absolute bottom-1 right-1 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{#if $currentFilter === 'trashed'}
|
||||
<button
|
||||
onclick={stop(() => restoreNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title="Restore"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={stop(() => deleteNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title="Delete forever"
|
||||
data-testid="delete-forever-btn"
|
||||
>
|
||||
<svg class="h-4 w-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title={note.pinned ? 'Unpin' : 'Pin'}
|
||||
data-testid="pin-btn"
|
||||
>
|
||||
<svg class="h-4 w-4 {note.pinned ? 'text-amber-600' : ''}" fill={note.pinned ? 'currentColor' : 'none'} stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if $currentFilter === 'archived'}
|
||||
<button
|
||||
onclick={stop(() => unarchiveNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title="Unarchive"
|
||||
data-testid="unarchive-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={stop(() => archiveNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title="Archive"
|
||||
data-testid="archive-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
onclick={stop(() => trashNote(note.id))}
|
||||
class="rounded-full p-1.5 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title="Delete"
|
||||
data-testid="trash-btn"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Pin indicator -->
|
||||
{#if note.pinned}
|
||||
<div class="absolute right-2 top-2">
|
||||
<svg class="h-4 w-4 text-amber-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import Checklist from './Checklist.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
import { effectiveTheme } from '$lib/stores/theme.js';
|
||||
import type { Note, NoteColor } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
isNew?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { note, isNew = false, onClose }: Props = $props();
|
||||
|
||||
let title = $state(note?.title || '');
|
||||
let content = $state(note?.content || '');
|
||||
let color = $state<NoteColor>(note?.color || 'default');
|
||||
let checklistMode = $state(note?.checklistMode || false);
|
||||
let showPreview = $state(false);
|
||||
let showColorPicker = $state(false);
|
||||
|
||||
let bgStyle = $state('');
|
||||
$effect(() => {
|
||||
const colors = NOTE_COLORS[color];
|
||||
bgStyle = `background-color: ${$effectiveTheme === 'dark' ? colors.dark : colors.light}`;
|
||||
});
|
||||
|
||||
const renderedContent = $derived(renderMarkdown(content));
|
||||
|
||||
async function save() {
|
||||
if (!title.trim() && !content.trim()) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
await createNote({ title, content, color, checklistMode });
|
||||
} else if (note) {
|
||||
await updateNote(note.id, { title, content, color, checklistMode });
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
function handleColorSelect(c: NoteColor) {
|
||||
color = c;
|
||||
showColorPicker = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-40 flex items-start justify-center bg-black/50 pt-20"
|
||||
onclick={save}
|
||||
onkeydown={handleKeydown}
|
||||
data-testid="note-editor-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 w-full max-w-xl rounded-lg border border-gray-200 shadow-xl dark:border-gray-700"
|
||||
style={bgStyle}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); handleKeydown(e); }}
|
||||
data-testid="note-editor"
|
||||
>
|
||||
<!-- Title -->
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={title}
|
||||
class="w-full bg-transparent px-4 pt-4 text-lg font-semibold text-gray-900 outline-none placeholder:text-gray-500 dark:text-gray-100"
|
||||
data-testid="note-title-input"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
{#if checklistMode}
|
||||
<div class="px-4 py-2">
|
||||
<Checklist {content} onChange={(c) => (content = c)} />
|
||||
</div>
|
||||
{:else if showPreview}
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert min-h-[100px] max-w-none px-4 py-2"
|
||||
data-testid="note-preview"
|
||||
>
|
||||
{@html renderedContent}
|
||||
</div>
|
||||
{:else}
|
||||
<textarea
|
||||
placeholder="Take a note..."
|
||||
bind:value={content}
|
||||
class="min-h-[100px] w-full resize-none bg-transparent px-4 py-2 text-sm text-gray-800 outline-none placeholder:text-gray-500 dark:text-gray-200"
|
||||
rows="6"
|
||||
data-testid="note-content-input"
|
||||
></textarea>
|
||||
{/if}
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between border-t border-gray-200/50 px-2 py-2 dark:border-gray-700/50">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Color picker toggle -->
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => (showColorPicker = !showColorPicker)}
|
||||
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title="Background color"
|
||||
data-testid="color-picker-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if showColorPicker}
|
||||
<div class="absolute left-0 top-full mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800">
|
||||
<ColorPicker selected={color} onSelect={handleColorSelect} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview toggle -->
|
||||
<button
|
||||
onclick={() => (showPreview = !showPreview)}
|
||||
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10"
|
||||
title={showPreview ? 'Edit' : 'Preview'}
|
||||
data-testid="preview-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{#if showPreview}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
{:else}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Checklist mode toggle -->
|
||||
<button
|
||||
onclick={() => (checklistMode = !checklistMode)}
|
||||
class="rounded-full p-2 hover:bg-black/10 dark:hover:bg-white/10 {checklistMode ? 'text-amber-600' : ''}"
|
||||
title="Checklist mode"
|
||||
data-testid="checklist-toggle"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onclick={save}
|
||||
class="rounded px-4 py-1 text-sm font-medium text-gray-700 hover:bg-black/10 dark:text-gray-300 dark:hover:bg-white/10"
|
||||
data-testid="close-editor-btn"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import NoteCard from './NoteCard.svelte';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
interface Props {
|
||||
notes: Note[];
|
||||
label?: string;
|
||||
onEdit: (note: Note) => void;
|
||||
}
|
||||
|
||||
let { notes, label = '', onEdit }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if notes.length > 0}
|
||||
{#if label}
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</p>
|
||||
{/if}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" data-testid="note-grid">
|
||||
{#each notes as note (note.id)}
|
||||
<NoteCard {note} {onEdit} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { notes, loadNotes, currentFilter } from '$lib/stores/notes.js';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
let query = $state('');
|
||||
let originalNotes: Note[] = [];
|
||||
let isSearching = $state(false);
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) {
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSearching) {
|
||||
notes.subscribe((n) => (originalNotes = n))();
|
||||
isSearching = true;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
if (res.ok) {
|
||||
const results = await res.json();
|
||||
notes.set(results);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
query = '';
|
||||
if (isSearching) {
|
||||
notes.set(originalNotes);
|
||||
isSearching = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative max-w-2xl">
|
||||
<div class="flex items-center rounded-lg bg-gray-100 px-4 py-2 dark:bg-gray-800">
|
||||
<svg class="mr-3 h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search notes..."
|
||||
bind:value={query}
|
||||
oninput={handleSearch}
|
||||
class="w-full bg-transparent text-sm text-gray-800 outline-none placeholder:text-gray-500 dark:text-gray-200"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
{#if query}
|
||||
<button onclick={clearSearch} class="ml-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" aria-label="Clear search">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
// Placeholder for Phase 6 - will be connected to sync store
|
||||
let status: 'synced' | 'syncing' | 'offline' | 'error' = $state('synced');
|
||||
</script>
|
||||
|
||||
<div class="flex items-center" title="Sync status: {status}" data-testid="sync-indicator">
|
||||
{#if status === 'synced'}
|
||||
<svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
{:else if status === 'syncing'}
|
||||
<svg class="h-5 w-5 animate-spin text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{:else if status === 'offline'}
|
||||
<svg class="h-5 w-5 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { allTags, selectedTag } from '$lib/stores/notes.js';
|
||||
|
||||
function toggleTag(tag: string) {
|
||||
selectedTag.update((current) => (current === tag ? null : tag));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $allTags.length > 0}
|
||||
<div class="flex flex-wrap gap-2" data-testid="tag-filter">
|
||||
{#each $allTags as tag}
|
||||
<button
|
||||
onclick={() => toggleTag(tag)}
|
||||
class="rounded-full px-3 py-1 text-xs font-medium transition-colors {$selectedTag === tag ? 'bg-amber-200 text-amber-900 dark:bg-amber-800 dark:text-amber-100' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'}"
|
||||
data-testid="tag-chip"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { effectiveTheme, toggleTheme } from '$lib/stores/theme.js';
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
class="rounded-full p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
aria-label="Toggle dark mode"
|
||||
data-testid="theme-toggle"
|
||||
>
|
||||
{#if $effectiveTheme === 'dark'}
|
||||
<svg class="h-5 w-5 text-gray-300" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-gray-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { writeFile, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from './db/index.js';
|
||||
import { attachments } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
||||
|
||||
// Ensure attachments directory exists
|
||||
if (!existsSync(ATTACHMENTS_DIR)) {
|
||||
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function saveAttachment(
|
||||
noteId: string,
|
||||
file: File
|
||||
): Promise<typeof attachments.$inferSelect> {
|
||||
const id = uuidv4();
|
||||
const ext = file.name.split('.').pop() || 'bin';
|
||||
const filename = `${id}.${ext}`;
|
||||
const filePath = join(ATTACHMENTS_DIR, filename);
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const [attachment] = await db
|
||||
.insert(attachments)
|
||||
.values({
|
||||
id,
|
||||
noteId,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
path: filePath,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning();
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByNote(noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
const attachment = await getAttachment(id);
|
||||
if (attachment) {
|
||||
try {
|
||||
await unlink(attachment.path);
|
||||
} catch {
|
||||
// File may already be deleted
|
||||
}
|
||||
await db.delete(attachments).where(eq(attachments.id, id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
describe('Auth - Password Hashing', () => {
|
||||
it('should hash and verify a password correctly', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
expect(hash).not.toBe(password);
|
||||
expect(await argon2.verify(hash, password)).toBe(true);
|
||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for the same password', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const hash1 = await argon2.hash(password);
|
||||
const hash2 = await argon2.hash(password);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
expect(await argon2.verify(hash1, password)).toBe(true);
|
||||
expect(await argon2.verify(hash2, password)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth - User Setup', () => {
|
||||
it('should create a user with hashed password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
passwordHash: hash,
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
const user = await db.select().from(users).get();
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.passwordHash).not.toBe(password);
|
||||
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only allow one user', async () => {
|
||||
await db.insert(users).values({
|
||||
passwordHash: 'hash1',
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
const allUsers = await db.select().from(users);
|
||||
expect(allUsers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth - Sessions', () => {
|
||||
it('should create and validate a session', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
});
|
||||
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.userId).toBe(user.id);
|
||||
expect(session!.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('should detect expired sessions', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() - 1000) // Expired
|
||||
});
|
||||
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.expiresAt.getTime()).toBeLessThan(Date.now());
|
||||
});
|
||||
|
||||
it('should delete a session', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
});
|
||||
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
expect(session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { db } from './db/index.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
export async function isSetupComplete(): Promise<boolean> {
|
||||
const user = await db.select().from(users).get();
|
||||
return !!user;
|
||||
}
|
||||
|
||||
export async function setupUser(password: string): Promise<boolean> {
|
||||
const existing = await db.select().from(users).get();
|
||||
if (existing) return false; // Already set up
|
||||
|
||||
const hash = await argon2.hash(password);
|
||||
await db.insert(users).values({
|
||||
passwordHash: hash,
|
||||
createdAt: new Date()
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string): Promise<boolean> {
|
||||
const user = await db.select().from(users).get();
|
||||
if (!user) return false;
|
||||
return argon2.verify(user.passwordHash, password);
|
||||
}
|
||||
|
||||
export async function createSession(): Promise<string> {
|
||||
const user = await db.select().from(users).get();
|
||||
if (!user) throw new Error('No user exists');
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function validateSession(token: string): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
if (!session) return false;
|
||||
|
||||
if (session.expiresAt < new Date()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteSession(token: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
}
|
||||
|
||||
export async function cleanExpiredSessions(): Promise<void> {
|
||||
const now = new Date();
|
||||
// Delete all expired sessions by checking each one
|
||||
const allSessions = await db.select().from(sessions);
|
||||
for (const session of allSessions) {
|
||||
if (session.expiresAt < now) {
|
||||
await db.delete(sessions).where(eq(sessions.id, session.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || './data/crumbs.db';
|
||||
|
||||
// Ensure the data directory exists
|
||||
const dir = dirname(DATABASE_URL);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const sqlite = new Database(DATABASE_URL);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Auto-create tables on first run
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
trashed_at INTEGER,
|
||||
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_tags (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id),
|
||||
operation TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { sqlite };
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteTags, tags } from './schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
describe('Notes CRUD', () => {
|
||||
it('should create a note with default values', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Test Note',
|
||||
content: 'Hello world',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.title).toBe('Test Note');
|
||||
expect(result!.content).toBe('Hello world');
|
||||
expect(result!.color).toBe('default');
|
||||
expect(result!.pinned).toBe(false);
|
||||
expect(result!.archived).toBe(false);
|
||||
expect(result!.trashed).toBe(false);
|
||||
expect(result!.checklistMode).toBe(false);
|
||||
expect(result!.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should create a note with custom color and pinned state', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Colored Note',
|
||||
content: '',
|
||||
color: 'coral',
|
||||
pinned: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
|
||||
expect(result!.color).toBe('coral');
|
||||
expect(result!.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should read all active notes (not archived, not trashed)', async () => {
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
|
||||
{
|
||||
id: 'n2',
|
||||
title: 'Archived',
|
||||
content: '',
|
||||
archived: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const active = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.archived, false), eq(notes.trashed, false)));
|
||||
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].title).toBe('Active');
|
||||
});
|
||||
|
||||
it('should update a note and increment version', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Original',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const later = new Date(now.getTime() + 1000);
|
||||
await db
|
||||
.update(notes)
|
||||
.set({ title: 'Updated', updatedAt: later, version: 2 })
|
||||
.where(eq(notes.id, id));
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
|
||||
expect(result!.title).toBe('Updated');
|
||||
expect(result!.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should soft-delete a note (move to trash)', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'To Trash',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.update(notes).set({ trashed: true, trashedAt: now }).where(eq(notes.id, id));
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.trashed).toBe(true);
|
||||
expect(result!.trashedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should permanently delete a note', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'To Delete',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.delete(notes).where(eq(notes.id, id));
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should archive and unarchive a note', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'To Archive',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.update(notes).set({ archived: true }).where(eq(notes.id, id));
|
||||
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.archived).toBe(true);
|
||||
|
||||
await db.update(notes).set({ archived: false }).where(eq(notes.id, id));
|
||||
result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.archived).toBe(false);
|
||||
});
|
||||
|
||||
it('should pin and unpin a note', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Pin Me',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.update(notes).set({ pinned: true }).where(eq(notes.id, id));
|
||||
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.pinned).toBe(true);
|
||||
|
||||
await db.update(notes).set({ pinned: false }).where(eq(notes.id, id));
|
||||
result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.pinned).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notes with Tags', () => {
|
||||
it('should associate tags with a note', async () => {
|
||||
const noteId = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
title: 'Tagged Note',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
// Create tags
|
||||
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
|
||||
|
||||
// Associate
|
||||
await db.insert(noteTags).values([
|
||||
{ noteId, tagId: tag1.id },
|
||||
{ noteId, tagId: tag2.id }
|
||||
]);
|
||||
|
||||
// Query
|
||||
const tagRows = await db
|
||||
.select({ name: tags.name })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(eq(noteTags.noteId, noteId));
|
||||
|
||||
expect(tagRows).toHaveLength(2);
|
||||
expect(tagRows.map((t) => t.name).sort()).toEqual(['important', 'work']);
|
||||
});
|
||||
|
||||
it('should cascade delete tags when note is deleted', async () => {
|
||||
const noteId = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
title: '',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
|
||||
await db.insert(noteTags).values({ noteId, tagId: tag.id });
|
||||
|
||||
// Delete note
|
||||
await db.delete(notes).where(eq(notes.id, noteId));
|
||||
|
||||
// note_tags should be gone
|
||||
const remaining = await db
|
||||
.select()
|
||||
.from(noteTags)
|
||||
.where(eq(noteTags.noteId, noteId));
|
||||
expect(remaining).toHaveLength(0);
|
||||
|
||||
// Tag itself should still exist
|
||||
const tagStillExists = await db.select().from(tags).where(eq(tags.id, tag.id)).get();
|
||||
expect(tagStillExists).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note ordering', () => {
|
||||
it('should return pinned notes first', async () => {
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{
|
||||
id: 'unpinned',
|
||||
title: 'Unpinned',
|
||||
content: '',
|
||||
pinned: false,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: 'pinned',
|
||||
title: 'Pinned',
|
||||
content: '',
|
||||
pinned: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
]);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(({ pinned }) => [
|
||||
// SQLite: true = 1, false = 0, DESC puts 1 first
|
||||
eq(pinned, pinned) // just use raw desc
|
||||
]);
|
||||
|
||||
// Manual approach: order by pinned desc
|
||||
const { desc } = await import('drizzle-orm');
|
||||
const ordered = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
|
||||
|
||||
expect(ordered[0].title).toBe('Pinned');
|
||||
expect(ordered[1].title).toBe('Unpinned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable('sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const notes = sqliteTable('notes', {
|
||||
id: text('id').primaryKey(),
|
||||
title: text('title').default('').notNull(),
|
||||
content: text('content').default('').notNull(),
|
||||
color: text('color').default('default').notNull(),
|
||||
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
|
||||
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
|
||||
trashed: integer('trashed', { mode: 'boolean' }).default(false).notNull(),
|
||||
trashedAt: integer('trashed_at', { mode: 'timestamp' }),
|
||||
checklistMode: integer('checklist_mode', { mode: 'boolean' }).default(false).notNull(),
|
||||
sortOrder: integer('sort_order').default(0).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
version: integer('version').default(1).notNull()
|
||||
});
|
||||
|
||||
export const tags = sqliteTable(
|
||||
'tags',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull()
|
||||
},
|
||||
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
|
||||
);
|
||||
|
||||
export const noteTags = sqliteTable('note_tags', {
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
tagId: integer('tag_id')
|
||||
.references(() => tags.id, { onDelete: 'cascade' })
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
path: text('path').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const syncLog = sqliteTable('sync_log', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id)
|
||||
.notNull(),
|
||||
operation: text('operation').notNull(),
|
||||
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
|
||||
clientId: text('client_id').notNull()
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, tags, noteTags } from './schema.js';
|
||||
import { and, eq, like, or } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
|
||||
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
|
||||
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
|
||||
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
|
||||
});
|
||||
|
||||
describe('Search', () => {
|
||||
it('should search by title', async () => {
|
||||
const pattern = '%shopping%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
it('should search by content', async () => {
|
||||
const pattern = '%tomato%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.trashed, false), like(notes.content, pattern)));
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n3');
|
||||
});
|
||||
|
||||
it('should search across title and content', async () => {
|
||||
const pattern = '%milk%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
it('should not include trashed notes', async () => {
|
||||
const pattern = '%should not%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty for no matches', async () => {
|
||||
const pattern = '%nonexistent%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be case-insensitive with LIKE', async () => {
|
||||
const pattern = '%SHOPPING%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
|
||||
|
||||
// SQLite LIKE is case-insensitive for ASCII by default
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should search by tag name', async () => {
|
||||
const tagResults = await db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(like(tags.name, '%work%'));
|
||||
|
||||
expect(tagResults).toHaveLength(1);
|
||||
expect(tagResults[0].noteId).toBe('n2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
/**
|
||||
* Create an in-memory SQLite database for testing.
|
||||
* Each test gets a fresh database.
|
||||
*/
|
||||
export function createTestDb() {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Create tables
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
trashed_at INTEGER,
|
||||
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
|
||||
|
||||
CREATE TABLE note_tags (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id),
|
||||
operation TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
return { db, sqlite };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import type { Note, NoteFilter, NoteCreate, NoteUpdate } from '$lib/types/index.js';
|
||||
|
||||
export const notes = writable<Note[]>([]);
|
||||
export const currentFilter = writable<NoteFilter>('all');
|
||||
export const selectedTag = writable<string | null>(null);
|
||||
|
||||
export const filteredNotes = derived(
|
||||
[notes, selectedTag, currentFilter],
|
||||
([$notes, $selectedTag, $filter]) => {
|
||||
let result = $notes;
|
||||
if ($filter === 'all') {
|
||||
result = result.filter((n) => !n.trashed && !n.archived);
|
||||
} else if ($filter === 'archived') {
|
||||
result = result.filter((n) => n.archived && !n.trashed);
|
||||
} else if ($filter === 'trashed') {
|
||||
result = result.filter((n) => n.trashed);
|
||||
}
|
||||
if ($selectedTag) {
|
||||
result = result.filter((n) => n.tags?.includes($selectedTag));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
export const pinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => n.pinned)
|
||||
);
|
||||
|
||||
export const unpinnedNotes = derived(filteredNotes, ($notes) =>
|
||||
$notes.filter((n) => !n.pinned)
|
||||
);
|
||||
|
||||
export const allTags = derived(notes, ($notes) => {
|
||||
const tagSet = new Set<string>();
|
||||
$notes.forEach((n) => n.tags?.forEach((t) => tagSet.add(t)));
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
export async function loadNotes(filter: NoteFilter = 'all') {
|
||||
const res = await fetch(`/api/notes?filter=${filter}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notes.set(data);
|
||||
currentFilter.set(filter);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNote(note: NoteCreate): Promise<Note | null> {
|
||||
const res = await fetch('/api/notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(note)
|
||||
});
|
||||
if (res.ok) {
|
||||
const created = await res.json();
|
||||
notes.update((list) => [created, ...list]);
|
||||
return created;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function updateNote(id: string, updates: NoteUpdate): Promise<Note | null> {
|
||||
const res = await fetch(`/api/notes/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates)
|
||||
});
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
notes.update((list) => list.map((n) => (n.id === id ? updated : n)));
|
||||
return updated;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function deleteNote(id: string): Promise<boolean> {
|
||||
const res = await fetch(`/api/notes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
notes.update((list) => list.filter((n) => n.id !== id));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function trashNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { trashed: true });
|
||||
}
|
||||
|
||||
export async function restoreNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { trashed: false });
|
||||
}
|
||||
|
||||
export async function archiveNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { archived: true });
|
||||
}
|
||||
|
||||
export async function unarchiveNote(id: string): Promise<Note | null> {
|
||||
return updateNote(id, { archived: false });
|
||||
}
|
||||
|
||||
export async function togglePin(id: string, currentPinned: boolean): Promise<Note | null> {
|
||||
return updateNote(id, { pinned: !currentPinned });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (!browser) return 'system';
|
||||
return (localStorage.getItem('theme') as Theme) || 'system';
|
||||
}
|
||||
|
||||
function getEffectiveTheme(theme: Theme): 'light' | 'dark' {
|
||||
if (theme !== 'system') return theme;
|
||||
if (!browser) return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export const themePreference = writable<Theme>(getInitialTheme());
|
||||
export const effectiveTheme = writable<'light' | 'dark'>('light');
|
||||
|
||||
if (browser) {
|
||||
themePreference.subscribe((theme) => {
|
||||
localStorage.setItem('theme', theme);
|
||||
const effective = getEffectiveTheme(theme);
|
||||
effectiveTheme.set(effective);
|
||||
document.documentElement.classList.toggle('dark', effective === 'dark');
|
||||
});
|
||||
|
||||
// Listen for system theme changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
themePreference.update((current) => {
|
||||
if (current === 'system') {
|
||||
const effective = getEffectiveTheme('system');
|
||||
effectiveTheme.set(effective);
|
||||
document.documentElement.classList.toggle('dark', effective === 'dark');
|
||||
}
|
||||
return current;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
themePreference.update((current) => {
|
||||
const effective = getEffectiveTheme(current);
|
||||
return effective === 'dark' ? 'light' : 'dark';
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
import { putNote, getAllNotes, addToSyncQueue, getSyncQueue, clearSyncQueue, deleteNoteFromIdb, getMeta, setMeta } from './idb.js';
|
||||
import { mergeNotes } from './crdt.js';
|
||||
|
||||
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
||||
|
||||
let syncStatus: SyncStatus = 'synced';
|
||||
let syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
const statusListeners: Set<(status: SyncStatus) => void> = new Set();
|
||||
|
||||
export function onSyncStatusChange(listener: (status: SyncStatus) => void) {
|
||||
statusListeners.add(listener);
|
||||
return () => statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
function setStatus(status: SyncStatus) {
|
||||
syncStatus = status;
|
||||
statusListeners.forEach((l) => l(status));
|
||||
}
|
||||
|
||||
export function getSyncStatus(): SyncStatus {
|
||||
return syncStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local changes to server.
|
||||
*/
|
||||
async function pushChanges(): Promise<boolean> {
|
||||
const queue = await getSyncQueue();
|
||||
if (queue.length === 0) return true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ changes: queue })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await clearSyncQueue();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull remote changes and merge with local state.
|
||||
*/
|
||||
async function pullChanges(): Promise<Note[]> {
|
||||
const lastSync = (await getMeta('lastSyncTimestamp')) || '0';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sync?since=${lastSync}`);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const remoteNotes: Note[] = await res.json();
|
||||
const localNotes = await getAllNotes();
|
||||
const localMap = new Map(localNotes.map((n) => [n.id, n]));
|
||||
|
||||
for (const remote of remoteNotes) {
|
||||
const local = localMap.get(remote.id);
|
||||
if (local) {
|
||||
const merged = mergeNotes(local, remote);
|
||||
await putNote(merged);
|
||||
} else {
|
||||
await putNote(remote);
|
||||
}
|
||||
}
|
||||
|
||||
await setMeta('lastSyncTimestamp', String(Date.now()));
|
||||
return remoteNotes;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full sync cycle: push then pull.
|
||||
*/
|
||||
export async function sync(): Promise<void> {
|
||||
if (!browser || !navigator.onLine) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('syncing');
|
||||
|
||||
try {
|
||||
const pushed = await pushChanges();
|
||||
if (!pushed) {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
await pullChanges();
|
||||
setStatus('synced');
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background sync (every 30 seconds).
|
||||
*/
|
||||
export function startSync() {
|
||||
if (!browser) return;
|
||||
|
||||
// Initial sync
|
||||
sync();
|
||||
|
||||
// Periodic sync
|
||||
syncInterval = setInterval(sync, 30_000);
|
||||
|
||||
// Sync on online/offline events
|
||||
window.addEventListener('online', () => sync());
|
||||
window.addEventListener('offline', () => setStatus('offline'));
|
||||
}
|
||||
|
||||
export function stopSync() {
|
||||
if (syncInterval) {
|
||||
clearInterval(syncInterval);
|
||||
syncInterval = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
/**
|
||||
* Last-Write-Wins (LWW) merge strategy per field.
|
||||
* For a single-user app with multiple devices, this is sufficient.
|
||||
* Each field is resolved by comparing updatedAt timestamps.
|
||||
*/
|
||||
export function mergeNotes(local: Note, remote: Note): Note {
|
||||
// If remote is newer overall, take remote
|
||||
const localTime = new Date(local.updatedAt).getTime();
|
||||
const remoteTime = new Date(remote.updatedAt).getTime();
|
||||
|
||||
if (remoteTime > localTime) {
|
||||
return { ...remote };
|
||||
}
|
||||
|
||||
if (localTime > remoteTime) {
|
||||
return { ...local };
|
||||
}
|
||||
|
||||
// Same timestamp - prefer higher version number
|
||||
if (remote.version > local.version) {
|
||||
return { ...remote };
|
||||
}
|
||||
|
||||
return { ...local };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a local note has changes compared to a remote note.
|
||||
*/
|
||||
export function hasChanges(local: Note, remote: Note): boolean {
|
||||
return (
|
||||
local.title !== remote.title ||
|
||||
local.content !== remote.content ||
|
||||
local.color !== remote.color ||
|
||||
local.pinned !== remote.pinned ||
|
||||
local.archived !== remote.archived ||
|
||||
local.trashed !== remote.trashed ||
|
||||
local.checklistMode !== remote.checklistMode ||
|
||||
local.sortOrder !== remote.sortOrder
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a diff of changed fields between two notes.
|
||||
*/
|
||||
export function diffNotes(
|
||||
oldNote: Note,
|
||||
newNote: Note
|
||||
): Partial<Note> {
|
||||
const diff: Partial<Note> = {};
|
||||
|
||||
if (oldNote.title !== newNote.title) diff.title = newNote.title;
|
||||
if (oldNote.content !== newNote.content) diff.content = newNote.content;
|
||||
if (oldNote.color !== newNote.color) diff.color = newNote.color;
|
||||
if (oldNote.pinned !== newNote.pinned) diff.pinned = newNote.pinned;
|
||||
if (oldNote.archived !== newNote.archived) diff.archived = newNote.archived;
|
||||
if (oldNote.trashed !== newNote.trashed) diff.trashed = newNote.trashed;
|
||||
if (oldNote.checklistMode !== newNote.checklistMode) diff.checklistMode = newNote.checklistMode;
|
||||
if (oldNote.sortOrder !== newNote.sortOrder) diff.sortOrder = newNote.sortOrder;
|
||||
|
||||
return diff;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { openDB, type IDBPDatabase } from 'idb';
|
||||
import type { Note } from '$lib/types/index.js';
|
||||
|
||||
const DB_NAME = 'crumbs';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
interface CrumbsDB {
|
||||
notes: {
|
||||
key: string;
|
||||
value: Note;
|
||||
indexes: {
|
||||
'by-updated': Date;
|
||||
};
|
||||
};
|
||||
syncQueue: {
|
||||
key: number;
|
||||
value: SyncQueueItem;
|
||||
indexes: {
|
||||
'by-timestamp': number;
|
||||
};
|
||||
};
|
||||
meta: {
|
||||
key: string;
|
||||
value: { key: string; value: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface SyncQueueItem {
|
||||
id?: number;
|
||||
noteId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
data?: Partial<Note>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<CrumbsDB>> | null = null;
|
||||
|
||||
export function getDb(): Promise<IDBPDatabase<CrumbsDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<CrumbsDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Notes store
|
||||
if (!db.objectStoreNames.contains('notes')) {
|
||||
const noteStore = db.createObjectStore('notes', { keyPath: 'id' });
|
||||
noteStore.createIndex('by-updated', 'updatedAt');
|
||||
}
|
||||
|
||||
// Sync queue
|
||||
if (!db.objectStoreNames.contains('syncQueue')) {
|
||||
const syncStore = db.createObjectStore('syncQueue', {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true
|
||||
});
|
||||
syncStore.createIndex('by-timestamp', 'timestamp');
|
||||
}
|
||||
|
||||
// Metadata (e.g., last sync timestamp, client ID)
|
||||
if (!db.objectStoreNames.contains('meta')) {
|
||||
db.createObjectStore('meta', { keyPath: 'key' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// Notes operations
|
||||
export async function getAllNotes(): Promise<Note[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll('notes');
|
||||
}
|
||||
|
||||
export async function getNote(id: string): Promise<Note | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get('notes', id);
|
||||
}
|
||||
|
||||
export async function putNote(note: Note): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('notes', note);
|
||||
}
|
||||
|
||||
export async function deleteNoteFromIdb(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('notes', id);
|
||||
}
|
||||
|
||||
export async function clearNotes(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.clear('notes');
|
||||
}
|
||||
|
||||
// Sync queue operations
|
||||
export async function addToSyncQueue(item: Omit<SyncQueueItem, 'id'>): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.add('syncQueue', item as SyncQueueItem);
|
||||
}
|
||||
|
||||
export async function getSyncQueue(): Promise<SyncQueueItem[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll('syncQueue');
|
||||
}
|
||||
|
||||
export async function clearSyncQueue(): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.clear('syncQueue');
|
||||
}
|
||||
|
||||
export async function removeSyncQueueItem(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('syncQueue', id);
|
||||
}
|
||||
|
||||
// Meta operations
|
||||
export async function getMeta(key: string): Promise<string | undefined> {
|
||||
const db = await getDb();
|
||||
const result = await db.get('meta', key);
|
||||
return result?.value;
|
||||
}
|
||||
|
||||
export async function setMeta(key: string, value: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('meta', { key, value });
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import { notes, syncLog } from '$lib/server/db/schema.js';
|
||||
import { eq, gt } from 'drizzle-orm';
|
||||
import type { SyncQueueItem } from './idb.js';
|
||||
|
||||
/**
|
||||
* Process incoming sync changes from client.
|
||||
*/
|
||||
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
|
||||
for (const change of changes) {
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
case 'update': {
|
||||
if (!change.data) continue;
|
||||
const existing = await db.select().from(notes).where(eq(notes.id, change.noteId)).get();
|
||||
|
||||
if (existing) {
|
||||
// LWW: only update if incoming timestamp is newer
|
||||
if (change.timestamp > existing.updatedAt.getTime()) {
|
||||
await db
|
||||
.update(notes)
|
||||
.set({
|
||||
...change.data,
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: existing.version + 1
|
||||
})
|
||||
.where(eq(notes.id, change.noteId));
|
||||
}
|
||||
} else if (change.operation === 'create' && change.data) {
|
||||
await db.insert(notes).values({
|
||||
id: change.noteId,
|
||||
title: change.data.title || '',
|
||||
content: change.data.content || '',
|
||||
color: change.data.color || 'default',
|
||||
pinned: change.data.pinned || false,
|
||||
archived: change.data.archived || false,
|
||||
trashed: change.data.trashed || false,
|
||||
checklistMode: change.data.checklistMode || false,
|
||||
sortOrder: change.data.sortOrder || 0,
|
||||
createdAt: new Date(change.timestamp),
|
||||
updatedAt: new Date(change.timestamp),
|
||||
version: 1
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
await db.delete(notes).where(eq(notes.id, change.noteId));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Log sync operation
|
||||
await db.insert(syncLog).values({
|
||||
noteId: change.noteId,
|
||||
operation: change.operation,
|
||||
timestamp: new Date(change.timestamp),
|
||||
clientId: 'default'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all notes updated since a given timestamp.
|
||||
*/
|
||||
export async function getChangesSince(sinceTimestamp: number) {
|
||||
const since = new Date(sinceTimestamp);
|
||||
const allNotes = await db.select().from(notes);
|
||||
// Filter notes updated after the timestamp
|
||||
return allNotes.filter((n) => n.updatedAt > since);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mergeNotes, hasChanges, diffNotes } from './crdt.js';
|
||||
import type { Note } from '../types/index.js';
|
||||
|
||||
function makeNote(overrides: Partial<Note> = {}): Note {
|
||||
return {
|
||||
id: 'test-id',
|
||||
title: 'Test',
|
||||
content: 'Content',
|
||||
color: 'default',
|
||||
pinned: false,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
checklistMode: false,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
version: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('mergeNotes (LWW)', () => {
|
||||
it('should prefer remote when remote is newer', () => {
|
||||
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-01') });
|
||||
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-02') });
|
||||
|
||||
const result = mergeNotes(local, remote);
|
||||
expect(result.title).toBe('Remote');
|
||||
});
|
||||
|
||||
it('should prefer local when local is newer', () => {
|
||||
const local = makeNote({ title: 'Local', updatedAt: new Date('2024-01-02') });
|
||||
const remote = makeNote({ title: 'Remote', updatedAt: new Date('2024-01-01') });
|
||||
|
||||
const result = mergeNotes(local, remote);
|
||||
expect(result.title).toBe('Local');
|
||||
});
|
||||
|
||||
it('should prefer higher version when timestamps are equal', () => {
|
||||
const ts = new Date('2024-01-01');
|
||||
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
|
||||
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 2 });
|
||||
|
||||
const result = mergeNotes(local, remote);
|
||||
expect(result.title).toBe('Remote');
|
||||
});
|
||||
|
||||
it('should prefer local when timestamps and versions are equal', () => {
|
||||
const ts = new Date('2024-01-01');
|
||||
const local = makeNote({ title: 'Local', updatedAt: ts, version: 1 });
|
||||
const remote = makeNote({ title: 'Remote', updatedAt: ts, version: 1 });
|
||||
|
||||
const result = mergeNotes(local, remote);
|
||||
expect(result.title).toBe('Local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasChanges', () => {
|
||||
it('should detect title change', () => {
|
||||
const a = makeNote({ title: 'A' });
|
||||
const b = makeNote({ title: 'B' });
|
||||
expect(hasChanges(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect content change', () => {
|
||||
const a = makeNote({ content: 'old' });
|
||||
const b = makeNote({ content: 'new' });
|
||||
expect(hasChanges(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect color change', () => {
|
||||
const a = makeNote({ color: 'default' });
|
||||
const b = makeNote({ color: 'coral' });
|
||||
expect(hasChanges(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect pinned change', () => {
|
||||
const a = makeNote({ pinned: false });
|
||||
const b = makeNote({ pinned: true });
|
||||
expect(hasChanges(a, b)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when notes are identical', () => {
|
||||
const a = makeNote();
|
||||
const b = makeNote();
|
||||
expect(hasChanges(a, b)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('diffNotes', () => {
|
||||
it('should return only changed fields', () => {
|
||||
const old = makeNote({ title: 'Old', content: 'Same' });
|
||||
const updated = makeNote({ title: 'New', content: 'Same' });
|
||||
|
||||
const diff = diffNotes(old, updated);
|
||||
expect(diff.title).toBe('New');
|
||||
expect(diff.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return empty object when no changes', () => {
|
||||
const a = makeNote();
|
||||
const b = makeNote();
|
||||
|
||||
const diff = diffNotes(a, b);
|
||||
expect(Object.keys(diff)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should track multiple changed fields', () => {
|
||||
const old = makeNote({ title: 'Old', color: 'default', pinned: false });
|
||||
const updated = makeNote({ title: 'New', color: 'coral', pinned: true });
|
||||
|
||||
const diff = diffNotes(old, updated);
|
||||
expect(diff.title).toBe('New');
|
||||
expect(diff.color).toBe('coral');
|
||||
expect(diff.pinned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
color: NoteColor;
|
||||
pinned: boolean;
|
||||
archived: boolean;
|
||||
trashed: boolean;
|
||||
trashedAt: Date | null;
|
||||
checklistMode: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
version: number;
|
||||
tags?: string[];
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
export interface NoteCreate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: NoteColor;
|
||||
pinned?: boolean;
|
||||
checklistMode?: boolean;
|
||||
}
|
||||
|
||||
export interface NoteUpdate {
|
||||
title?: string;
|
||||
content?: string;
|
||||
color?: NoteColor;
|
||||
pinned?: boolean;
|
||||
archived?: boolean;
|
||||
trashed?: boolean;
|
||||
checklistMode?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
noteId: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SyncChange {
|
||||
noteId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
timestamp: Date;
|
||||
clientId: string;
|
||||
data?: Partial<Note>;
|
||||
}
|
||||
|
||||
export type NoteColor =
|
||||
| 'default'
|
||||
| 'coral'
|
||||
| 'peach'
|
||||
| 'sand'
|
||||
| 'mint'
|
||||
| 'sage'
|
||||
| 'fog'
|
||||
| 'storm'
|
||||
| 'dusk'
|
||||
| 'blossom'
|
||||
| 'clay'
|
||||
| 'chalk';
|
||||
|
||||
export type NoteFilter = 'all' | 'archived' | 'trashed';
|
||||
|
||||
export type SyncStatus = 'synced' | 'syncing' | 'offline' | 'error';
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NoteColor } from '$lib/types/index.js';
|
||||
|
||||
export const NOTE_COLORS: Record<NoteColor, { light: string; dark: string; label: string }> = {
|
||||
default: { light: '#ffffff', dark: '#202124', label: 'Default' },
|
||||
coral: { light: '#faafa8', dark: '#77172e', label: 'Coral' },
|
||||
peach: { light: '#f39f76', dark: '#692b17', label: 'Peach' },
|
||||
sand: { light: '#fff8b8', dark: '#7c4a03', label: 'Sand' },
|
||||
mint: { light: '#e2f6d3', dark: '#264d3b', label: 'Mint' },
|
||||
sage: { light: '#b4ddd3', dark: '#0c625d', label: 'Sage' },
|
||||
fog: { light: '#d4e4ed', dark: '#256377', label: 'Fog' },
|
||||
storm: { light: '#aeccdc', dark: '#284255', label: 'Storm' },
|
||||
dusk: { light: '#d3bfdb', dark: '#472e5b', label: 'Dusk' },
|
||||
blossom: { light: '#f6e2dd', dark: '#6c394f', label: 'Blossom' },
|
||||
clay: { light: '#e9e3d4', dark: '#4b443a', label: 'Clay' },
|
||||
chalk: { light: '#efeff1', dark: '#232427', label: 'Chalk' }
|
||||
};
|
||||
|
||||
export const COLOR_OPTIONS = Object.entries(NOTE_COLORS).map(([value, { label }]) => ({
|
||||
value: value as NoteColor,
|
||||
label
|
||||
}));
|
||||
@@ -0,0 +1,7 @@
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number): T {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
return ((...args: unknown[]) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), ms);
|
||||
}) as T;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderMarkdown, stripMarkdown } from './markdown.js';
|
||||
|
||||
describe('renderMarkdown', () => {
|
||||
it('should render bold text', () => {
|
||||
const html = renderMarkdown('**bold**');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
});
|
||||
|
||||
it('should render italic text', () => {
|
||||
const html = renderMarkdown('*italic*');
|
||||
expect(html).toContain('<em>italic</em>');
|
||||
});
|
||||
|
||||
it('should render headings', () => {
|
||||
const html = renderMarkdown('# Heading 1');
|
||||
expect(html).toContain('<h1>Heading 1</h1>');
|
||||
});
|
||||
|
||||
it('should render unordered lists', () => {
|
||||
const html = renderMarkdown('- item 1\n- item 2');
|
||||
expect(html).toContain('<li>item 1</li>');
|
||||
expect(html).toContain('<li>item 2</li>');
|
||||
});
|
||||
|
||||
it('should render ordered lists', () => {
|
||||
const html = renderMarkdown('1. first\n2. second');
|
||||
expect(html).toContain('<li>first</li>');
|
||||
expect(html).toContain('<li>second</li>');
|
||||
});
|
||||
|
||||
it('should render code blocks', () => {
|
||||
const html = renderMarkdown('```\nconst x = 1;\n```');
|
||||
expect(html).toContain('<code>');
|
||||
expect(html).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('should render inline code', () => {
|
||||
const html = renderMarkdown('Use `npm install`');
|
||||
expect(html).toContain('<code>npm install</code>');
|
||||
});
|
||||
|
||||
it('should render links', () => {
|
||||
const html = renderMarkdown('[Google](https://google.com)');
|
||||
expect(html).toContain('href="https://google.com"');
|
||||
expect(html).toContain('Google');
|
||||
});
|
||||
|
||||
it('should render tables', () => {
|
||||
const md = '| A | B |\n|---|---|\n| 1 | 2 |';
|
||||
const html = renderMarkdown(md);
|
||||
expect(html).toContain('<table>');
|
||||
expect(html).toContain('<td>1</td>');
|
||||
});
|
||||
|
||||
it('should auto-linkify URLs', () => {
|
||||
const html = renderMarkdown('Visit https://example.com');
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
it('should convert line breaks', () => {
|
||||
const html = renderMarkdown('line 1\nline 2');
|
||||
expect(html).toContain('<br>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMarkdown', () => {
|
||||
it('should remove markdown formatting', () => {
|
||||
expect(stripMarkdown('**bold** and *italic*')).toBe('bold and italic');
|
||||
});
|
||||
|
||||
it('should remove code blocks', () => {
|
||||
expect(stripMarkdown('```\ncode\n```')).toBe('');
|
||||
});
|
||||
|
||||
it('should remove inline code backticks', () => {
|
||||
expect(stripMarkdown('Use `command`')).toBe('Use');
|
||||
});
|
||||
|
||||
it('should extract link text', () => {
|
||||
expect(stripMarkdown('[Click here](https://example.com)')).toBe('Click here');
|
||||
});
|
||||
|
||||
it('should return empty for empty input', () => {
|
||||
expect(stripMarkdown('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
breaks: true
|
||||
});
|
||||
|
||||
// Add task list support
|
||||
md.core.ruler.after('inline', 'task-lists', (state) => {
|
||||
const tokens = state.tokens;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (tokens[i].type !== 'inline') continue;
|
||||
const content = tokens[i].content;
|
||||
if (content.startsWith('[ ] ') || content.startsWith('[x] ')) {
|
||||
const checked = content.startsWith('[x] ');
|
||||
const checkbox = checked
|
||||
? '<input type="checkbox" checked disabled class="task-checkbox" /> '
|
||||
: '<input type="checkbox" disabled class="task-checkbox" /> ';
|
||||
tokens[i].content = content.slice(4);
|
||||
tokens[i].children = md.parseInline(tokens[i].content, state.env)[0].children;
|
||||
// Prepend checkbox
|
||||
const token = new state.Token('html_inline', '', 0);
|
||||
token.content = checkbox;
|
||||
tokens[i].children!.unshift(token);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export function renderMarkdown(content: string): string {
|
||||
return md.render(content);
|
||||
}
|
||||
|
||||
export function stripMarkdown(content: string): string {
|
||||
// Simple strip for preview - remove markdown syntax
|
||||
return content
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]*`/g, '')
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1')
|
||||
.replace(/[#*_~\[\]]/g, '')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractTags } from './tags.js';
|
||||
|
||||
describe('extractTags', () => {
|
||||
it('should extract simple hashtags', () => {
|
||||
expect(extractTags('Hello #world')).toEqual(['world']);
|
||||
});
|
||||
|
||||
it('should extract multiple hashtags', () => {
|
||||
expect(extractTags('#work #personal #todo')).toEqual(['work', 'personal', 'todo']);
|
||||
});
|
||||
|
||||
it('should deduplicate tags', () => {
|
||||
expect(extractTags('#work some text #work')).toEqual(['work']);
|
||||
});
|
||||
|
||||
it('should lowercase tags', () => {
|
||||
expect(extractTags('#Work #IMPORTANT')).toEqual(['work', 'important']);
|
||||
});
|
||||
|
||||
it('should handle tags with hyphens', () => {
|
||||
expect(extractTags('#my-project')).toEqual(['my-project']);
|
||||
});
|
||||
|
||||
it('should handle tags with underscores', () => {
|
||||
expect(extractTags('#my_project')).toEqual(['my_project']);
|
||||
});
|
||||
|
||||
it('should return empty array for no tags', () => {
|
||||
expect(extractTags('No hashtags here')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
expect(extractTags('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match hashtags inside code blocks', () => {
|
||||
expect(extractTags('```\n#not-a-tag\n```')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match hashtags inside inline code', () => {
|
||||
expect(extractTags('Use `#channel` for that')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not match hashtags in URLs', () => {
|
||||
expect(extractTags('Visit https://example.com#section')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle tags at start of line', () => {
|
||||
expect(extractTags('#first tag')).toEqual(['first']);
|
||||
});
|
||||
|
||||
it('should handle mixed content with code and tags', () => {
|
||||
const content = 'My #project uses `#define` and has a #deadline';
|
||||
expect(extractTags(content)).toEqual(['project', 'deadline']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Extract #hashtags from text content.
|
||||
* Matches hashtags that start with # followed by word characters.
|
||||
* Does not match inside code blocks or URLs.
|
||||
*/
|
||||
export function extractTags(content: string): string[] {
|
||||
if (!content) return [];
|
||||
|
||||
// Remove code blocks (``` ... ```) to avoid matching inside them
|
||||
const withoutCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
|
||||
// Remove inline code (` ... `)
|
||||
const withoutInlineCode = withoutCodeBlocks.replace(/`[^`]*`/g, '');
|
||||
// Remove URLs
|
||||
const withoutUrls = withoutInlineCode.replace(/https?:\/\/\S+/g, '');
|
||||
|
||||
const matches = withoutUrls.match(/(?:^|\s)#([\w-]+)/g);
|
||||
if (!matches) return [];
|
||||
|
||||
const tags = matches.map((m) => m.trim().slice(1).toLowerCase());
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
Reference in New Issue
Block a user