Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,16 @@
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"git.ignoreLimitWarning": true
"git.ignoreLimitWarning": true,
// Source - https://stackoverflow.com/a/70257568
// Posted by wagonaf979
// Retrieved 2026-06-19, License - CC BY-SA 4.0

"emmet.includeLanguages": {
"javascript": "javascriptreact",
"typescript": "typescriptreact"
},
"emmet.triggerExpansionOnTab": true,
"editor.tabCompletion": "on",
"editor.snippetSuggestions": "top"
}
2 changes: 2 additions & 0 deletions backend/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ services:
CHOKIDAR_INTERVAL: "500"
TSC_WATCHFILE: FixedPollingInterval
TSC_WATCHDIRECTORY: FixedPollingInterval
ANKI_HOST: ${ANKI_HOST}
ANKI_PORT: ${ANKI_PORT}
volumes:
- ./:/app
- /app/node_modules
Expand Down
92 changes: 92 additions & 0 deletions backend/openapi.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
{
"name": "Words",
"description": "Word list lookup endpoints"
},
{
"name": "Anki",
"description": "Endpoints that proxy AnkiConnect deck data"
}
],
"components": {
Expand Down Expand Up @@ -401,6 +405,94 @@
}
},
"paths": {
"/api/anki/decks": {
"get": {
"operationId": "getAnkiDecks",
"tags": [
"Anki"
],
"summary": "List available Anki deck names from AnkiConnect",
"responses": {
"200": {
"description": "List of Anki deck names",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "AnkiConnect is unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/anki/modelNames": {
"get": {
"operationId": "getAnkiModelNames",
"tags": [
"Anki"
],
"summary": "List available Anki model names from AnkiConnect",
"responses": {
"200": {
"description": "List of Anki model names",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "AnkiConnect is unavailable",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/auth/register": {
"post": {
"operationId": "registerUser",
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import express, {
} from "express";
import cors from "cors";

import { router as ankiRouter } from "./routes/anki.js";
import { router as authRouter } from "./routes/auth.js";
import { router as docsRouter } from "./routes/docs.js";
import { router as meRouter } from "./routes/me.js";
Expand Down Expand Up @@ -47,6 +48,7 @@ export const createApp = () => {
app.use("/api/me", meRouter);
app.use("/api/words", wordsRouter);
app.use("/api/auth", authRouter);
app.use("/api/anki", ankiRouter);

// If any other URL is requested, return a 404 error because the only routes in this application are the ones listed
app.use((req, res) => {
Expand Down
26 changes: 26 additions & 0 deletions backend/src/routes/anki.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Router } from "express";
import { getDeckNames, getModelNames } from "./ankiConnect.js";

export const router = Router();

router.get("/decks", async (_req, res) => {
void _req;
try {
const decks = await getDeckNames();
res.json(decks);
} catch (error) {
console.error("Error fetching decks:", error);
res.status(502).json({ error: "Failed to reach AnkiConnect" });
}
});

router.get("/modelNames", async (_req, res) => {
void _req;
try {
const decks = await getModelNames();
res.json(decks);
} catch (error) {
console.error("Error fetching model names:", error);
res.status(502).json({ error: "Failed to reach AnkiConnect" });
}
});
32 changes: 32 additions & 0 deletions backend/src/routes/ankiConnect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const ANKI_HOST = process.env.ANKI_HOST ?? "127.0.0.1";
const ANKI_PORT = process.env.ANKI_PORT ?? "8765";
const url = `http://${ANKI_HOST}:${ANKI_PORT}`;

type AnkiResponse<T> = {
result: T;
error: string | null;
};

async function invokeAnki<T>(action: string, params = {}): Promise<T> {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, version: 5, params }),
});
const data = (await res.json()) as AnkiResponse<T>;
if (data.error) throw new Error(data.error);
return data.result;
} catch (err) {
console.error("AnkiConnect request failed:", { url, err });
throw err;
}
}

export async function getDeckNames(): Promise<string[]> {
return invokeAnki<string[]>("deckNames");
}

export async function getModelNames(): Promise<string[]> {
return invokeAnki<string[]>("modelNames");
}
66 changes: 66 additions & 0 deletions backend/src/routes/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export const createOpenApiDocument = () => {
name: "Words",
description: "Word list lookup endpoints",
},
{
name: "Anki",
description: "Endpoints that proxy AnkiConnect deck data",
},
],
components: {
securitySchemes: {
Expand Down Expand Up @@ -368,6 +372,66 @@ export const createOpenApiDocument = () => {
},
},
paths: {
"/api/anki/decks": {
get: {
operationId: "getAnkiDecks",
tags: ["Anki"],
summary: "List available Anki deck names from AnkiConnect",
responses: {
"200": {
description: "List of Anki deck names",
content: {
"application/json": {
schema: {
type: "array",
items: {
type: "string",
},
},
},
},
},
"502": {
description: "AnkiConnect is unavailable",
content: jsonContent(schemaRef("ErrorResponse")),
},
"500": {
description: "Internal server error",
content: jsonContent(schemaRef("ErrorResponse")),
},
},
},
},
"/api/anki/modelNames": {
get: {
operationId: "getAnkiModelNames",
tags: ["Anki"],
summary: "List available Anki model names from AnkiConnect",
responses: {
"200": {
description: "List of Anki model names",
content: {
"application/json": {
schema: {
type: "array",
items: {
type: "string",
},
},
},
},
},
"502": {
description: "AnkiConnect is unavailable",
content: jsonContent(schemaRef("ErrorResponse")),
},
"500": {
description: "Internal server error",
content: jsonContent(schemaRef("ErrorResponse")),
},
},
},
},
"/api/auth/register": {
post: {
operationId: "registerUser",
Expand Down Expand Up @@ -691,9 +755,11 @@ const createSwaggerUiHtml = (openApiJsonUrl: string) => `<!DOCTYPE html>
export const router = Router();

router.get("/", (_req, res) => {
void _req;
res.type("html").send(createSwaggerUiHtml("/api/docs/openapi.json"));
});

router.get("/openapi.json", (_req, res) => {
void _req;
res.json(createOpenApiDocument());
});
67 changes: 47 additions & 20 deletions backend/src/routes/words.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ const getDetailsByValueRequestSchema = z.object({
language: z.enum(["de", "en"]),
});

const getAllWordsRequestSchema = z.object({
wordValueOnly: z.boolean().optional(),
limit: z.number().optional(),
});

const wordListSelect = {
id: true,
value: true,
Expand Down Expand Up @@ -336,9 +341,20 @@ const mapEnglishMatchesToGermanWordDetails = (
return sortWordDetailMatches(Array.from(germanWordsById.values()));
};

// GET /api/words/all
router.get("/all", async (_req, res) => {
void _req;
// GET /api/words/all?wordValueOnly={wordValueOnly}&limit={limit}
router.get("/all", async (req, res) => {
const parseResult = getAllWordsRequestSchema.safeParse({
wordValueOnly: req.query.wordValueOnly,
limit: req.query.limit,
});
if (!parseResult.success) {
const flattenedError = z.flattenError(parseResult.error);
return res.status(400).json({
formErrors: flattenedError.formErrors,
fieldErrors: flattenedError.fieldErrors,
});
}

const lang: Language | null = await prisma.language.findUnique({
where: { value: "de" },
});
Expand All @@ -348,29 +364,40 @@ router.get("/all", async (_req, res) => {
.json({ error: `Language 'de' not found in database` });
}

// add cursor pagination, based on the word frequency id.
const words = await prisma.word.findMany({
where: { languageId: lang.id },
orderBy: { frequencyRank: "asc" },
select: wordListSelect,
});

return res.json(
words.map((word) => {
const firstMeaning = word.meanings[0];
const firstTranslation = firstMeaning?.translations[0];

return {
id: word.id,
value: word.value,
languageId: word.languageId,
frequencyRank: word.frequencyRank,
partOfSpeech: firstMeaning?.partOfSpeech.value ?? null,
translation: firstTranslation?.toWord.value ?? null,
exampleBase: firstMeaning?.exampleBase ?? null,
exampleTarget: firstMeaning?.exampleTarget ?? null,
};
}),
);
if (req.query.wordValueOnly) {
return res.json(
words.map((word) => {
return {
value: word.value,
};
}),
);
} else {
return res.json(
words.map((word) => {
const firstMeaning = word.meanings[0];
const firstTranslation = firstMeaning?.translations[0];

return {
id: word.id,
value: word.value,
languageId: word.languageId,
frequencyRank: word.frequencyRank,
partOfSpeech: firstMeaning?.partOfSpeech.value ?? null,
translation: firstTranslation?.toWord.value ?? null,
exampleBase: firstMeaning?.exampleBase ?? null,
exampleTarget: firstMeaning?.exampleTarget ?? null,
};
}),
);
}
});

// GET /api/words?word={word}&language=de|en
Expand Down
Loading