Skip to content

Commit

Permalink
Merge pull request #5 from Shunseii/feat/flashcards
Browse files Browse the repository at this point in the history
Feat/flashcards
  • Loading branch information
Shunseii authored Jul 6, 2024
2 parents 4cdafa6 + 3b22f44 commit d29439b
Show file tree
Hide file tree
Showing 26 changed files with 6,842 additions and 4,806 deletions.
18 changes: 18 additions & 0 deletions .github/workflows/update-indexes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: Update Meilisearch Indexes for All Users

# This workflow updates the settings for all indexes for all users.

on: workflow_dispatch

jobs:
update-indexes:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3

- name: Update Meilisearch Indexes
run: ./scripts/update-indexes.sh
env:
MEILISEARCH_HOST: ${{ secrets.MEILISEARCH_HOST }}
MEILISEARCH_MASTER_KEY: ${{ secrets.MEILISEARCH_MASTER_KEY }}
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@upstash/ratelimit": "^1.2.0",
"@upstash/redis": "^1.28.4",
"ajv": "^8.16.0",
"ajv-formats": "^3.0.1",
"arctic": "^1.2.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
Expand All @@ -37,6 +38,7 @@
"meilisearch": "^0.40.0",
"multer": "1.4.5-lts.1",
"oslo": "^1.1.2",
"ts-fsrs": "^3.5.7",
"tsx": "^4.7.1",
"zod": "^3.22.4"
},
Expand Down
File renamed without changes.
27 changes: 27 additions & 0 deletions apps/api/src/clients/meilisearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MeiliSearch } from "meilisearch";

export const meilisearchClient = new MeiliSearch({
host: process.env.MEILISEARCH_HOST!,
apiKey: process.env.MEILISEARCH_API_KEY!,
});

/**
* Creates a new index for a user and configures it
* with the necessary universal settings.
*
* @param userId The user's id.
*/
export const createUserIndex = async (userId: string) => {
const { taskUid: createTaskUid } =
await meilisearchClient.createIndex(userId);

await meilisearchClient.waitForTask(createTaskUid);

const userIndex = meilisearchClient.index(userId);

const { taskUid: updateTaskUid } = await userIndex.updateSettings({
filterableAttributes: ["flashcard.due_timestamp"],
});

await meilisearchClient.waitForTask(updateTaskUid);
};
File renamed without changes.
2 changes: 2 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import cors from "cors";
import { userRouter } from "./routers/user";
import { authRouter } from "./routers/auth/github";
import { dictionaryRouter } from "./routers/dictionary";
import { flashcardRouter } from "./routers/flashcard";
import { trpcAuthRouter } from "./routers/auth";
import { csrf } from "./middleware";
import { getAllowedDomains } from "./utils";
Expand All @@ -31,6 +32,7 @@ const port = process.env.PORT;
const appRouter = router({
user: userRouter,
auth: trpcAuthRouter,
flashcard: flashcardRouter,
});

const app = express();
Expand Down
6 changes: 0 additions & 6 deletions apps/api/src/meilisearch.ts

This file was deleted.

6 changes: 2 additions & 4 deletions apps/api/src/routers/auth/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { db } from "../../db";
import { users } from "../../db/schema/users";
import { eq, or } from "drizzle-orm";
import { generateIdFromEntropySize } from "lucia";
import { meilisearchClient } from "../../meilisearch";
import { createUserIndex } from "../../clients/meilisearch";

interface GitHubUser {
id: number;
Expand Down Expand Up @@ -99,9 +99,7 @@ authRouter.get("/login/github/callback", async (req, res) => {
email: githubUser.email,
});

const { taskUid } = await meilisearchClient.createIndex(userId);

await meilisearchClient.waitForTask(taskUid);
await createUserIndex(userId);

const session = await lucia.createSession(userId, {} as any);

Expand Down
10 changes: 4 additions & 6 deletions apps/api/src/routers/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import {
} from "../../trpc";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { sendMail } from "../../mail";
import { redisClient } from "../../redis";
import { sendMail } from "../../clients/mail";
import { redisClient } from "../../clients/redis";
import { base64 } from "oslo/encoding";
import { OTP_VALID_PERIOD, generateOTP, verifyOTP } from "../../otp/totp";
import { meilisearchClient } from "../../meilisearch";
import { createUserIndex } from "../../clients/meilisearch";

/**
* A buffer added to the redis ttl for otp
Expand Down Expand Up @@ -253,9 +253,7 @@ export const trpcAuthRouter = trpcRouter({
email,
});

const { taskUid } = await meilisearchClient.createIndex(userId);

await meilisearchClient.waitForTask(taskUid);
await createUserIndex(userId);

const session = await lucia.createSession(userId, {} as any);

Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/routers/dictionary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import express, {
type NextFunction,
} from "express";
import Ajv from "ajv";
import addFormats from "ajv-formats";
import multer from "multer";

import schema from "../schema.json";
import { meilisearchClient } from "../meilisearch";
import { meilisearchClient } from "../clients/meilisearch";
import { auth } from "../middleware";
import { ErrorCode, MeilisearchError } from "../error";

Expand Down Expand Up @@ -54,6 +55,8 @@ dictionaryRouter.post(
uploadWithErrorHandling,
async (req, res) => {
const ajv = new Ajv({ allErrors: false });
addFormats(ajv, ["date-time"]);

const fileData = req.file?.buffer.toString("utf-8");

if (!fileData) {
Expand Down
117 changes: 117 additions & 0 deletions apps/api/src/routers/flashcard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { router, protectedProcedure } from "../trpc";
import { meilisearchClient } from "../clients/meilisearch";
import { Card, createEmptyCard } from "ts-fsrs";
import { z } from "zod";

export type Flashcard = Card & {
id: string;
content: string;
translation: string;
due: string;
last_review: string | null;
due_timestamp: number;
last_review_timestamp: number | null;
};

const FlashcardSchema = z.object({
id: z.string(),
content: z.string(),
translation: z.string(),
elapsed_days: z.number(),
lapses: z.number(),
reps: z.number(),
scheduled_days: z.number(),
stability: z.number(),
state: z.number(),
difficulty: z.number(),
due: z.string(),
due_timestamp: z.number(),
last_review: z.string().nullable(),
last_review_timestamp: z.number().nullable(),
});

export const flashcardRouter = router({
today: protectedProcedure
.output(
z.object({
flashcards: z.array(FlashcardSchema),
}),
)
.query(async ({ ctx }) => {
const { user } = ctx;

/**
* Current timestamp in seconds
*/
const now = Math.floor(new Date().getTime() / 1000);

const { results } = await meilisearchClient.index(user.id).getDocuments({
filter: [
`flashcard.due_timestamp NOT EXISTS OR flashcard.due_timestamp <= ${now}`,
],
limit: 1000,
});

const flashcards: Flashcard[] = results.map(
({ id, word, translation, flashcard }) => {
const card = flashcard ?? getEmptyFlashcard(id);

return {
id,
content: word,
translation,
...card,
};
},
);

return {
flashcards,
};
}),

update: protectedProcedure
.input(FlashcardSchema)
.mutation(async ({ ctx, input }) => {
const { user } = ctx;

const { id, ...flashcard } = input;

const { taskUid } = await meilisearchClient
.index(user.id)
.updateDocuments([
{
id,
flashcard,
},
]);

await meilisearchClient.index(user.id).waitForTask(taskUid);

return {
id,
...flashcard,
};
}),
});

const getEmptyFlashcard = (id: string): Flashcard => {
return createEmptyCard(new Date(), (c) => {
const due = new Date(c.due);
const last_review = c.last_review ? new Date(c.last_review) : null;

const due_timestamp = Math.floor(due.getTime() / 1000);
const last_review_timestamp = last_review
? Math.floor(last_review.getTime() / 1000)
: null;

return {
...c,
id,
due: due.toISOString(),
last_review: last_review?.toISOString() ?? null,
due_timestamp,
last_review_timestamp,
} as Flashcard;
});
};
74 changes: 74 additions & 0 deletions apps/api/src/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,80 @@
}
}
}
},
"flashcard": {
"type": "object",
"description": "Properties used for scheduling the flashcards associated with the word using FSRS. When adding new words, just set these to the default values. For updating existing words, leave them as-is to keep flashcard progress or set to default values to reset it.",
"properties": {
"difficulty": {
"type": "number",
"minimum": 0,
"default": 0
},
"due": {
"type": "string",
"format": "date-time",
"description": "The date when the card needs to be reviewed. Set to the current time for new cards."
},
"due_timestamp": {
"type": "integer",
"minimum": 0,
"description": "A UNIX timestamp in seconds that represents the same date as `due`."
},
"elapsed_days": {
"type": "integer",
"minimum": 0,
"default": 0
},
"lapses": {
"type": "integer",
"minimum": 0,
"default": 0
},
"last_review": {
"type": "string",
"format": "date-time",
"default": null
},
"last_review_timestamp": {
"type": "integer",
"minimum": 0,
"default": null,
"description": "A UNIX timestamp in seconds that represents the same date as `last_review`, if present."
},
"reps": {
"type": "integer",
"minimum": 0,
"default": 0
},
"scheduled_days": {
"type": "integer",
"minimum": 0,
"default": 0
},
"stability": {
"type": "number",
"minimum": 0,
"default": 0
},
"state": {
"type": "integer",
"enum": [0, 1, 2, 3],
"description": "0 is New, 1 is Learning, 2 is Review, and 3 is Relearning.",
"default": 0
}
},
"required": [
"difficulty",
"due",
"due_timestamp",
"elapsed_days",
"lapses",
"reps",
"scheduled_days",
"stability",
"state"
]
}
},
"required": ["id", "word", "translation"]
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { TRPCError, initTRPC } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";
import { validateRequest } from "./auth";
import { Ratelimit } from "@upstash/ratelimit";
import { redisClient } from "./redis";
import { redisClient } from "./clients/redis";

// Created for each request
export const createContext = async ({
Expand Down
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"ajv": "^8.16.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"date-fns": "^3.6.0",
"framer-motion": "^11.2.11",
"input-otp": "0.3.31-beta",
"jotai": "^2.7.0",
Expand All @@ -48,6 +49,8 @@
"react-instantsearch": "^7.7.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"ts-fsrs": "^3.5.7",
"vaul": "^0.9.1",
"zod": "^3.22.4"
},
"devDependencies": {
Expand Down
Loading

0 comments on commit d29439b

Please sign in to comment.