320 lines
7.9 KiB
TypeScript
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);
|
|
}
|
|
});
|