update from mon table to hirarch. table

This commit is contained in:
Your Name 2025-03-26 00:12:03 +01:00
parent 3228f0033b
commit fc29ee95df
10 changed files with 867 additions and 390 deletions

5
api/drizzle.config.ts Normal file
View File

@ -0,0 +1,5 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql', // 'mysql' | 'sqlite' | 'turso'
schema: './src/db/schema.ts',
});

View File

@ -1,3 +1,4 @@
import { relations } from 'drizzle-orm';
import * as t from 'drizzle-orm/pg-core'; import * as t from 'drizzle-orm/pg-core';
import { pgEnum, pgTable as table } from 'drizzle-orm/pg-core'; import { pgEnum, pgTable as table } from 'drizzle-orm/pg-core';
@ -24,10 +25,75 @@ export const deck = table(
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(), inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(), updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
}, },
table => [t.uniqueIndex('deck_idx').on(table.id)], (table) => [t.uniqueIndex('deck_idx').on(table.id)],
); );
export type InsertDeck = typeof deck.$inferInsert; export type InsertDeck = typeof deck.$inferInsert;
export type SelectDeck = typeof deck.$inferSelect; export type SelectDeck = typeof deck.$inferSelect;
export const decks_table = table('decks', {
id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(),
name: t.varchar('name').notNull(),
boxOrder: t.varchar('box_order').notNull().default('shuffle'),
user: t.varchar('user').notNull(),
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
});
export type InsertDecks = typeof decks_table.$inferInsert;
export type SelectDecks = typeof decks_table.$inferSelect;
export const images_table = table('images', {
id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(),
deckId: t.integer('deck_id').references(() => decks_table.id),
name: t.varchar('name').notNull(),
bildid: t.varchar('bildid').notNull(),
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
});
export type InsertImages = typeof images_table.$inferInsert;
export type SelectImages = typeof images_table.$inferSelect;
export const boxes_table = table('boxes', {
id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(),
imageId: t.integer('image_id').references(() => images_table.id),
x1: t.real('x1').notNull(),
x2: t.real('x2').notNull(),
y1: t.real('y1').notNull(),
y2: t.real('y2').notNull(),
due: t.integer('due'),
ivl: t.real('ivl'),
factor: t.real('factor'),
reps: t.integer('reps'),
lapses: t.integer('lapses'),
isGraduated: t.integer('is_graduated').notNull().default(0),
inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(),
updated: t.timestamp('updated', { mode: 'date' }).defaultNow(),
});
export type InsertBoxes = typeof boxes_table.$inferInsert;
export type SelectBoxes = typeof boxes_table.$inferSelect;
// Relations (optional, aber hilfreich für Abfragen)
export const decksRelations = relations(decks_table, ({ many }) => ({
images: many(images_table),
}));
export const imagesRelations = relations(images_table, ({ one, many }) => ({
deck: one(decks_table, {
fields: [images_table.deckId],
references: [decks_table.id],
}),
boxes: many(boxes_table),
}));
export const boxesRelations = relations(boxes_table, ({ one }) => ({
image: one(images_table, {
fields: [boxes_table.imageId],
references: [images_table.id],
}),
}));
// -------------------------------------
// USERS
// -------------------------------------
export const users = table( export const users = table(
'users', 'users',
{ {
@ -39,7 +105,7 @@ export const users = table(
lastLogin: t.timestamp('lastLogin', { mode: 'date' }).defaultNow(), lastLogin: t.timestamp('lastLogin', { mode: 'date' }).defaultNow(),
numberOfLogins: t.integer('numberOfLogins').default(1), // Neue Spalte numberOfLogins: t.integer('numberOfLogins').default(1), // Neue Spalte
}, },
table => [t.uniqueIndex('users_idx').on(table.id)], (table) => [t.uniqueIndex('users_idx').on(table.id)],
); );
export type InsertUser = typeof users.$inferInsert; export type InsertUser = typeof users.$inferInsert;
export type SelectUser = typeof users.$inferSelect; export type SelectUser = typeof users.$inferSelect;

View File

@ -25,36 +25,7 @@ export class DecksController {
async getDecks(@Request() req) { async getDecks(@Request() req) {
const user: User = req['user']; const user: User = req['user'];
const entries = await this.drizzleService.getDecks(user); const entries = await this.drizzleService.getDecks(user);
const decks = {}; return entries;
for (const entry of entries) {
const deckname = entry.deckname!!;
if (!decks[deckname]) {
decks[deckname] = {
name: deckname,
images: [],
};
}
if (entry.bildname && entry.bildid) {
decks[deckname].images.push({
name: entry.bildname,
bildid: entry.bildid,
id: entry.id,
x1: entry.x1,
x2: entry.x2,
y1: entry.y1,
y2: entry.y2,
due: entry.due,
ivl: entry.ivl,
factor: entry.factor,
reps: entry.reps,
lapses: entry.lapses,
isGraduated: Boolean(entry.isGraduated),
inserted: new Date(entry.inserted!!),
updated: new Date(entry.updated!!),
});
}
}
return Object.values(decks);
} }
@Post() @Post()
@ -66,43 +37,6 @@ export class DecksController {
return this.drizzleService.createDeck(data.deckname, user); return this.drizzleService.createDeck(data.deckname, user);
} }
@Get(':deckname')
async getDeck(@Request() req, @Param('deckname') deckname: string) {
const user: User = req['user'];
const entries = await this.drizzleService.getDeckByName(deckname, user);
if (entries.length === 0) {
throw new HttpException('Deck not found', HttpStatus.NOT_FOUND);
}
const deck = {
name: deckname,
images: [] as any,
};
for (const entry of entries) {
if (entry.bildname && entry.bildid) {
deck.images.push({
name: entry.bildname,
bildid: entry.bildid,
id: entry.id,
x1: entry.x1,
x2: entry.x2,
y1: entry.y1,
y2: entry.y2,
due: entry.due,
ivl: entry.ivl,
factor: entry.factor,
reps: entry.reps,
lapses: entry.lapses,
isGraduated: Boolean(entry.isGraduated),
inserted: new Date(entry.inserted!!),
updated: new Date(entry.updated!!),
});
}
}
return deck;
}
@Delete(':deckname') @Delete(':deckname')
async deleteDeck(@Request() req, @Param('deckname') deckname: string) { async deleteDeck(@Request() req, @Param('deckname') deckname: string) {
const user: User = req['user']; const user: User = req['user'];
@ -122,9 +56,21 @@ export class DecksController {
); );
} }
const user: User = req['user']; const user: User = req['user'];
return this.drizzleService.renameDeck(oldDeckname, data.newDeckName, user); return this.drizzleService.updateDeck(
oldDeckname,
{ newName: data.newDeckName },
user,
);
}
@Put(':oldDeckname/update')
async updateDeck(
@Request() req,
@Param('oldDeckname') oldDeckname: string,
@Body() data: { newDeckName: string; boxOrder?: 'shuffle' | 'position' },
) {
const user: User = req['user'];
return this.drizzleService.updateDeck(oldDeckname, data, user);
} }
@Post('image') @Post('image')
async updateImage(@Request() req, @Body() data: any) { async updateImage(@Request() req, @Body() data: any) {
if (!data) { if (!data) {

File diff suppressed because it is too large Load Diff

View File

@ -76,58 +76,58 @@ export class ProxyController {
// -------------------- // --------------------
// Cleanup Endpoint // Cleanup Endpoint
// -------------------- // --------------------
@Post('cleanup') // @Post('cleanup')
async cleanupEndpoint( // async cleanupEndpoint(
@Body() data: { dryrun?: boolean }, // @Body() data: { dryrun?: boolean },
@Res() res: express.Response, // @Res() res: express.Response,
) { // ) {
try { // try {
const user = res.req['user']; // Benutzerinformationen aus dem Request // const user = res.req['user']; // Benutzerinformationen aus dem Request
// 2. Nur Benutzer mit der spezifischen E-Mail dürfen fortfahren // // 2. Nur Benutzer mit der spezifischen E-Mail dürfen fortfahren
if (user.email !== 'andreas.knuth@gmail.com') { // if (user.email !== 'andreas.knuth@gmail.com') {
throw new HttpException('Zugriff verweigert.', HttpStatus.FORBIDDEN); // throw new HttpException('Zugriff verweigert.', HttpStatus.FORBIDDEN);
} // }
// 1. Abrufen der distinct bildid aus der Datenbank // // 1. Abrufen der distinct bildid aus der Datenbank
const usedIds = await this.drizzleService.getDistinctBildIds(user); // const usedIds = await this.drizzleService.getDistinctBildIds(user);
// 3. Verarbeitung des dryrun Parameters // // 3. Verarbeitung des dryrun Parameters
const dryrun = data.dryrun !== undefined ? data.dryrun : true; // const dryrun = data.dryrun !== undefined ? data.dryrun : true;
if (typeof dryrun !== 'boolean') { // if (typeof dryrun !== 'boolean') {
throw new HttpException( // throw new HttpException(
"'dryrun' muss ein boolescher Wert sein.", // "'dryrun' muss ein boolescher Wert sein.",
HttpStatus.BAD_REQUEST, // HttpStatus.BAD_REQUEST,
); // );
} // }
// 4. Aufruf des Flask-Backend-Endpunkts // // 4. Aufruf des Flask-Backend-Endpunkts
const response = await fetch('http://localhost:5000/api/cleanup', { // const response = await fetch('http://localhost:5000/api/cleanup', {
method: 'POST', // method: 'POST',
headers: { // headers: {
'Content-Type': 'application/json', // 'Content-Type': 'application/json',
}, // },
body: JSON.stringify({ dryrun, usedIds }), // body: JSON.stringify({ dryrun, usedIds }),
}); // });
const result = await response.json(); // const result = await response.json();
if (!response.ok) { // if (!response.ok) {
throw new HttpException( // throw new HttpException(
result.error || 'Cleanup failed', // result.error || 'Cleanup failed',
response.status, // response.status,
); // );
} // }
// 5. Rückgabe der Ergebnisse an den Client // // 5. Rückgabe der Ergebnisse an den Client
return res.status(HttpStatus.OK).json(result); // return res.status(HttpStatus.OK).json(result);
} catch (error) { // } catch (error) {
if (error instanceof HttpException) { // if (error instanceof HttpException) {
throw error; // throw error;
} // }
throw new HttpException( // throw new HttpException(
'Interner Serverfehler', // 'Interner Serverfehler',
HttpStatus.INTERNAL_SERVER_ERROR, // HttpStatus.INTERNAL_SERVER_ERROR,
); // );
} // }
} // }
} }

View File

@ -1,6 +1,6 @@
<div class="flex flex-col"> <div class="flex flex-col">
<!-- Two-column layout --> <!-- Two-column layout -->
<div *ngIf="!trainingsDeck" class="flex flex-col md:flex-row gap-4 mx-auto max-w-5xl"> <div *ngIf="!trainingsDeck" class="flex flex-col md:flex-row gap-4 mx-auto max-w-6xl">
<!-- Left column: List of decks --> <!-- Left column: List of decks -->
<div class="w-auto"> <div class="w-auto">
<div class="bg-white shadow rounded-lg p-4"> <div class="bg-white shadow rounded-lg p-4">
@ -47,7 +47,20 @@
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<h2 class="text-xl font-semibold">{{ activeDeck.name }}</h2> <h2 class="text-xl font-semibold">{{ activeDeck.name }}</h2>
<span class="text-gray-600">({{ activeDeck.images.length }} images)</span> <!-- <span class="text-gray-600">({{ activeDeck.images.length }} images)</span> -->
<div class="flex items-center mx-2">
<span class="text-sm mr-2">Shuffle</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" [ngModel]="activeDeck.boxOrder === 'position'" (ngModelChange)="changeBoxPosition($event)" />
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"
></div>
<span class="ml-2 text-sm font-medium">
<!-- {{ activeDeck.boxOrder === 'shuffle' ? 'Shuffle' : 'Position' }} -->
Position
</span>
</label>
</div>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button (click)="openDeletePopover(activeDeck.name)" class="text-red-500 hover:text-red-700" title="Delete Deck"> <button (click)="openDeletePopover(activeDeck.name)" class="text-red-500 hover:text-red-700" title="Delete Deck">
@ -62,7 +75,7 @@
</button> </button>
</div> </div>
</div> </div>
<!-- <span class="text-gray-600">({{ activeDeck.images.length }} images)</span> -->
<!-- Image list --> <!-- Image list -->
<ul class="mb-4"> <ul class="mb-4">
<li *ngFor="let image of activeDeck.images" class="flex justify-between items-center py-2 border-b last:border-b-0"> <li *ngFor="let image of activeDeck.images" class="flex justify-between items-center py-2 border-b last:border-b-0">
@ -138,7 +151,7 @@
<!-- <app-upload-image-modal (imageUploaded)="onImageUploaded($event)"></app-upload-image-modal> --> <!-- <app-upload-image-modal (imageUploaded)="onImageUploaded($event)"></app-upload-image-modal> -->
<app-edit-image-modal *ngIf="imageData" [deckName]="activeDeck.name" [imageData]="imageData" (imageSaved)="onImageSaved()" (closed)="onClosed()"></app-edit-image-modal> <app-edit-image-modal *ngIf="imageData" [deckName]="activeDeck.name" [imageData]="imageData" (imageSaved)="onImageSaved()" (closed)="onClosed()"></app-edit-image-modal>
<!-- TrainingComponent --> <!-- TrainingComponent -->
<app-training *ngIf="trainingsDeck" [deck]="trainingsDeck" (close)="closeTraining()"></app-training> <app-training *ngIf="trainingsDeck" [deck]="trainingsDeck" [boxOrder]="trainingsDeck.boxOrder" (close)="closeTraining()"></app-training>
<!-- MoveImageModalComponent --> <!-- MoveImageModalComponent -->
<app-move-image-modal *ngIf="imageToMove" [image]="imageToMove.image" [sourceDeck]="imageToMove.sourceDeck" [decks]="decks" (moveCompleted)="onImageMoved()" (closed)="imageToMove = null"> </app-move-image-modal> <app-move-image-modal *ngIf="imageToMove" [image]="imageToMove.image" [sourceDeck]="imageToMove.sourceDeck" [decks]="decks" (moveCompleted)="onImageMoved()" (closed)="imageToMove = null"> </app-move-image-modal>
</div> </div>

View File

@ -230,7 +230,15 @@ export class DeckListComponent implements OnInit {
this.trainingsDeck = null; this.trainingsDeck = null;
this.loadDecks(); this.loadDecks();
} }
async changeBoxPosition(val: boolean) {
this.activeDeck.boxOrder = val ? 'position' : 'shuffle';
this.deckService.updateDeck(this.activeDeck.name, { boxOrder: this.activeDeck.boxOrder }).subscribe({
next: () => {
this.loadDecks();
},
error: err => console.error('Error renaming image', err),
});
}
// Method to open the create deck modal // Method to open the create deck modal
openCreateDeckModal(): void { openCreateDeckModal(): void {
this.createDeckModal.open(); this.createDeckModal.open();
@ -502,7 +510,7 @@ export class DeckListComponent implements OnInit {
//const futureDueDates = dueDates.filter(date => date && date >= now); //const futureDueDates = dueDates.filter(date => date && date >= now);
if (dueDates.length > 0) { if (dueDates.length > 0) {
const nextDate = dueDates.reduce((a, b) => (a < b ? a : b)); const nextDate = dueDates.reduce((a, b) => (a < b ? a : b));
return nextDate<today?today:nextDate; return nextDate < today ? today : nextDate;
} }
return today; return today;
} }
@ -514,7 +522,7 @@ export class DeckListComponent implements OnInit {
getWordsToReview(deck: Deck): number { getWordsToReview(deck: Deck): number {
const nextTraining = this.getNextTrainingDate(deck); const nextTraining = this.getNextTrainingDate(deck);
const today = this.getTodayInDays(); const today = this.getTodayInDays();
return deck.images.flatMap(image => image.boxes.map(box => (box.due ? box.due : null))).filter(e=>e<=nextTraining).length; return deck.images.flatMap(image => image.boxes.map(box => (box.due ? box.due : null))).filter(e => e <= nextTraining).length;
} }
getTodayInDays(): number { getTodayInDays(): number {
const epoch = new Date(1970, 0, 1); // Anki uses UNIX epoch const epoch = new Date(1970, 0, 1); // Anki uses UNIX epoch

View File

@ -1,9 +1,10 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { map, Observable } from 'rxjs'; import { Observable } from 'rxjs';
export interface Deck { export interface Deck {
name: string; name: string;
boxOrder: 'shuffle' | 'position';
images: DeckImage[]; images: DeckImage[];
} }
@ -62,14 +63,7 @@ export class DeckService {
constructor(private http: HttpClient) {} constructor(private http: HttpClient) {}
getDecks(): Observable<Deck[]> { getDecks(): Observable<Deck[]> {
return this.http.get<any[]>(this.apiUrl).pipe( return this.http.get<any[]>(this.apiUrl);
map(decks =>
decks.map(deck => ({
name: deck.name,
images: this.groupImagesByName(deck.images),
})),
),
);
} }
private groupImagesByName(images: any[]): DeckImage[] { private groupImagesByName(images: any[]): DeckImage[] {
@ -103,9 +97,9 @@ export class DeckService {
return Object.values(imageMap); return Object.values(imageMap);
} }
getDeck(deckname: string): Observable<Deck> { // getDeck(deckname: string): Observable<Deck> {
return this.http.get<Deck>(`${this.apiUrl}/${deckname}/images`); // return this.http.get<Deck>(`${this.apiUrl}/${deckname}/images`);
} // }
createDeck(deckname: string): Observable<any> { createDeck(deckname: string): Observable<any> {
return this.http.post(this.apiUrl, { deckname }); return this.http.post(this.apiUrl, { deckname });
@ -117,6 +111,9 @@ export class DeckService {
renameDeck(oldDeckName: string, newDeckName: string): Observable<any> { renameDeck(oldDeckName: string, newDeckName: string): Observable<any> {
return this.http.put(`${this.apiUrl}/${encodeURIComponent(oldDeckName)}/rename`, { newDeckName }); return this.http.put(`${this.apiUrl}/${encodeURIComponent(oldDeckName)}/rename`, { newDeckName });
} }
updateDeck(oldDeckName: string, updateData: { newName?: string; boxOrder?: 'shuffle' | 'position' }): Observable<any> {
return this.http.put(`${this.apiUrl}/${encodeURIComponent(oldDeckName)}/update`, updateData);
}
renameImage(bildid: string, newImageName: string): Observable<any> { renameImage(bildid: string, newImageName: string): Observable<any> {
return this.http.put(`${this.apiUrl}/image/${encodeURIComponent(bildid)}/rename`, { newImageName }); return this.http.put(`${this.apiUrl}/image/${encodeURIComponent(bildid)}/rename`, { newImageName });
} }

View File

@ -1,7 +1,7 @@
<div class="mt-10 mx-auto max-w-5xl"> <div class="mt-10 mx-auto max-w-5xl">
<h2 class="text-2xl font-bold mb-4">Training: {{ deck.name }}</h2> <h2 class="text-2xl font-bold mb-4">Training: {{ deck.name }}</h2>
<div class="rounded-lg p-6 flex flex-col items-center"> <div class="rounded-lg p-6 flex flex-col items-center">
<canvas #canvas class="mb-4 border max-h-[50vh]"></canvas> <canvas #canvas class="mb-4 border max-h-[70vh]"></canvas>
<div class="flex space-x-4 mb-4"> <div class="flex space-x-4 mb-4">
<!-- Show Button --> <!-- Show Button -->

View File

@ -25,6 +25,7 @@ const EASY_INTERVAL = 4 * 1440; // 4 days in minutes
}) })
export class TrainingComponent implements OnInit { export class TrainingComponent implements OnInit {
@Input() deck!: Deck; @Input() deck!: Deck;
@Input() boxOrder: 'shuffle' | 'position' = 'shuffle'; // Standardmäßig shuffle
@Output() close = new EventEmitter<void>(); @Output() close = new EventEmitter<void>();
@ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>; @ViewChild('canvas', { static: false }) canvasRef!: ElementRef<HTMLCanvasElement>;
@ -92,8 +93,13 @@ export class TrainingComponent implements OnInit {
return; return;
} }
// Shuffle the boxes randomly // Order the boxes based on the input parameter
if (this.boxOrder === 'position') {
this.boxesToReview = this.sortBoxesByPosition(this.boxesToReview);
} else {
// Default: shuffle
this.boxesToReview = this.shuffleArray(this.boxesToReview); this.boxesToReview = this.shuffleArray(this.boxesToReview);
}
// Initialize the array to track revealed boxes // Initialize the array to track revealed boxes
this.boxRevealed = new Array(this.boxesToReview.length).fill(false); this.boxRevealed = new Array(this.boxesToReview.length).fill(false);
@ -165,7 +171,43 @@ export class TrainingComponent implements OnInit {
this.close.emit(); this.close.emit();
}; };
} }
/**
* Sorts boxes by their position (left to right, top to bottom)
*/
/**
* Sorts boxes by their position (left to right, top to bottom) with better row detection
*/
sortBoxesByPosition(boxes: Box[]): Box[] {
// First create a copy of the array
const boxesCopy = [...boxes];
// Determine the average box height to use for row grouping tolerance
const avgHeight = boxesCopy.reduce((sum, box) => sum + (box.y2 - box.y1), 0) / boxesCopy.length;
const rowTolerance = avgHeight * 0.4; // 40% of average box height as tolerance
// Group boxes into rows
const rows: Box[][] = [];
boxesCopy.forEach(box => {
// Find an existing row within tolerance
const row = rows.find(r => Math.abs(r[0].y1 - box.y1) < rowTolerance || Math.abs(r[0].y2 - box.y2) < rowTolerance);
if (row) {
row.push(box);
} else {
rows.push([box]);
}
});
// Sort each row by x1 (left to right)
rows.forEach(row => row.sort((a, b) => a.x1 - b.x1));
// Sort rows by y1 (top to bottom)
rows.sort((a, b) => a[0].y1 - b[0].y1);
// Flatten the rows into a single array
return rows.flat();
}
/** /**
* Shuffles an array randomly. * Shuffles an array randomly.
* @param array The array to shuffle * @param array The array to shuffle