Some checks are pending
CI / validate (push) Waiting to run
Ω-1: Text-Only-Architektur ist scharfgestellt. Code-Cleanup: - 4 Components gelöscht: ImageOcclusionEditor, ImageOcclusionView, AudioFrontView, AudioUploadField - 3 API-Module gelöscht: routes/media.ts, services/storage.ts, db/schema/media.ts (mediaRefs + mediaFiles), routes/decks-from-image.ts - packages/cards-domain: image-occlusion.ts + Tests entfernt, CardTypeSchema reduziert auf basic/basic-reverse/cloze/typing/multiple-choice - 3 Web-Routes (study/[deckId], cards/new, cards/[id]/edit) bereinigt: Image-Occlusion- und Audio-Front-Code-Pfade raus - anki/import.ts text-only: kein Media-Upload mehr, img/sound werden ersatzlos gestrippt - 21 weitere Files bereinigt: dto, health, me, dsgvo, tools, cards, decks, share-handlers, marketplace/decks, marketplace/fork, marketplace/pull-requests, AnkiImport.svelte DB-Migrationen (noch nicht gerannt, idempotent): - 0004_wordeck_text_only.sql: DELETE image-occlusion/audio (0 betroffene Rows), media_files-Tabelle DROP, media_refs-Spalte DROP, CHECK cards.type IN (basic, basic-reverse, cloze, type-in, multiple-choice) - 0005_wordeck_license_rename.sql: Cardecky-Personal-Use-1.0 → Wordeck-Personal-Use-1.0, Cardecky-Pro-Only-1.0 → Wordeck-Pro-Only-1.0, Default + CHECK + Backfill Infrastruktur: - docker-compose.production.yml: cards-minio-Service raus, MinIO-Envs aus cards-api raus, CARDS_PUBLIC_URL + PUBLIC_CARDS_API_URL auf wordeck.com / api.wordeck.com - App-Manifest schon vorher auf wordeck umgestellt Type-Check grün (api, domain, web — alle 3 Sub-Pakete). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
4.3 KiB
TypeScript
147 lines
4.3 KiB
TypeScript
import { eq } from 'drizzle-orm';
|
|
import { Hono } from 'hono';
|
|
|
|
import {
|
|
CardsCreateInputSchema,
|
|
CardsSearchInputSchema,
|
|
cardContentHash,
|
|
subIndexCount,
|
|
subIndexCountForCloze,
|
|
} from '@cards/domain';
|
|
|
|
import { makeInitialReviewRows } from '../lib/reviews.ts';
|
|
|
|
import { getDb, type CardsDb } from '../db/connection.ts';
|
|
import { cards, decks, reviews } from '../db/schema/index.ts';
|
|
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
|
|
import { ulid } from '../lib/ulid.ts';
|
|
import { searchUserCards } from '../lib/search.ts';
|
|
|
|
const APP_BASE_URL = process.env.CARDS_PUBLIC_URL ?? 'https://wordeck.com';
|
|
const APP_VERSION = process.env.CARDS_API_VERSION ?? '0.0.0';
|
|
|
|
export type ToolsDeps = { db?: CardsDb };
|
|
|
|
/**
|
|
* Tool-Invoke-Endpoint für mana-mcp / Persona-Runner / Claude.
|
|
* Dispatch nach `:name`. Auth: User-JWT (X-User-Id-Header im Dev-Stub).
|
|
*
|
|
* Phase F-1: zusätzlich Service-Key-Pfad für mcp-getriggerte Calls
|
|
* mit user-on-behalf-of-Token.
|
|
*/
|
|
export function toolsRouter(deps: ToolsDeps = {}): Hono<{ Variables: AuthVars }> {
|
|
const r = new Hono<{ Variables: AuthVars }>();
|
|
const dbOf = () => deps.db ?? getDb();
|
|
|
|
r.use('*', authMiddleware);
|
|
|
|
r.post('/:name', async (c) => {
|
|
const userId = c.get('userId');
|
|
const name = c.req.param('name');
|
|
const body = await c.req.json().catch(() => null);
|
|
if (body == null) return c.json({ error: 'invalid_json' }, 400);
|
|
|
|
switch (name) {
|
|
case 'cards.create': {
|
|
const parsed = CardsCreateInputSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return c.json(
|
|
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
|
422
|
|
);
|
|
}
|
|
const [deck] = await dbOf()
|
|
.select({ id: decks.id, userId: decks.userId })
|
|
.from(decks)
|
|
.where(eq(decks.id, parsed.data.deck_id))
|
|
.limit(1);
|
|
if (!deck) return c.json({ error: 'deck_not_found' }, 404);
|
|
if (deck.userId !== userId) return c.json({ error: 'deck_not_owned' }, 403);
|
|
|
|
// Text-abhängige Sub-Index-Counts identisch zum REST-Pfad
|
|
// (cards.ts POST). Cloze ohne Cluster wird 422.
|
|
let count: number;
|
|
if (parsed.data.type === 'cloze') {
|
|
count = subIndexCountForCloze(parsed.data.fields.text ?? '');
|
|
if (count === 0) {
|
|
return c.json(
|
|
{ error: 'invalid_input', issues: ['cloze.text contains no {{cN::…}} clusters'] },
|
|
422
|
|
);
|
|
}
|
|
} else {
|
|
count = subIndexCount(parsed.data.type);
|
|
}
|
|
|
|
const cardId = ulid();
|
|
const now = new Date();
|
|
const contentHash = await cardContentHash({
|
|
type: parsed.data.type,
|
|
fields: parsed.data.fields,
|
|
});
|
|
const [row] = await dbOf().transaction(async (tx) => {
|
|
const [card] = await tx
|
|
.insert(cards)
|
|
.values({
|
|
id: cardId,
|
|
deckId: parsed.data.deck_id,
|
|
userId,
|
|
type: parsed.data.type,
|
|
fields: parsed.data.fields,
|
|
contentHash,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
const subIndices = Array.from({ length: count }, (_, i) => i);
|
|
const initial = makeInitialReviewRows({ userId, cardId, subIndices, now });
|
|
if (initial.length > 0) await tx.insert(reviews).values(initial);
|
|
return [card];
|
|
});
|
|
return c.json({
|
|
id: row.id,
|
|
deck_id: row.deckId,
|
|
user_id: row.userId,
|
|
type: row.type,
|
|
fields: row.fields,
|
|
content_hash: row.contentHash,
|
|
created_at: row.createdAt.toISOString(),
|
|
updated_at: row.updatedAt.toISOString(),
|
|
});
|
|
}
|
|
|
|
case 'cards.search': {
|
|
const parsed = CardsSearchInputSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return c.json(
|
|
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
|
|
422
|
|
);
|
|
}
|
|
const max = parsed.data.max_results ?? 30;
|
|
const { hits, tookMs } = await searchUserCards(dbOf(), userId, parsed.data.query, max);
|
|
return c.json({
|
|
query: parsed.data.query,
|
|
results: hits.map((h) => ({
|
|
id: h.id,
|
|
type: 'card' as const,
|
|
title: h.title,
|
|
snippet: h.snippet,
|
|
link: h.link,
|
|
score: h.score,
|
|
})),
|
|
total: hits.length,
|
|
took_ms: tookMs,
|
|
app: 'cards',
|
|
app_version: APP_VERSION,
|
|
base_url: APP_BASE_URL,
|
|
});
|
|
}
|
|
|
|
default:
|
|
return c.json({ error: 'unknown_tool', name }, 404);
|
|
}
|
|
});
|
|
|
|
return r;
|
|
}
|