Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ yarn-error.log*
.env
# Sentry Config File
.env.sentry-build-plugin
/tmp-assets
/tmp-assets
.codex
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ Follow existing TypeScript/TSX patterns and `.prettierrc`: four-space indentatio

Use PascalCase for components and classes, `useCamelCase` for hooks. Prefer configured `@tdev-*` import aliases and keep site-specific overrides in the appropriate `tdev-website/` directory.

New components should normally be put in an `index.tsx` alongside a `styles.module.css` file, where the folder is named after the component. Use `clsx` for conditional class names. Avoid inline styles and global CSS.

Use mobx for state management and only use local reactish component state for component-specific state that does not need to be shared or persisted after unmounting. Use `useStore` to access stores.

## Testing Guidelines

Use tests only for remark plugins or packages which are run from the cli. No tests for components, hooks, or stores. Use Vitest with descriptive `describe` and `it` blocks. Name tests `*.test.ts` under the relevant module's `tests/` directory. Plugin tests commonly use inline snapshots; review snapshot changes for intended output differences. No coverage threshold is configured.
Expand All @@ -41,3 +45,13 @@ In pull requests, describe the behavior change, link relevant issues, report val
## Configuration & Secrets

Use `.env.example` and the README to configure local settings. Keep credentials and tokens out of commits.

## Agent delegation

For complex tasks, delegate independent work to subagents:

- Use explorer to investigate relevant code and existing patterns.
- Use reviewer to review the completed changes.
- The main agent implements changes and runs validation.
- Avoid having multiple agents edit the same files.
- Keep simple tasks in the main agent
10 changes: 5 additions & 5 deletions packages/tdev/text-message/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,25 @@ tags:
- dynamic-documents
---

import SimpleChat from '@tdev/text-message/components/SimpleChat';
import DynamicDocumentRoots from '@tdev-components/documents/DynamicDocumentRoots';
import CodeShowcase from '@tdev-components/CodeShowcase';
import DynamicDocumentRoots from '@tdev-components/documents/DynamicDocumentRoots';
import SimpleChat from '@tdev/text-message/components/SimpleChat';

# text-message

Textnachrichten ermöglichen einen simplen Chat - dies ist eher ein PoC und die Basis für weitere, unterrichtsrelevante Interaktionsmöglichkeiten.

<CodeShowcase imports={[`import SimpleChat from '@tdev/text-message/components/SimpleChat';`]}>
<SimpleChat id="1c762ae7-b3f2-4379-a779-3ac4b7858eb4" name="Allgemeiner Chat" maxHeight='300px' />
<SimpleChat id="271bb4b0-bcf5-4b01-9f4b-3d5732106886" name="Allgemeiner Chat" maxHeight='300px' />
</CodeShowcase>


<CodeShowcase imports={[`import DynamicDocumentRoots from '@tdev-components/documents/DynamicDocumentRoots';`]}>
<DynamicDocumentRoots id="a74c052a-34f3-4ecd-93df-71bf842156fb" type='simple_chat' />
<DynamicDocumentRoots id="622874d1-cf9b-4b4e-a3c5-6d7309f7eb92" type='simple_chat' />
</CodeShowcase>


## Technischer Hintergrund
## Technischer Hintergrundnaa

Die `DocumentRoot` wird verwendet, um in MDX-Dokumenten bspw. Antworten User-Basiert abzuspeichern - es ist das Elternelement für alle Antworten und jede User:in kann dann ein eigenes `Document` hinzufügen. Über die `DocumentRoot` werden auch die Berechtigungen festgelegt.

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { NotCreated } from '@tdev-components/Rooms';
import Button from '@tdev-components/shared/Button';
import { useCreateDocument } from '@tdev-hooks/useCreateDocument';
import { useStore } from '@tdev-hooks/useStore';
Expand All @@ -20,7 +21,12 @@ const CreateSimpleChat = observer((props: Props) => {
const meta = React.useMemo(() => new ModelMeta({ name: props.name }), [props.id, props.name]);
const { create, apiState } = useCreateDocument(props.id, meta, true);
if (!userStore.current?.hasElevatedAccess) {
return null;
return (
<>
<ChatName name={props.name} documentRootId={props.id} />
<NotCreated message="Kein Chat vorhanden - warten auf die Lehrperson, bis der Chat erstellt wurde." />
</>
);
}
return (
<div className={clsx(styles.simpleChatContainer)}>
Expand Down
4 changes: 2 additions & 2 deletions packages/tdev/text-message/components/SimpleChat/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useFirstMainDocument } from '@tdev-hooks/useFirstMainDocument';
import { useGlobalFirstMainDocument } from '@tdev-hooks/useGlobalFirstMainDocument';
import { ModelMeta } from '@tdev/text-message/models/SimpleChat/ModelMeta';
import clsx from 'clsx';
import { observer } from 'mobx-react-lite';
Expand All @@ -16,7 +16,7 @@ interface Props {
const SimpleChat = observer((props: Props): React.ReactNode => {
const { id, name } = props;
const meta = React.useMemo(() => new ModelMeta({ name }), [id, name]);
const simpleChat = useFirstMainDocument(id, meta, false);
const simpleChat = useGlobalFirstMainDocument(id, meta, false);
if (!simpleChat || simpleChat.isDummy) {
return <CreateSimpleChat id={id} name={name} />;
}
Expand Down
242 changes: 141 additions & 101 deletions src/api/OfflineApi/Adapter/IndexedDb.ts
Original file line number Diff line number Diff line change
@@ -1,152 +1,192 @@
import { Document, DocumentType } from '@tdev-api/document';
import { IDBPDatabase, openDB } from 'idb';
import { DbAdapter, DBSchema } from '.';
import type { Document, DocumentType } from '@tdev-api/document';
import { deleteDB, type IDBPDatabase, openDB } from 'idb';
import type { DbAdapter, DBSchema, DocumentsTransaction } from '.';
import { OfflineUser } from '..';
import { normalizeDocumentParent } from '../documents';
import { IMPORT_STORES, type ImportedStores, prepareImport } from './import';
import { DB_VERSION, migrations } from './migrations';

const withFallback = <T>(fn: () => Promise<T>, fallback: T = undefined as T) => {
return fn().catch(() => fallback);
};

type InitStores = 'fsHandles' | 'documents' | 'studentGroups' | 'permissions' | 'users';
const DB_VERSION = 3 as const;

class IndexedDbAdapter implements DbAdapter {
private dbName: string;
private dbPromise: Promise<IDBPDatabase<DBSchema>>;
readonly mode = 'indexedDB';

constructor(dbName: string, initializeStores: boolean = false) {
// Retain the constructor argument for existing callers; every mode now uses the same schema.
constructor(dbName: string, _initializeStores: boolean = false) {
this.dbName = dbName;
this.dbPromise = this.initDB(initializeStores);
this.dbPromise = this.initDB();
void this.dbPromise.catch((error) => console.error('Failed to initialize IndexedDB:', error));
}

private async initDB(initializeStores: boolean): Promise<IDBPDatabase<DBSchema>> {
return openDB<DBSchema>(this.dbName, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains('fsHandles')) {
db.createObjectStore('fsHandles');
}
if (process.env.NODE_ENV !== 'production' || initializeStores) {
if (!db.objectStoreNames.contains('documents')) {
const store = db.createObjectStore('documents', { keyPath: 'id' });
store.createIndex('documentRootId', 'documentRootId', { unique: false });
}
if (!db.objectStoreNames.contains('studentGroups')) {
db.createObjectStore('studentGroups', { keyPath: 'id' });
private async initDB(): Promise<IDBPDatabase<DBSchema>> {
let upgraded: Promise<void> = Promise.resolve();
let upgradeError: unknown;
const opened = openDB<DBSchema>(this.dbName, DB_VERSION, {
upgrade(db, oldVersion, newVersion, transaction) {
// Index population can fail asynchronously after createIndex has returned.
void transaction.done.catch((error) => {
upgradeError ??= error;
});
upgraded = (async () => {
for (const migration of migrations) {
if (
oldVersion < migration.version &&
migration.version <= (newVersion ?? DB_VERSION)
) {
await migration.upgrade(db, transaction);
}
}
if (!db.objectStoreNames.contains('permissions')) {
db.createObjectStore('permissions', { keyPath: 'id' });
})().catch((error) => {
upgradeError = error;
console.error('IndexedDB migration failed:', error);
try {
transaction.abort();
} catch {
// A failed IndexedDB request may have already aborted the upgrade.
}
if (!db.objectStoreNames.contains('users')) {
db.createObjectStore('users', { keyPath: 'id' });
}
}
}
});
},
blocked: () => console.warn('IndexedDB upgrade blocked by another open tab:', this.dbName),
blocking: () => void this.dbPromise.then((db) => db.close())
});
try {
const db = await opened;
await upgraded;
if (upgradeError) {
db.close();
throw upgradeError;
}
return db;
} catch (error) {
throw upgradeError ?? error;
}
}

async withDocumentsTransaction<T>(work: (store: DocumentsTransaction) => Promise<T>): Promise<T> {
const db = await this.dbPromise;
const transaction = db.transaction('documents', 'readwrite');
const done = transaction.done;
// Observe aborts even when a request or validation fails before awaiting completion.
void done.catch(() => {});
const store: DocumentsTransaction = {
get: (id) => transaction.store.get(id),
getAll: () => transaction.store.getAll(),
put: async (document) => {
await transaction.store.put(normalizeDocumentParent(document));
},
delete: async (id) => {
await transaction.store.delete(id);
}
};
try {
const result = await work(store);
await done;
return result;
} catch (error) {
try {
transaction.abort();
} catch {
// A failed IndexedDB request may have already aborted the transaction.
}
await done.catch(() => {});
throw error;
}
}

async get<T>(storeName: InitStores, id: string): Promise<T | undefined>;
async get<T>(storeName: string, id: string): Promise<T | undefined>;
async get<T>(storeName: InitStores | string, id: string): Promise<T | undefined> {
return withFallback(async () => {
const db = await this.dbPromise;
return db.get(storeName, id) as Promise<T | undefined>;
});
async get<T>(storeName: string, id: string): Promise<T | undefined> {
const db = await this.dbPromise;
return withFallback(() => db.get(storeName, id) as Promise<T | undefined>);
}

async byDocumentRootId<T extends DocumentType>(
documentRootId: string | null | undefined
): Promise<Document<T>[]> {
const db = await this.dbPromise;
if (!documentRootId) {
return Promise.resolve([]);
return [];
}
return withFallback(async () => {
const db = await this.dbPromise;
const index = db.transaction('documents', 'readonly').store.index('documentRootId');
const docs = (await index.getAll(documentRootId)) as Document<T>[];
return docs;
}, []);
return db.getAllFromIndex('documents', 'documentRootId', documentRootId);
}

async getAll<T>(storeName: string): Promise<T[]> {
return withFallback(async () => {
const db = await this.dbPromise;
return db.getAll(storeName) as Promise<T[]>;
}, []);
const db = await this.dbPromise;
return withFallback(() => db.getAll(storeName) as Promise<T[]>, []);
}

async put<T>(storeName: string, item: T, id: IDBKeyRange | IDBValidKey): Promise<void>;
async put<T>(storeName: string, item: T & { id: string }): Promise<void>;
async put<T>(
storeName: string,
item: (T & { id: string }) | T,
id?: IDBKeyRange | IDBValidKey
): Promise<void> {
return withFallback(async () => {
const db = await this.dbPromise;
await db.put(storeName, item, id);
});
async put<T>(storeName: string, item: T, id?: IDBKeyRange | IDBValidKey): Promise<void> {
if (storeName === 'documents') {
return this.withDocumentsTransaction((store) => store.put(item as Document<DocumentType>));
}
const db = await this.dbPromise;
await db.put(storeName, item, id);
}

async delete(storeName: InitStores, id: string): Promise<void>;
async delete(storeName: string, id: string): Promise<void>;
async delete(storeName: string | InitStores, id: string): Promise<void> {
return withFallback(async () => {
const db = await this.dbPromise;
await db.delete(storeName, id);
});
async delete(storeName: string, id: string): Promise<void> {
if (storeName === 'documents') {
return this.withDocumentsTransaction((store) => store.delete(id));
}
const db = await this.dbPromise;
await db.delete(storeName, id);
}

async destroyDb(): Promise<void> {
return withFallback(async () => {
const db = await this.dbPromise;
await db.close();
// Optionally delete the database
await indexedDB.deleteDatabase(this.dbName);
});
const db = await this.dbPromise;
db.close();
await deleteDB(this.dbName);
}

async exportDb(): Promise<{ [storeName: string]: any }> {
return withFallback(async () => {
const db = await this.dbPromise;
const exportData: { [storeName: string]: any } = {};
for (const storeName of ['documents', 'studentGroups', 'permissions']) {
exportData[storeName] = await db.getAll(storeName);
}
// Here you can handle the exportData, e.g., save it to a file or send it to a server
return exportData;
});
const db = await this.dbPromise;
const transaction = db.transaction(['documents', 'studentGroups', 'permissions'], 'readonly');
const data: { [storeName: string]: any } = { user: OfflineUser.getUser() };
for (const name of transaction.objectStoreNames) {
data[name] = await transaction.objectStore(name).getAll();
}
await transaction.done;
return data;
}

async importDb(data: { [storeName: string]: any }): Promise<void> {
const hasUser = data['user'];
let needsCleanup = false;
if (hasUser) {
const user = data['user'];
const current = OfflineUser.getUser();
if (!current || current.id !== user.id) {
needsCleanup = true;
const db = await this.dbPromise;
const transaction = db.transaction([...IMPORT_STORES], 'readwrite');
const done = transaction.done;
void done.catch(() => {});
try {
const existing = {} as ImportedStores;
for (const name of IMPORT_STORES) {
existing[name] = await transaction.objectStore(name).getAll();
}
OfflineUser.setUser(user);
}
return withFallback(async () => {
const db = await this.dbPromise;
if (needsCleanup) {
await db.clear('users');
await db.clear('documents');
await db.clear('studentGroups');
await db.clear('permissions');
}
for (const storeName of Object.keys(data)) {
if (storeName === 'user') {
await db.put('users', data['user']);
}
if (!db.objectStoreNames.contains(storeName)) {
continue;
}
const items = data[storeName];
for (const item of items) {
await db.put(storeName, item);
const imported = prepareImport(data, existing, OfflineUser.getUser());
for (const name of IMPORT_STORES) {
const store = transaction.objectStore(name);
await store.clear();
for (const record of imported.stores[name]) {
await store.put(record);
}
}
});
await done;
if (imported.user) {
OfflineUser.setUser(imported.user);
}
} catch (error) {
try {
transaction.abort();
} catch {
// The transaction may already be aborted.
}
await done.catch(() => {});
throw error;
}
}
}

Expand Down
Loading
Loading