import { v } from 'convex/values'; import { mutation, query } from './_generated/server.js'; import type { Doc, Id } from './_generated/dataModel.js'; import type { MutationCtx, QueryCtx } from './_generated/server.js'; import { buildSearchText, cleanTagName, markdownToPlainText, requireOwner, slugifyTag, tagColor } from './lib.js'; import { moodValidator } from './validators.js'; const sortValidator = v.union( v.literal('newest'), v.literal('oldest'), v.literal('updated'), v.literal('title') ); type EntryWithTags = Doc<'entries'> & { tags: Doc<'tags'>[]; }; async function ensureTags(ctx: MutationCtx, owner: string, tagNames: string[]) { const now = Date.now(); const uniqueNames = Array.from( new Map( tagNames .map((name) => cleanTagName(name)) .filter(Boolean) .map((name) => [slugifyTag(name), name]) ).entries() ).filter(([slug]) => slug.length > 0); const tags: Doc<'tags'>[] = []; for (const [slug, name] of uniqueNames) { const existing = await ctx.db .query('tags') .withIndex('by_owner_and_slug', (q) => q.eq('owner', owner).eq('slug', slug)) .unique(); if (existing) { tags.push(existing); continue; } const tagId = await ctx.db.insert('tags', { owner, name, slug, color: tagColor(slug), usageCount: 0, createdAt: now, updatedAt: now }); const created = await ctx.db.get(tagId); if (created) tags.push(created); } return tags; } async function applyEntryTags( ctx: MutationCtx, owner: string, entryId: Id<'entries'>, nextTags: Doc<'tags'>[] ) { const now = Date.now(); const existingLinks = await ctx.db .query('entryTags') .withIndex('by_entry', (q) => q.eq('entryId', entryId)) .take(100); const nextSlugs = new Set(nextTags.map((tag) => tag.slug)); const existingSlugs = new Set(existingLinks.map((link) => link.tagSlug)); for (const link of existingLinks) { if (!nextSlugs.has(link.tagSlug)) { await ctx.db.delete(link._id); const tag = await ctx.db.get(link.tagId); if (tag && tag.owner === owner) { await ctx.db.patch(tag._id, { usageCount: Math.max(0, tag.usageCount - 1), updatedAt: now }); } } } for (const tag of nextTags) { if (existingSlugs.has(tag.slug)) continue; await ctx.db.insert('entryTags', { owner, entryId, tagId: tag._id, tagSlug: tag.slug, createdAt: now }); await ctx.db.patch(tag._id, { usageCount: tag.usageCount + 1, updatedAt: now }); } } async function attachTags(ctx: QueryCtx, entries: Doc<'entries'>[]) { const withTags: EntryWithTags[] = []; for (const entry of entries) { const links = await ctx.db .query('entryTags') .withIndex('by_entry', (q) => q.eq('entryId', entry._id)) .take(40); const tags = []; for (const link of links) { const tag = await ctx.db.get(link.tagId); if (tag) tags.push(tag); } withTags.push({ ...entry, tags }); } return withTags; } export const list = query({ args: { search: v.optional(v.string()), mood: v.optional(moodValidator), tagSlugs: v.optional(v.array(v.string())), dateFrom: v.optional(v.string()), dateTo: v.optional(v.string()), includeArchived: v.optional(v.boolean()), sort: v.optional(sortValidator), limit: v.optional(v.number()) }, handler: async (ctx, args) => { const owner = await requireOwner(ctx); const limit = Math.min(args.limit ?? 100, 150); const sort = args.sort ?? 'newest'; const search = args.search?.trim(); let entries = search ? await ctx.db .query('entries') .withSearchIndex('search_entries', (q) => q.search('searchText', search).eq('owner', owner) ) .take(limit) : args.mood ? await ctx.db .query('entries') .withIndex('by_owner_and_mood_and_entryDate', (q) => q.eq('owner', owner).eq('mood', args.mood!) ) .order(sort === 'oldest' ? 'asc' : 'desc') .take(limit) : await ctx.db .query('entries') .withIndex('by_owner_and_entryDate', (q) => q.eq('owner', owner)) .order(sort === 'oldest' ? 'asc' : 'desc') .take(limit); const tagSlugs = args.tagSlugs ?? []; entries = entries.filter((entry) => { if (!args.includeArchived && entry.archived) return false; if (args.mood && entry.mood !== args.mood) return false; if (args.dateFrom && entry.entryDate < args.dateFrom) return false; if (args.dateTo && entry.entryDate > args.dateTo) return false; if (tagSlugs.length > 0 && !tagSlugs.every((slug) => entry.tagSlugs.includes(slug))) return false; return true; }); if (sort === 'updated') { entries = entries.sort((a, b) => b.updatedAt - a.updatedAt); } else if (sort === 'title') { entries = entries.sort((a, b) => a.title.localeCompare(b.title)); } return await attachTags(ctx, entries.slice(0, limit)); } }); export const recent = query({ args: { limit: v.optional(v.number()) }, handler: async (ctx, args) => { const owner = await requireOwner(ctx); const entries = await ctx.db .query('entries') .withIndex('by_owner_and_entryDate', (q) => q.eq('owner', owner)) .order('desc') .take(Math.min(args.limit ?? 6, 12)); return await attachTags( ctx, entries.filter((entry) => !entry.archived) ); } }); export const get = query({ args: { entryId: v.id('entries') }, handler: async (ctx, args) => { const owner = await requireOwner(ctx); const entry = await ctx.db.get(args.entryId); if (!entry || entry.owner !== owner) return null; const [withTags] = await attachTags(ctx, [entry]); return withTags; } }); export const create = mutation({ args: { title: v.string(), body: v.string(), mood: moodValidator, entryDate: v.string(), tagNames: v.array(v.string()), pinned: v.optional(v.boolean()) }, handler: async (ctx, args) => { const owner = await requireOwner(ctx); const tags = await ensureTags(ctx, owner, args.tagNames); const now = Date.now(); const plainText = markdownToPlainText(args.body); const entryId = await ctx.db.insert('entries', { owner, title: args.title.trim() || 'Untitled entry', body: args.body, plainText, searchText: buildSearchText({ title: args.title, body: args.body, mood: args.mood, tagNames: tags.map((tag) => tag.name) }), mood: args.mood, entryDate: args.entryDate, tagNames: tags.map((tag) => tag.name), tagSlugs: tags.map((tag) => tag.slug), pinned: args.pinned ?? false, archived: false, createdAt: now, updatedAt: now }); await applyEntryTags(ctx, owner, entryId, tags); return entryId; } }); export const update = mutation({ args: { entryId: v.id('entries'), title: v.string(), body: v.string(), mood: moodValidator, entryDate: v.string(), tagNames: v.array(v.string()), pinned: v.boolean(), archived: v.boolean() }, handler: async (ctx, args) => { const owner = await requireOwner(ctx); const entry = await ctx.db.get(args.entryId); if (!entry || entry.owner !== owner) { throw new Error('Unauthorized'); } const tags = await ensureTags(ctx, owner, args.tagNames); const plainText = markdownToPlainText(args.body); await ctx.db.patch(args.entryId, { title: args.title.trim() || 'Untitled entry', body: args.body, plainText, searchText: buildSearchText({ title: args.title, body: args.body, mood: args.mood, tagNames: tags.map((tag) => tag.name) }), mood: args.mood, entryDate: args.entryDate, tagNames: tags.map((tag) => tag.name), tagSlugs: tags.map((tag) => tag.slug), pinned: args.pinned, archived: args.archived, updatedAt: Date.now() }); await applyEntryTags(ctx, owner, args.entryId, tags); } }); export const remove = mutation({ args: { entryId: v.id('entries') }, handler: async (ctx, args) => { const owner = await requireOwner(ctx); const entry = await ctx.db.get(args.entryId); if (!entry || entry.owner !== owner) { throw new Error('Unauthorized'); } await applyEntryTags(ctx, owner, args.entryId, []); await ctx.db.delete(args.entryId); } });