Tags that are no longer associated with any entry are automatically deleted from the database when a tag is removed from an entry. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
351 lines
9.0 KiB
TypeScript
351 lines
9.0 KiB
TypeScript
import type { PageServerLoad, Actions } from './$types';
|
|
import { fail } from '@sveltejs/kit';
|
|
import { entryRepository, imageRepository, tagRepository } from '$lib/server/db/repository';
|
|
import {
|
|
saveOriginal,
|
|
saveThumbnail,
|
|
ensureDirectories,
|
|
deleteImage as deleteImageFile
|
|
} from '$lib/server/images/storage';
|
|
import { generateThumbnail } from '$lib/server/images/thumbnails';
|
|
import { parseHashtags } from '$lib/utils/parseHashtags';
|
|
|
|
export const load: PageServerLoad = async ({ url }) => {
|
|
const showCompleted = url.searchParams.get('showCompleted') === 'true';
|
|
const entries = entryRepository.getOrdered({ showCompleted });
|
|
|
|
// Attach images AND tags to each entry
|
|
const entriesWithData = entries.map((entry) => ({
|
|
...entry,
|
|
images: imageRepository.getByEntryId(entry.id),
|
|
tags: tagRepository.getByEntryId(entry.id)
|
|
}));
|
|
|
|
// Get all tags for autocomplete
|
|
const allTags = tagRepository.getAll();
|
|
|
|
return {
|
|
entries: entriesWithData,
|
|
allTags,
|
|
showCompleted
|
|
};
|
|
};
|
|
|
|
export const actions: Actions = {
|
|
create: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const title = formData.get('title')?.toString().trim() || undefined;
|
|
const content = formData.get('content')?.toString().trim() || '';
|
|
const type = formData.get('type')?.toString() as 'task' | 'thought' | undefined;
|
|
|
|
// Validate content is not empty
|
|
if (!content) {
|
|
return fail(400, {
|
|
error: 'Content is required',
|
|
title: title || '',
|
|
content: '',
|
|
type: type || 'thought'
|
|
});
|
|
}
|
|
|
|
const entry = entryRepository.create({
|
|
title,
|
|
content,
|
|
type: type || 'thought'
|
|
});
|
|
|
|
return { success: true, entryId: entry.id };
|
|
},
|
|
|
|
update: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
// Collect fields to update
|
|
const updates: Record<string, string | boolean | null> = {};
|
|
|
|
const title = formData.get('title');
|
|
if (title !== null) {
|
|
updates.title = title.toString().trim() || null;
|
|
}
|
|
|
|
let contentChanged = false;
|
|
const content = formData.get('content');
|
|
if (content !== null) {
|
|
const contentStr = content.toString().trim();
|
|
if (contentStr === '') {
|
|
return fail(400, { error: 'Content cannot be empty' });
|
|
}
|
|
updates.content = contentStr;
|
|
contentChanged = true;
|
|
}
|
|
|
|
const type = formData.get('type');
|
|
if (type !== null) {
|
|
const typeStr = type.toString();
|
|
if (typeStr !== 'task' && typeStr !== 'thought') {
|
|
return fail(400, { error: 'Invalid type' });
|
|
}
|
|
updates.type = typeStr;
|
|
}
|
|
|
|
const status = formData.get('status');
|
|
if (status !== null) {
|
|
const statusStr = status.toString();
|
|
if (statusStr !== 'open' && statusStr !== 'done' && statusStr !== 'archived') {
|
|
return fail(400, { error: 'Invalid status' });
|
|
}
|
|
updates.status = statusStr;
|
|
}
|
|
|
|
entryRepository.update(id, updates);
|
|
|
|
return { success: true };
|
|
},
|
|
|
|
delete: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
entryRepository.delete(id);
|
|
|
|
return { success: true };
|
|
},
|
|
|
|
toggleComplete: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
// Toggle status between 'open' and 'done'
|
|
const newStatus = existing.status === 'done' ? 'open' : 'done';
|
|
entryRepository.update(id, { status: newStatus });
|
|
|
|
return { success: true };
|
|
},
|
|
|
|
uploadImage: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const file = formData.get('image') as File;
|
|
const entryId = formData.get('entryId') as string;
|
|
|
|
if (!file || file.size === 0) {
|
|
return fail(400, { error: 'No file uploaded' });
|
|
}
|
|
|
|
if (!entryId) {
|
|
return fail(400, { error: 'Entry ID required' });
|
|
}
|
|
|
|
// Verify entry exists
|
|
const entry = entryRepository.getById(entryId);
|
|
if (!entry) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
// Validate image type
|
|
if (!file.type.startsWith('image/')) {
|
|
return fail(400, { error: 'File must be an image' });
|
|
}
|
|
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
|
|
try {
|
|
await ensureDirectories();
|
|
|
|
// Generate and save thumbnail
|
|
const thumbnailBuffer = await generateThumbnail(buffer);
|
|
|
|
// Save to database to get ID
|
|
const image = imageRepository.create({
|
|
entryId,
|
|
filename: file.name,
|
|
ext
|
|
});
|
|
|
|
// Save original
|
|
await saveOriginal(image.id, ext, buffer);
|
|
|
|
// Save thumbnail
|
|
await saveThumbnail(image.id, thumbnailBuffer);
|
|
|
|
return { success: true, imageId: image.id };
|
|
} catch (err) {
|
|
console.error('Upload error:', err);
|
|
return fail(500, { error: 'Failed to save image' });
|
|
}
|
|
},
|
|
|
|
deleteImage: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const imageId = formData.get('imageId')?.toString();
|
|
|
|
if (!imageId) {
|
|
return fail(400, { error: 'Image ID required' });
|
|
}
|
|
|
|
// Get image to find extension for file deletion
|
|
const image = imageRepository.getById(imageId);
|
|
if (!image) {
|
|
return fail(404, { error: 'Image not found' });
|
|
}
|
|
|
|
// Delete files from filesystem
|
|
try {
|
|
await deleteImageFile(imageId, image.ext);
|
|
} catch (err) {
|
|
console.error('Failed to delete image files:', err);
|
|
// Continue to delete from database even if files missing
|
|
}
|
|
|
|
// Delete from database
|
|
imageRepository.delete(imageId);
|
|
|
|
return { success: true };
|
|
},
|
|
|
|
togglePin: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
// Toggle pinned state
|
|
entryRepository.update(id, { pinned: !existing.pinned });
|
|
|
|
return { success: true };
|
|
},
|
|
|
|
updateDueDate: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
const dueDate = formData.get('dueDate')?.toString() || null;
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
// Update due date (empty string becomes null)
|
|
entryRepository.update(id, { dueDate: dueDate || null });
|
|
|
|
return { success: true };
|
|
},
|
|
|
|
updateTags: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
const tagsJson = formData.get('tags')?.toString() || '[]';
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
try {
|
|
const tagNames = JSON.parse(tagsJson) as string[];
|
|
tagRepository.updateEntryTags(id, tagNames);
|
|
return { success: true };
|
|
} catch {
|
|
return fail(400, { error: 'Invalid tags format' });
|
|
}
|
|
},
|
|
|
|
parseTags: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
const content = formData.get('content')?.toString();
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
// Parse hashtags from provided content (or fall back to database content)
|
|
const textToParse = content ?? existing.content;
|
|
const newTags = parseHashtags(textToParse);
|
|
const existingTags = tagRepository.getByEntryId(id).map((t) => t.name.toLowerCase());
|
|
const allTags = [...new Set([...existingTags, ...newTags])];
|
|
tagRepository.updateEntryTags(id, allTags);
|
|
|
|
return { success: true };
|
|
},
|
|
|
|
removeTag: async ({ request }) => {
|
|
const formData = await request.formData();
|
|
const id = formData.get('id')?.toString();
|
|
const tagName = formData.get('tagName')?.toString();
|
|
|
|
if (!id) {
|
|
return fail(400, { error: 'Entry ID is required' });
|
|
}
|
|
|
|
if (!tagName) {
|
|
return fail(400, { error: 'Tag name is required' });
|
|
}
|
|
|
|
const existing = entryRepository.getById(id);
|
|
if (!existing) {
|
|
return fail(404, { error: 'Entry not found' });
|
|
}
|
|
|
|
// Get current tags and remove the specified one
|
|
const currentTags = tagRepository.getByEntryId(id).map((t) => t.name);
|
|
const updatedTags = currentTags.filter(
|
|
(t) => t.toLowerCase() !== tagName.toLowerCase()
|
|
);
|
|
tagRepository.updateEntryTags(id, updatedTags);
|
|
|
|
// Clean up orphaned tags
|
|
tagRepository.cleanupOrphanedTags();
|
|
|
|
return { success: true };
|
|
}
|
|
};
|