Files
taskplaner/src/routes/+page.server.ts
Thomas Richter c92aec14d3 feat: auto-cleanup orphaned tags when removed from entries
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>
2026-02-01 23:13:50 +01:00

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 };
}
};