Files
journaley/convex/entries.ts
T

320 lines
7.9 KiB
TypeScript

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