Initial move

This commit is contained in:
2024-09-03 14:50:33 +02:00
parent adb6be0006
commit 9fa656ed5e
138 changed files with 13117 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.env
backend/.env
.vscode
README.md
node_modules

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# notebrook-notes
Stream of consciousness note taking

12
backend/.env.example Normal file
View File

@@ -0,0 +1,12 @@
DB_PATH=/nb/database.db
API_TOKEN=test
UPLOAD_DIR=/nb/uploads/
DESCRIBE_IMAGES=1
DESCRIBE_IMAGES_API=ollama
DESCRIBE_IMAGES_PROMPT="Your task is to describe images to your friend in a friendly, detailed but concise manner.\n"
DESCRIBE_IMAGES_TEMPERATURE=0.5
DESCRIBE_IMAGES_MAX_TOKENS=8192
OPENAI_API_KEY=sk-blahblahblahblahblahImAnAPIKeyWoopDeeDoo
OPENAI_MODEL=gpt-4o
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=moondream

175
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
backend/README.md Normal file
View File

@@ -0,0 +1,15 @@
# notebrook-backend
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.1.21. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
filePath TEXT,
fileType TEXT,
fileSize INTEGER,
originalName TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
content TEXT,
fileId INTEGER NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
SET
NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content = 'messages',
content_rowid = 'id'
);

View File

@@ -0,0 +1,52 @@
-- 1. Create a backup of the existing tables
CREATE TABLE channels_backup AS SELECT * FROM channels;
CREATE TABLE files_backup AS SELECT * FROM files;
CREATE TABLE messages_backup AS SELECT * FROM messages;
-- 2. Drop the existing tables
DROP TABLE channels;
DROP TABLE files;
DROP TABLE messages;
-- 3. Recreate the tables with the updated schema
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
createdAt DATETIME DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
filePath TEXT,
fileType TEXT,
fileSize INTEGER,
originalName TEXT,
createdAt DATETIME DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
content TEXT,
fileId INTEGER NULL,
createdAt DATETIME DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE SET NULL
);
-- 4. Migrate the data back from the backup tables
INSERT INTO channels (id, name, createdAt)
SELECT id, name, createdAt FROM channels_backup;
INSERT INTO files (id, channelId, filePath, fileType, fileSize, originalName, createdAt)
SELECT id, channelId, filePath, fileType, fileSize, originalName, createdAt FROM files_backup;
INSERT INTO messages (id, channelId, content, fileId, createdAt)
SELECT id, channelId, content, fileId, createdAt FROM messages_backup;
-- 5. Drop the backup tables
DROP TABLE channels_backup;
DROP TABLE files_backup;
DROP TABLE messages_backup;

2435
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
backend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "notebrook-backend",
"module": "src/server.ts",
"type": "module",
"scripts": {
"start": "tsx src/server.ts",
"dev": "tsx --watch src/server.ts",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.5.4"
},
"dependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/multer": "^1.4.11",
"@types/ws": "^8.5.12",
"better-sqlite3": "^11.2.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"ollama": "^0.5.8",
"openai": "^4.56.0",
"selfsigned": "^2.4.1",
"sharp": "^0.33.5",
"tsx": "^4.18.0",
"ws": "^8.18.0"
}
}

31
backend/schema.sql Normal file
View File

@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
filePath TEXT,
fileType TEXT,
fileSize INTEGER,
originalName TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channelId INTEGER,
content TEXT,
fileId INTEGER NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (channelId) REFERENCES channels (id) ON DELETE CASCADE,
FOREIGN KEY (fileId) REFERENCES files (id) ON DELETE
SET
NULL
);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content = 'messages',
content_rowid = 'id'
);

27
backend/src/app.ts Normal file
View File

@@ -0,0 +1,27 @@
import express from "express";
import cors from "cors";
import * as ChannelRoutes from "./routes/channel";
import * as FileRoutes from "./routes/file";
import * as MessageRoutes from "./routes/message";
import * as SearchRoutes from "./routes/search";
import { authenticate } from "./middleware/auth";
import { initializeDB } from "./db";
import { FRONTEND_DIR, UPLOAD_DIR } from "./config";
export const app = express();
app.use(express.json());
app.use(cors());
app.use('/uploads', express.static(UPLOAD_DIR));
app.use(express.static(FRONTEND_DIR));
app.use("/channels", ChannelRoutes.router);
app.use("/channels/:channelId/messages", MessageRoutes.router);
app.use("/channels/:channelId/messages/:messageId/files", FileRoutes.router);
app.use("/search", SearchRoutes.router);
app.get('/check-token', authenticate, (req, res) => {
res.json({ message: 'Token is valid' });
});

21
backend/src/config.ts Normal file
View File

@@ -0,0 +1,21 @@
import dotenv from "dotenv";
dotenv.config();
export const DB_PATH = process.env["DB_PATH"] || "/usr/src/app/data/db.sqlite";
export const SECRET_KEY = process.env["API_TOKEN"] || "";
export const UPLOAD_DIR = process.env["UPLOAD_DIR"] || "/usr/src/app/data/uploads/";
export const FRONTEND_DIR = process.env["FRONTEND_DIR"] || "/usr/src/app/backend/public";
export const DESCRIBE_IMAGES: boolean = process.env["DESCRIBE_IMAGES"] === "1" ? true : false;
export const DESCRIBE_IMAGES_API = process.env["DESCRIBE_IMAGES_API"] || "ollama";
export const DESCRIBE_IMAGES_PROMPT= process.env["DESCRIBE_IMAGES_PROMPT"] || "Describe this image.";
export const DESCRIBE_IMAGES_TEMPERATURE= parseFloat(process.env["DESCRIBE_IMAGES_TEMPERATURE"]!) || 0.5;
export const DESCRIBE_IMAGES_MAX_TOKENS= parseInt(process.env["DESCRIBE_IMAGES_MAX_TOKENS"]!) || 1024;
export const OPENAI_API_KEY= process.env["OPENAI_API_KEY"] || "";
export const OPENAI_MODEL = process.env["OPENAI_MODEL"] || "gpt-4o";
export const OLLAMA_URL= process.env["OLLAMA_URL"] || "http://localhost:11434";
export const OLLAMA_MODEL= process.env["OLLAMA_MODEL"] || "moondream";
export const PORT = parseInt(process.env["PORT"]!) || 3000;
export const USE_SSL = process.env["USE_SSL"] === "1" ? true : false;
export const SSL_KEY = process.env["SSL_KEY"] || "";
export const SSL_CERT = process.env["SSL_CERT"] || "";
console.log(process.env);

View File

@@ -0,0 +1,62 @@
import type { Request, Response } from "express";
import * as ChannelService from "../services/channel-service";
import { logger } from "../globals";
export const createChannel = async (req: Request, res: Response) => {
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const chan = await ChannelService.createChannel(name);
logger.info(`Channel ${name} created`);
res.json(chan);
}
export const deleteChannel = async (req: Request, res: Response) => {
const { channelId } = req.params;
if (!channelId) {
return res.status(400).json({ error: 'Channel ID is required' });
}
const result = await ChannelService.deleteChannel(channelId);
if (result.changes === 0) {
logger.warn(`Channel ${channelId} not found while deleting`);
return res.status(404).json({ error: 'Channel not found' });
}
logger.info(`Channel ${channelId} deleted`);
res.json({ message: 'Channel deleted successfully' });
}
export const getChannels = async (req: Request, res: Response) => {
const channels = await ChannelService.getChannels();
res.json({ channels });
}
export const mergeChannel = async (req: Request, res: Response) => {
const { channelId } = req.params;
const { targetChannelId } = req.body;
if (!channelId || !targetChannelId) {
return res.status(400).json({ error: 'Channel ID and targetChannelId are required' });
}
const result = await ChannelService.mergeChannel(channelId, targetChannelId);
logger.info(`Channel ${targetChannelId} merged into ${channelId}`);
res.json({ message: 'Channels merged successfully' });
}
export const updateChannel = async (req: Request, res: Response) => {
const { channelId } = req.params;
const { name } = req.body;
if (!channelId || !name) {
return res.status(400).json({ error: 'Channel ID and name are required' });
}
const result = await ChannelService.updateChannel(channelId, name);
if (result.changes === 0) {
return res.status(404).json({ error: 'Channel not found' });
}
logger.info(`Channel ${channelId} updated as ${name}`);
res.json({ message: 'Channel updated successfully' });
}

View File

@@ -0,0 +1,33 @@
import type { Request, Response } from "express";
import * as FileService from "../services/file-service";
import { logger } from "../globals";
export const uploadFile = async (req: Request, res: Response) => {
const { channelId, messageId } = req.params;
const filePath = (req.file as Express.Multer.File).path;
const fileType = req.file?.mimetype;
const fileSize = req.file?.size;
const originalName = req.file?.originalname;
if (!channelId || !messageId) {
return res.status(400).json({ error: 'Channel ID and message ID are required' });
}
if (!filePath || !fileType || !fileSize || !originalName) {
return res.status(400).json({ error: 'File is required' });
}
const result = await FileService.uploadFile(channelId, messageId, filePath, fileType!, fileSize!, originalName!);
logger.info(`File ${originalName} uploaded to message ${messageId} as ${filePath}`);
res.json({ id: result.lastInsertRowid, channelId, messageId, filePath, fileType });
}
export const getFiles = async (req: Request, res: Response) => {
const { messageId } = req.params;
if (!messageId) {
return res.status(400).json({ error: 'Message ID is required' });
}
const files = await FileService.getFiles(messageId);
res.json({ files });
}

View File

@@ -0,0 +1,54 @@
import type { Request, Response } from "express";
import * as MessageService from "../services/message-service";
import { logger } from "../globals";
export const createMessage = async (req: Request, res: Response) => {
const { content } = req.body;
const { channelId } = req.params;
if (!content || !channelId) {
return res.status(400).json({ error: 'Content and channel ID are required' });
}
const messageId = await MessageService.createMessage(channelId, content);
logger.info(`Message ${messageId} created in channel ${channelId}`);
res.json({ id: messageId, channelId, content, createdAt: new Date().toISOString() });
};
export const updateMessage = async (req: Request, res: Response) => {
const { content } = req.body;
const { messageId } = req.params;
if (!content || !messageId) {
return res.status(400).json({ error: 'Content and message ID are required ' });
}
const result = await MessageService.updateMessage(messageId, content);
if (result.changes === 0) {
return res.status(404).json({ error: 'Message not found' });
}
logger.info(`Message ${messageId} updated`);
res.json({ id: messageId, content });
}
export const deleteMessage = async (req: Request, res: Response) => {
const { messageId } = req.params;
if (!messageId) {
return res.status(400).json({ error: 'Message ID is required' });
}
const result = await MessageService.deleteMessage(messageId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Message not found' });
}
logger.info(`Message ${messageId} deleted`);
res.json({ message: 'Message deleted successfully' });
}
export const getMessages = async (req: Request, res: Response) => {
const { channelId } = req.params;
if (!channelId) {
return res.status(400).json({ error: 'Channel ID is required' });
}
const messages = await MessageService.getMessages(channelId);
res.json({ messages });
}

View File

@@ -0,0 +1,13 @@
import type { Request, Response } from "express";
import * as SearchService from "../services/search-service";
import { logger } from "../globals";
export const search = async (req: Request, res: Response) => {
const { query, channelId } = req.query;
if (!query) {
return res.status(400).json({ error: 'Query is required' });
}
const results = await SearchService.search(query as string, channelId as string);
logger.info(`Searched for ${query}`);
res.json({ results });
}

View File

@@ -0,0 +1,29 @@
import { events } from "../globals";
import { WebSocket } from "ws";
export const attachEvents = (ws: WebSocket) => {
events.on('file-uploaded', (id, channelId, messageId, filePath, fileType, fileSize, originalName) => {
ws.send(JSON.stringify({ type: 'file-uploaded', data: {id, channelId, messageId, filePath, fileType, fileSize, originalName }}));
});
events.on('message-created', (id, channelId, content) => {
ws.send(JSON.stringify({ type: 'message-created', data: {id, channelId, content }}));
});
events.on('message-updated', (id, content) => {
ws.send(JSON.stringify({ type: 'message-updated', data: {id, content }}));
});
events.on('message-deleted', (id) => {
ws.send(JSON.stringify({ type: 'message-deleted', data: {id }}));
});
events.on('channel-created', (channel) => {
ws.send(JSON.stringify({ type: 'channel-created', data: {channel }}));
});
events.on('channel-deleted', (id) => {
ws.send(JSON.stringify({ type: 'channel-deleted', data: {id} }));
});
events.on('channel-merged', (channelId, targetChannelId) => {
ws.send(JSON.stringify({ type: 'channel-merged', data: {channelId, targetChannelId }}));
});
events.on('channel-updated', (id, name) => {
ws.send(JSON.stringify({ type: 'channel-updated', data: {id, name }}));
});
}

67
backend/src/db.ts Normal file
View File

@@ -0,0 +1,67 @@
import Database from 'better-sqlite3';
import { DB_PATH } from './config';
import { logger } from './globals';
import { readdir, readFile } from "fs/promises";
import { join, dirname } from "path";
export let FTS5Enabled = true;
export const initializeDB = () => {
logger.info("Checking fts");
const ftstest = db.prepare(`pragma compile_options;`);
const result = ftstest.all() as { compile_options: string }[];
if (result.find((o) => o["compile_options"].includes("ENABLE_FTS5"))) {
logger.info("FTS5 is enabled");
} else {
logger.info("FTS5 is not enabled. Attempting to load...");
try {
db.loadExtension('./fts5');
} catch (e) {
logger.warn("Failed to load FTS5 extension. Disabling FTS5");
FTS5Enabled = false;
}
}
return FTS5Enabled;
}
export const migrate = async () => {
logger.info(`Checking for migrations...`);
const result = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='meta'`);
if (result.all().length === 0) {
logger.info(`Creating meta table...`);
db.exec(`CREATE TABLE meta (version INTEGER)`);
db.exec(`INSERT INTO meta (version) VALUES (-1)`);
}
const version = db.prepare(`SELECT version FROM meta`).get() as { version: number };
logger.info(`Migration version: ${version.version}`);
// we are in bun.js. use its API's to read the file list.
logger.info(`Searching for migrations in ${join("migrations")}`);
const files = await readdir(join("migrations"));
for (const file of files) {
const [fileVersion, ...rest] = file.split("_");
logger.info(`Found migration ${fileVersion}`);
if (fileVersion && Number(fileVersion) > version.version) {
logger.info(`Running migration ${file}`);
const sql = new TextDecoder().decode(await readFile(join(`migrations/${file}`)));
db.exec(sql);
const query = db.prepare(`UPDATE meta SET version = ($version)`);
const res = query.run({ version: fileVersion })
logger.info(`Migration ${file} done`);
}
}
logger.info(`Migrations done`);
}
logger.info(`Loading database at ${DB_PATH}`);
export const db = new Database(DB_PATH);
initializeDB();
migrate();

14
backend/src/globals.ts Normal file
View File

@@ -0,0 +1,14 @@
import { EventEmitter } from "events";
import { Scheduler } from "./utils/scheduler";
import { jobs } from "./jobs";
import { Logger } from "./logging/logger";
import { ConsoleAdapter } from "./logging/adapters/console-adapter";
export const events = new EventEmitter();
export const scheduler = new Scheduler();
export const logger = new Logger();
logger.addAdapter(new ConsoleAdapter());
jobs.forEach((job) => {
job();
});

View File

@@ -0,0 +1,6 @@
import { loadImage, describeWithOpenAI, describeImage } from "./services/image-description";
import { DESCRIBE_IMAGES_PROMPT, OPENAI_API_KEY } from "./config";
(async () => {
console.log(await describeImage("d:/avatar.jpg"));
})();

View File

@@ -0,0 +1,17 @@
import type { Message } from "../../types";
import { events, logger } from "../globals"
import { describeImage } from "../services/image-description";
import { getMessage, updateMessage } from "../services/message-service";
export const describeImageJob = () => {
events.on("file-uploaded", (id, channelId, messageId, filePath, fileType, fileSize, originalName) => {
if (fileType.includes("image")) {
describeImage(filePath).then((description) => {
const msg = getMessage(messageId) as any;
updateMessage(messageId, `${msg.content ? msg.content : ''}\n\n${description}`);
}).catch((e) => {
logger.warn(`Failed to describe image: ${e.message}`);
});
}
});
}

View File

@@ -0,0 +1,7 @@
import { describeImageJob } from "./describe-image";
import { scheduleVacuum } from "./vacuum";
export const jobs = [
scheduleVacuum,
describeImageJob
]

View File

@@ -0,0 +1,9 @@
import { Scheduler, TimeUnit } from "../utils/scheduler";
import { scheduler } from "../globals";
import { db } from "../db";
export const scheduleVacuum = () => {
scheduler.register('vacuum', () => {
db.exec('VACUUM');
}, 1, TimeUnit.DAY);
}

View File

@@ -0,0 +1,15 @@
import { type LogEntry } from "./log-entry";
export abstract class LogAdapter {
public log(message: LogEntry) {
if (this.shouldLog(message)) {
this.logImpl(message);
}
}
public abstract logImpl(message: LogEntry): boolean;
public shouldLog(message: LogEntry): boolean {
return true;
}
}

View File

@@ -0,0 +1,10 @@
import { LogAdapter } from "../adapter";
import { type LogEntry, LogLevel } from "../log-entry";
export class ConsoleAdapter extends LogAdapter {
public logImpl(message: LogEntry): boolean {
console.log(`${LogLevel[message.level]}: ${message.message}; ${new Date(message.timestamp).toLocaleString()}:`);
if (message.additionalInfo) console.log(message.additionalInfo);
return true;
}
}

View File

@@ -0,0 +1,12 @@
export interface LogEntry {
level: LogLevel;
timestamp: number;
message: string;
additionalInfo?: any;
}
export enum LogLevel {
info,
warning,
critical
}

View File

@@ -0,0 +1,49 @@
import { LogAdapter } from "./adapter";
import { type LogEntry, LogLevel } from "./log-entry";
export class Logger {
private adapters: LogAdapter[];
public constructor() {
this.adapters = [];
}
public log(message: LogEntry) {
this.adapters.forEach((adapter) => adapter.log(message));
}
public info(message: string, additionalInfo?: any) {
this.log({
level: LogLevel.info,
message,
additionalInfo,
timestamp: Date.now()
})
}
public warn(message: string, additionalInfo?: any) {
this.log({
level: LogLevel.warning,
message,
additionalInfo,
timestamp: Date.now()
})
}
public critical(message: string, additionalInfo?: any) {
this.log({
level: LogLevel.critical,
message,
additionalInfo,
timestamp: Date.now()
})
}
public addAdapter(adapter: LogAdapter) {
this.adapters.push(adapter);
}
public removeAdapter(adapter: LogAdapter) {
this.adapters.slice(this.adapters.indexOf(adapter), 1);
}
}

View File

@@ -0,0 +1,15 @@
import type { NextFunction, Request, Response } from "express";
import { SECRET_KEY } from "../config";
import { logger } from "../globals";
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const token = req.headers['authorization'];
if (!token) {
return res.status(403).json({ error: 'No token provided' });
}
if (token === SECRET_KEY) {
next();
} else {
res.status(401).json({ error: "Unauthenticated" })
}
}

View File

@@ -0,0 +1,10 @@
import { Router } from 'express';
import * as ChannelController from '../controllers/channel-controller';
import { authenticate } from '../middleware/auth';
export const router = Router({mergeParams: true});
router.post('/', authenticate, ChannelController.createChannel);
router.get('/', authenticate, ChannelController.getChannels);
router.delete('/:channelId', authenticate, ChannelController.deleteChannel);
router.put('/:channelId/merge', authenticate, ChannelController.mergeChannel);

View File

@@ -0,0 +1,9 @@
import { Router } from "express";
import { upload } from "../utils/multer";
import * as FileController from "../controllers/file-controller";
import { authenticate } from "../middleware/auth";
export const router = Router({mergeParams: true});
router.post("/", authenticate, upload.single("file"), FileController.uploadFile);
router.get("/", authenticate, FileController.getFiles);

View File

@@ -0,0 +1,11 @@
import { Router } from 'express';
import * as MessageController from '../controllers/message-controller';
import { authenticate } from '../middleware/auth';
export const router = Router({mergeParams: true});
router.post('/', authenticate, MessageController.createMessage);
router.put('/:messageId', authenticate, MessageController.updateMessage);
router.delete('/:messageId', authenticate, MessageController.deleteMessage);
router.get('/', authenticate, MessageController.getMessages);

View File

@@ -0,0 +1,7 @@
import { Router } from "express";
import * as SearchController from "../controllers/search-controller";
import { authenticate } from "../middleware/auth";
export const router = Router({mergeParams: true});
router.get("/", authenticate, SearchController.search);

55
backend/src/server.ts Normal file
View File

@@ -0,0 +1,55 @@
import { app } from "./app";
import { createServer } from "http";
import { WebSocket, WebSocketServer } from "ws";
import { attachEvents } from "./controllers/websocket-controller";
import { logger } from "./globals";
import selfSigned from "selfsigned";
const PORT = process.env.PORT || 3000;
const server = createServer(app);
const wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => {
logger.info('Websocket client connected');
attachEvents(ws);
ws.on('message', (message: string) => {
logger.info(`Received message: ${message}`);
});
ws.on('close', () => {
logger.info('Websocket client disconnected');
});
});
server.listen(PORT, () => {
logger.info(`Server is running on http://localhost:${PORT}`);
});
const getOrCreateCertificate = async () => {
if (process.env.USE_SSL === '1') {
if (!process.env.SSL_KEY || !process.env.SSL_CERT) {
return await createSelfSignedSSLCert();
}
return {
key: process.env.SSL_KEY,
cert: process.env.SSL_CERT
};
}
return null;
}
const createSelfSignedSSLCert = async () => {
const selfsigned = await import('selfsigned');
const pems = selfsigned.generate([{ name: 'Notebrook Self Signed Auto Generated Key', value: 'localhost' }], {
keySize: 2048,
days: 365
});
return {
key: pems.private,
cert: pems.cert
};
}

View File

@@ -0,0 +1,37 @@
import { db } from "../db";
import { events } from "../globals";
export const createChannel = async (name: string) => {
const query = db.prepare(`INSERT INTO channels (name) VALUES ($name)`);
const result = query.run({ name: name });
events.emit('channel-created', { id: result.lastInsertRowid, name });
return { id: result.lastInsertRowid, name };
}
export const deleteChannel = async (id: string) => {
const query = db.prepare(`DELETE FROM channels WHERE id = ($channelId)`);
const result = query.run({channelId: id});
// No need to manually delete messages and files as they are set to cascade on delete in the schema
events .emit('channel-deleted', id);
return result;
}
export const getChannels = async () => {
const query = db.prepare(`SELECT * FROM channels`);
const rows = query.all();
return rows;
}
export const mergeChannel = async (channelId: string, targetChannelId: string) => {
const query = db.prepare(`UPDATE messages SET channelId = $targetChannelId WHERE channelId = $channelId`);
const result = query.run({ channelId: channelId, targetChannelId: targetChannelId });
events.emit('channel-merged', channelId, targetChannelId);
return result;
}
export const updateChannel = async (id: string, name: string) => {
const query = db.prepare(`UPDATE channels SET name = $name WHERE id = $id`);
const result = query.run({ id: id, name: name });
events.emit('channel-updated', id, name);
return result;
}

View File

@@ -0,0 +1,21 @@
import { db } from "../db";
import { events } from "../globals";
export const uploadFile = async (channelId: string, messageId: string, filePath: string, fileType: string, fileSize: number, originalName: string) => {
const query = db.prepare(`INSERT INTO files (channelId, filePath, fileType, fileSize, originalName) VALUES ($channelId, $filePath, $fileType, $fileSize, $originalName)`);
const result = query.run({ channelId: channelId, filePath: filePath, fileType: fileType, fileSize: fileSize, originalName: originalName });
const fileId = result.lastInsertRowid;
const updateQuery = db.prepare(`UPDATE messages SET fileId = $fileId WHERE id = $messageId`);
const result2 = updateQuery.run({ fileId: fileId, messageId: messageId });
events.emit('file-uploaded', result.lastInsertRowid, channelId, messageId, filePath, fileType, fileSize, originalName);
return result2; ''
}
export const getFiles = async (messageId: string) => {
const query = db.prepare(`SELECT * FROM files WHERE messageId = $messageId`);
const rows = query.all({ messageId: messageId });
return rows;
}

View File

@@ -0,0 +1,83 @@
import { Ollama } from "ollama";
import OpenAI from "openai";
import { DESCRIBE_IMAGES_API, DESCRIBE_IMAGES_MAX_TOKENS, DESCRIBE_IMAGES_PROMPT, DESCRIBE_IMAGES_TEMPERATURE, OLLAMA_MODEL, OLLAMA_URL, OPENAI_API_KEY, OPENAI_MODEL } from "../config";
import { readFile } from "fs/promises";
import sharp from "sharp";
export const describeWithOllama = async (image: Buffer) => {
const client = new Ollama({ host: OLLAMA_URL });
const response = await client.chat({
model: OLLAMA_MODEL,
options: {
temperature: DESCRIBE_IMAGES_TEMPERATURE,
},
messages: [
{ role: "system", content: DESCRIBE_IMAGES_PROMPT },
{ role: "user", images: [image], content: "Describe this image." },
]
});
return response.message.content;
}
export const describeWithOpenAI = async (image: Buffer) => {
const client = new OpenAI({
apiKey: OPENAI_API_KEY,
});
const response = await client.chat.completions.create({
model: OPENAI_MODEL,
max_tokens: DESCRIBE_IMAGES_MAX_TOKENS,
temperature: DESCRIBE_IMAGES_TEMPERATURE,
messages: [
{ role: "system", content: DESCRIBE_IMAGES_PROMPT },
{ role: "user", content: [{ type: "text", text: "Describe the following image in a detailed but concise manner." }, { type: "image_url", image_url: { url: imageToBase64URL(image) } }] },
]
})
return response.choices[0].message.content;
}
export const describeImage = async (filePath: string) => {
const image = await loadImage(filePath);
if (DESCRIBE_IMAGES_API === "ollama") {
return describeWithOllama(image);
} else {
return describeWithOpenAI(image);
}
return "";
}
export const loadImage = async (filePath: string) => {
return processImage(filePath);
}
async function processImage(imagePath: string): Promise<Buffer> {
try {
const image = sharp(imagePath);
const metadata = await image.metadata();
const maxDimension = 1024;
// Check if the image needs to be resized
let resizedImage = image;
if (metadata.width && metadata.height && (metadata.width > maxDimension || metadata.height > maxDimension)) {
resizedImage = image.resize({
width: Math.min(metadata.width, maxDimension),
height: Math.min(metadata.height, maxDimension),
fit: sharp.fit.inside,
withoutEnlargement: true
});
}
// Convert the image to JPG
const jpgBuffer = await resizedImage.jpeg().toBuffer();
return jpgBuffer;
} catch (error) {
console.error('Error processing the image:', error);
throw new Error('Failed to process the image.');
}
}
export const imageToBase64URL = (input: Buffer) => {
return `data:image/jpeg;base64,${input.toString('base64')}`;
}

View File

@@ -0,0 +1,83 @@
import { db, FTS5Enabled } from "../db";
import { events } from "../globals";
export const createMessage = async (channelId: string, content: string) => {
const query = db.prepare(`INSERT INTO messages (channelId, content) VALUES ($channelId, $content)`);
const result = query.run({ channelId: channelId, content: content });
const messageId = result.lastInsertRowid;
console.log(`Adding message for search with id ${messageId}`);
// Insert into FTS table if FTS is enabled.
if (FTS5Enabled) {
const query2 = db.prepare(`INSERT INTO messages_fts (rowid, content) VALUES ($rowId, $content)`);
const result2 = query2.run({ rowId: messageId, content: content });
}
events.emit('message-created', messageId, channelId, content);
return messageId;
}
export const updateMessage = async (messageId: string, content: string, append: boolean = false) => {
const query = db.prepare(`UPDATE messages SET content = $content WHERE id = $id`);
const result = query.run({ content: content, id: messageId });
// Update FTS table if enabled
if (!FTS5Enabled) {
const query2 = db.prepare(`INSERT INTO messages_fts (rowid, content) VALUES ($rowId, $content) ON CONFLICT(rowid) DO UPDATE SET content = excluded.content`);
const result2 = query.run({ rowId: messageId, content: content });
}
events.emit('message-updated', messageId, content);
return result;
}
export const deleteMessage = async (messageId: string) => {
const query = db.prepare(`DELETE FROM messages WHERE id = $id`);
const result = query.run({ id: messageId });
// Remove from FTS table if enabled
if (FTS5Enabled) {
const query2 = db.prepare(`DELETE FROM messages_fts WHERE rowid = $rowId`);
const result2 = query2.run({ rowId: messageId });
}
events.emit('message-deleted', messageId);
return result;
}
export const getMessages = async (channelId: string) => {
const query = db.prepare(`
SELECT
messages.id, messages.channelId, messages.content, messages.createdAt,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM
messages
LEFT JOIN
files
ON
messages.fileId = files.id
WHERE
messages.channelId = $channelId
`);
const rows = query.all({ channelId: channelId });
return rows;
}
export const getMessage = async (id: string) => {
const query = db.prepare(`
SELECT
messages.id, messages.channelId, messages.content, messages.createdAt,
files.id as fileId, files.filePath, files.fileType, files.createdAt as fileCreatedAt, files.originalName, files.fileSize
FROM
messages
LEFT JOIN
files
ON
messages.fileId = files.id
WHERE
messages.id = $id
`);
const row = query.get({ id: id });
return row;
}

View File

@@ -0,0 +1,44 @@
import { db, FTS5Enabled } from "../db";
export const search = async (query: string, channelId?: string) => {
let sql: string;
let params: any;
if (FTS5Enabled) {
if (channelId) {
sql = `
SELECT messages.id, messages.channelId, messages.content, messages.createdAt
FROM messages_fts
JOIN messages ON messages_fts.rowid = messages.id
WHERE messages_fts MATCH lower($query) AND messages.channelId = $channelId
`;
params = { channelId: channelId, query: (query || '').toString().toLowerCase() };
} else {
sql = `
SELECT messages.id, messages.channelId, messages.content, messages.createdAt
FROM messages_fts
JOIN messages ON messages_fts.rowid = messages.id
WHERE messages_fts MATCH lower($query)
`;
params = { query: (query || '').toString().toLowerCase() };
}
} else {
console.log("Performing search without FTS5. This might be very slow.");
if (channelId) {
sql = `
SELECT * FROM messages WHERE LOWER(content) LIKE '%' || LOWER($query) || '%' AND channelId = $channelId
`;
params = { channelId: channelId, query: query };
} else {
sql = `
SELECT * FROM messages WHERE LOWER(content) LIKE '%' || LOWER($query) || '%'
`;
params = { query: query };
}
}
const sqlquery = db.prepare(sql);
const rows = sqlquery.all(params);
return rows;
}

View File

@@ -0,0 +1,4 @@
import multer from "multer";
import { UPLOAD_DIR } from "../config";
export const upload = multer({ dest: UPLOAD_DIR });

View File

@@ -0,0 +1,54 @@
export enum TimeUnit {
SECOND = 1000,
MINUTE = 60 * 1000,
HOUR = 60 * 60 * 1000,
DAY = 24 * 60 * 60 * 1000,
WEEK = 7 * 24 * 60 * 60 * 1000
}
export type Task = () => void;
export interface TaskEntry {
id: Timer;
task: Task;
remainingRuns: number;
}
export class Scheduler {
private tasks: Map<string, TaskEntry> = new Map();
static toMilliseconds(time: number, unit: TimeUnit): number {
return time * unit;
}
register(taskName: string, task: Task, delay: number, unit: TimeUnit, runs: number = Infinity): void {
if (this.tasks.has(taskName)) {
throw new Error(`Task ${taskName} is already registered.`);
}
const performTask = () => {
task();
const taskEntry = this.tasks.get(taskName);
if (taskEntry) {
taskEntry.remainingRuns--;
if (taskEntry.remainingRuns > 0) {
taskEntry.id = setTimeout(performTask, Scheduler.toMilliseconds(delay, unit));
} else {
this.tasks.delete(taskName);
}
}
};
this.tasks.set(taskName, { id: setTimeout(performTask, Scheduler.toMilliseconds(delay, unit)), task, remainingRuns: runs });
}
unregister(taskName: string): void {
const taskEntry = this.tasks.get(taskName);
if (taskEntry) {
clearTimeout(taskEntry.id);
this.tasks.delete(taskName);
}
}
getTasks(): Map<string, TaskEntry> {
return this.tasks;
}
}

27
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

21
backend/types.ts Normal file
View File

@@ -0,0 +1,21 @@
export interface Channel {
id: number;
name: string;
created_at: string;
}
export interface Message {
id: number;
channel_id: number;
content: string;
created_at: string;
}
export interface File {
id: number;
channel_id: number;
message_id: number;
file_path: string;
file_type: string;
created_at: string;
}

36
dockerfile Normal file
View File

@@ -0,0 +1,36 @@
FROM node:22-slim AS base
WORKDIR /usr/src/app
FROM base AS install
COPY backend/package.json backend/package-lock.json /temp/dev/backend/
COPY frontend/package.json frontend/package-lock.json /temp/dev/frontend/
RUN cd /temp/dev/backend && npm install
RUN cd /temp/dev/frontend && npm install
RUN mkdir -p /temp/prod/backend /temp/prod/frontend
COPY backend/package.json backend/package-lock.json /temp/prod/backend/
COPY frontend/package.json frontend/package-lock.json /temp/prod/frontend/
RUN cd /temp/prod/backend && npm install --production
RUN cd /temp/prod/frontend && npm install --production
FROM install AS build-frontend
WORKDIR /usr/src/app/frontend
COPY frontend/ .
COPY --from=install /temp/dev/frontend/node_modules node_modules
RUN npm run build
FROM base AS release
WORKDIR /usr/src/app
COPY backend/ backend/
COPY --from=install /temp/prod/backend/node_modules backend/node_modules
COPY --from=install /temp/prod/frontend/node_modules frontend/node_modules
COPY --from=build-frontend /usr/src/app/frontend/dist backend/public
USER node
WORKDIR /usr/src/app/backend
EXPOSE 3000/tcp
ENTRYPOINT [ "npm", "run", "start"]

View File

@@ -0,0 +1,14 @@
[Unit]
Description=backend Service
After=network.target
[Service]
User=notebrook
WorkingDirectory=/home/notebrook/backend
ExecStart=/usr/bin/npm start
Restart=always
Environment=PATH=/usr/bin:/usr/local/bin
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=frontend Service notebrook
After=network.target
[Service]
User=notebrook
WorkingDirectory=/home/notebrook/frontend
ExecStart=/usr/bin/npm run dev -- --host
Restart=always
Environment=PATH=/usr/bin:/usr/local/bin
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

75
frontend/index.html Normal file
View File

@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notebrook</title>
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<!-- Theme Color (For Mobile idk) -->
<meta name="theme-color" content="#ffffff" />
<!-- PWA Metadata -->
<meta name="description" content="Notebrook, stream of consciousness accessible note taking" />
<meta name="application-name" content="Notebrook" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Notebrook" />
<meta name="msapplication-starturl" content="/" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-TileImage" content="/icons/mstile-150x150.png" />
<style>
/* Basic styles for the toasts */
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background-color: #333;
color: #fff;
padding: 15px;
border-radius: 5px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.3s, transform 0.3s;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body role="application">
<div id="app"></div>
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
<script type="module" src="/src/main.ts"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, function (err) {
console.log('ServiceWorker registration failed: ', err);
});
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{
"name": "Notebrook",
"short_name": "Notebrook",
"description": "Stream of conciousness accessible note taking",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff",
"icons": [
{
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/icons/mstile-150x150.png",
"sizes": "150x150",
"type": "image/png"
}
]
}

4949
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
frontend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "notebrook-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "^5.5.3",
"vite": "^5.4.0"
},
"dependencies": {
"idb-keyval": "^6.2.1",
"vite-plugin-pwa": "^0.20.1"
}
}

BIN
frontend/public/intro.wav Normal file

Binary file not shown.

BIN
frontend/public/login.wav Normal file

Binary file not shown.

BIN
frontend/public/sent1.wav Normal file

Binary file not shown.

BIN
frontend/public/sent2.wav Normal file

Binary file not shown.

BIN
frontend/public/sent3.wav Normal file

Binary file not shown.

BIN
frontend/public/sent4.wav Normal file

Binary file not shown.

BIN
frontend/public/sent5.wav Normal file

Binary file not shown.

BIN
frontend/public/sent6.wav Normal file

Binary file not shown.

Binary file not shown.

BIN
frontend/public/water1.wav Normal file

Binary file not shown.

BIN
frontend/public/water10.wav Normal file

Binary file not shown.

BIN
frontend/public/water2.wav Normal file

Binary file not shown.

BIN
frontend/public/water3.wav Normal file

Binary file not shown.

BIN
frontend/public/water4.wav Normal file

Binary file not shown.

BIN
frontend/public/water5.wav Normal file

Binary file not shown.

BIN
frontend/public/water6.wav Normal file

Binary file not shown.

BIN
frontend/public/water7.wav Normal file

Binary file not shown.

BIN
frontend/public/water8.wav Normal file

Binary file not shown.

BIN
frontend/public/water9.wav Normal file

Binary file not shown.

103
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,103 @@
import { IChannel } from "./model/channel";
import { IChannelList } from "./model/channel-list";
import { IMessage } from "./model/message";
import { IUnsentMessage } from "./model/unsent-message";
import { state } from "./state";
export const API = {
token: "",
path: "http://localhost:3000",
async request(method: string, path: string, body?: any) {
if (!API.token) {
throw new Error("API token was not set.");
}
return fetch(`${API.path}/${path}`, {
method,
headers: {
"Content-Type": "application/json",
"Authorization": API.token
},
body: JSON.stringify(body),
});
},
async checkToken() {
const response = await API.request("GET", "check-token");
if (response.status !== 200) {
throw new Error("Invalid token in request");
}
},
async getChannels() {
const response = await API.request("GET", "channels");
const json = await response.json();
return json.channels as IChannel[];
},
async getChannel(id: string) {
const response = await API.request("GET", `channels/${id}`);
const json = await response.json();
return json.channel as IChannel;
},
async createChannel(name: string) {
const response = await API.request("POST", "channels", { name });
const json = await response.json();
return json as IChannel;
},
async deleteChannel(id: string) {
await API.request("DELETE", `channels/${id}`);
},
async getMessages(channelId: string) {
const response = await API.request("GET", `channels/${channelId}/messages`);
const json = await response.json();
return json.messages as IMessage[];
},
async createMessage(channelId: string, content: string) {
const response = await API.request("POST", `channels/${channelId}/messages`, { content });
const json = await response.json();
return json as IMessage;
},
async deleteMessage(channelId: string, messageId: string) {
await API.request("DELETE", `channels/${channelId}/messages/${messageId}`);
},
async uploadFile(channelId: string, messageId: string, file: File | Blob) {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${API.path}/channels/${channelId}/messages/${messageId}/files`, {
method: "POST",
headers: {
"Authorization": API.token
},
body: formData,
});
const json = await response.json();
return json;
},
async mergeChannels(channelId: string, targetChannelId: string) {
await API.request("PUT", `channels/${channelId}/merge`, { targetChannelId });
},
async search(query: string, channelId?: string) {
const queryPath = channelId ? `search?query=${encodeURIComponent(query)}&channelId=${channelId}` : `search?query=${encodeURIComponent(query)}`;
const response = await API.request("GET", queryPath);
const json = await response.json();
return json.results as IMessage[];
},
async getFiles(channelId: string, messageId: string) {
const response = await API.request("GET", `channels/${channelId}/messages/${messageId}/files`);
const json = await response.json();
return json.files as string[];
}
}

View File

@@ -0,0 +1,25 @@
export class ChunkProcessor<T> {
private chunkSize: number;
constructor(chunkSize: number = 1000) {
this.chunkSize = chunkSize;
}
async processArray(array: T[], callback: (chunk: T[]) => void): Promise<void> {
const totalChunks = Math.ceil(array.length / this.chunkSize);
for (let i = 0; i < totalChunks; i++) {
const chunk = array.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
await this.processChunk(chunk, callback);
}
}
private async processChunk(chunk: T[], callback: (chunk: T[]) => void): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(() => {
callback(chunk);
resolve();
}, 0);
});
}
}

View File

@@ -0,0 +1,68 @@
import { IChannel } from "../model/channel";
import { showToast } from "../speech";
import { state } from "../state";
import { Button, TextInput } from "../ui";
import { Dialog } from "../ui/dialog";
import { MergeDialog } from "./merge-dialog";
import { RemoveDialog } from "./remove-dialog";
export class ChannelDialog extends Dialog<IChannel | null> {
private channel: IChannel;
private nameField: TextInput;
private makeDefault: Button;
private mergeButton: Button;
private deleteButton: Button;
public constructor(channel: IChannel) {
super("Channel info for " + channel.name);
this.channel = channel;
this.nameField = new TextInput("Channel name");
this.nameField.setPosition(25, 10, 50, 10);
this.nameField.setValue(channel.name);
this.makeDefault = new Button("Make default");
this.makeDefault.setPosition(20, 70, 10, 10);
this.makeDefault.onClick(() => {
state.defaultChannelId = this.channel.id;
showToast(`${channel.name} is now the default channel.`);
});
this.mergeButton = new Button("Merge");
this.mergeButton.setPosition(40, 70, 10, 10);
this.mergeButton.onClick(() => {
this.mergeChannel();
});
if (state.channelList.channels.length === 1) {
this.mergeButton.setDisabled(true);
}
this.deleteButton = new Button("Delete");
this.deleteButton.setPosition(60, 70, 10, 10);
this.deleteButton.onClick(() => {
this.deleteChannel();
});
this.add(this.nameField);
this.add(this.makeDefault);
this.add(this.mergeButton);
this.add(this.deleteButton);
this.setOkAction(() => {
this.channel.name = this.nameField.getValue();
return this.channel;
});
}
private async mergeChannel() {
const res = await new MergeDialog().open();
if (res) {
this.choose(this.channel);
} else {
return;
}
}
private async deleteChannel() {
const res = await new RemoveDialog(this.channel.id.toString()).open();
if (res) {
this.choose(null);
} else {
return;
}
}
}

View File

@@ -0,0 +1,22 @@
import { API } from "../api";
import { showToast } from "../speech";
import { TextInput } from "../ui";
import { Dialog } from "../ui/dialog";
export class CreateChannelDialog extends Dialog<string> {
private nameField: TextInput;
public constructor() {
super("Create new channel");
this.nameField = new TextInput("Name of new channel");
this.add(this.nameField);
this.setOkAction(() => {
return this.nameField.getValue();
});
this.nameField.onKeyDown((key) => {
if (key === "Enter") {
this.choose(this.nameField.getValue());
}
});
}
}

View File

@@ -0,0 +1,51 @@
import { Button } from "../ui";
import { Dialog } from "../ui/dialog";
import { API } from "../api";
import { Dropdown } from "../ui/dropdown";
import { state } from "../state";
import { showToast } from "../speech";
export class MergeDialog extends Dialog<boolean> {
private channelList: Dropdown;
private mergeButton: Button;
protected cancelButton: Button;
public constructor() {
super("Merge channels", false);
this.channelList = new Dropdown("Target channel", []);
this.channelList.setPosition(10, 10, 80, 20);
this.mergeButton = new Button("Merge");
this.mergeButton.setPosition(30, 30, 40, 30);
this.mergeButton.onClick(() => this.merge());
this.cancelButton = new Button("Cancel");
this.cancelButton.setPosition(30, 70, 40, 30);
this.cancelButton.onClick(() => this.cancel());
this.add(this.channelList);
this.add(this.mergeButton);
this.add(this.cancelButton);
this.setupChannelList();
}
private setupChannelList() {
this.channelList.clearOptions();
state.channelList.getChannels().forEach((channel) => {
if (channel.id !== state.currentChannel!.id) this.channelList.addOption(channel.id.toString(), channel.name);
})
}
private async merge() {
const currentChannel = state.currentChannel;
const target = this.channelList.getSelectedValue();
const targetChannel = state.getChannelById(parseInt(target));
console.log(currentChannel, targetChannel);
if (!targetChannel || !currentChannel) this.cancel();
try {
const res = await API.mergeChannels(currentChannel!.id.toString(), target);
currentChannel!.messages = [];
showToast("Channels were merged.");
this.choose(true);
} catch (e) {
showToast("Failed to merge channels: " + e);
this.choose(false);
}
}
}

View File

@@ -0,0 +1,50 @@
import { API } from "../api";
import { IMessage } from "../model/message";
import { Button, Container, TextInput} from "../ui";
import { Dialog } from "../ui/dialog";
import { Text } from "../ui";
import { MultilineInput } from "../ui/multiline-input";
import { state } from "../state";
export class MessageDialog extends Dialog<IMessage | null> {
private message: IMessage;
private messageText: MultilineInput;
private deleteButton: Button;
private fileInfoContainer?: Container;
public constructor(message: IMessage) {
super("Message");
this.message = message;
this.messageText = new MultilineInput("Message");
this.messageText.setValue(message.content);
this.messageText.setPosition(10, 10, 80, 20);
this.deleteButton = new Button("Delete");
this.deleteButton.setPosition(10, 90, 80, 10);
this.deleteButton.onClick(async () => {
await API.deleteMessage(state.currentChannel!.id.toString(), this.message.id.toString());
this.choose(null);
});
this.add(this.messageText);
this.add(this.deleteButton);
if (this.message.fileId !== null) {
this.fileInfoContainer = new Container("File info");
this.fileInfoContainer.setPosition(10, 50, 30, 80);
this.add(this.fileInfoContainer);
this.handleMessage();
}
}
private handleMessage() {
if (this.message?.fileType?.toLowerCase().includes("audio")) {
const audio = new Audio(`${API.path}/${this.message.filePath}`);
audio.autoplay = true;
}
// display info about files, or the image if it is an image. Also display all metadata.
this.fileInfoContainer?.add(new Text(`File type: ${this.message.fileType}`));
this.fileInfoContainer?.add(new Text(`File path: ${this.message.filePath}`));
this.fileInfoContainer?.add(new Text(`File ID: ${this.message.fileId}`));
this.fileInfoContainer?.add(new Text(`File size: ${this.message.fileSize}`));
this.fileInfoContainer?.add(new Text(`Original name: ${this.message.originalName}`));
}
}

View File

@@ -0,0 +1,72 @@
import { Button } from "../ui";
import { Audio } from "../ui/audio";
import { AudioRecorder } from "../ui/audio-recorder";
import { Dialog } from "../ui/dialog";
export class RecordAudioDialog extends Dialog<Blob> {
private audioRecorder: AudioRecorder;
private recordButton: Button;
private stopButton: Button;
private playButton: Button;
private saveButton: Button;
private discardButton: Button;
private audioBlob: Blob | undefined;
private audioPlayer?: Audio;
constructor() {
super("Record audio", false);
this.audioRecorder = new AudioRecorder("Record from microphone");
this.audioRecorder.onRecordingComplete(() => {
this.audioBlob = this.audioRecorder.getRecording();
this.saveButton.setDisabled(false);
});
this.recordButton = new Button("Record");
this.recordButton.setPosition(30, 30, 40, 30);
this.recordButton.onClick(() => this.startRecording());
this.stopButton = new Button("Stop");
this.stopButton.setPosition(70, 40, 30, 30);
this.stopButton.onClick(() => this.stopRecording());
this.stopButton.setDisabled(true);
this.saveButton = new Button("Save");
this.saveButton.setPosition(10, 80, 50, 20);
this.saveButton.onClick(() => this.saveRecording());
this.saveButton.setDisabled(true);
this.playButton = new Button("Play");
this.playButton.setPosition(0, 40, 30, 30);
this.playButton.onClick(() => {
if (this.audioBlob) {
this.audioPlayer = new Audio("Recorded audio");
this.audioPlayer.setSource(URL.createObjectURL(this.audioBlob));
this.audioPlayer.play();
}
});
this.playButton.setDisabled(true);
this.discardButton = new Button("Discard");
this.discardButton.setPosition(50, 90, 50, 10);
this.discardButton.onClick(() => this.cancel());
this.add(this.recordButton);
this.add(this.stopButton);
this.add(this.playButton);
this.add(this.saveButton);
this.add(this.discardButton);
}
private startRecording() {
this.audioRecorder.startRecording();
this.stopButton.setDisabled(false);
this.recordButton.setDisabled(true);
}
private stopRecording() {
this.audioRecorder.stopRecording();
this.recordButton.setDisabled(false);
this.stopButton.setDisabled(true);
this.playButton.setDisabled(false);
}
private saveRecording() {
if (this.audioBlob) {
this.choose(this.audioBlob);
}
}
}

View File

@@ -0,0 +1,39 @@
import { Button } from "../ui";
import { Dialog } from "../ui/dialog";
import { Text } from "../ui";
import { API } from "../api";
import { state } from "../state";
import { showToast } from "../speech";
export class RemoveDialog extends Dialog<boolean> {
private content: Text;
private confirmButton: Button;
protected cancelButton: Button;
public constructor(channelId: string) {
super("Remove channel", false);
this.content = new Text("Are you sure you want to remove this channel?");
this.confirmButton = new Button("Remove");
this.confirmButton.setPosition(30, 30, 40, 30);
this.confirmButton.onClick(() => this.doRemove());
this.cancelButton = new Button("Cancel");
this.cancelButton.setPosition(30, 70, 40, 30);
this.cancelButton.onClick(() => this.cancel());
this.add(this.content);
this.add(this.confirmButton);
this.add(this.cancelButton);
}
private async doRemove() {
try {
const res = await API.deleteChannel(state.currentChannel!.id.toString());
state.removeChannel(state.currentChannel!);
showToast("Channel was removed.");
this.choose(true);
} catch (e) {
showToast("Failed to remove channel: " + e);
this.choose(false);
}
}
}

View File

@@ -0,0 +1,48 @@
import { API } from "../api";
import { IMessage } from "../model/message";
import { Button, List, ListItem, TextInput } from "../ui";
import { Dialog } from "../ui/dialog";
export class SearchDialog extends Dialog<{channelId: number, messageId: number}> {
private searchField: TextInput;
private searchButton: Button;
private resultsList: List;
private closeButton: Button;
public constructor() {
super("Search for message", false);
this.searchField = new TextInput("Search query");
this.searchField.setPosition(5, 5, 80, 20);
this.searchField.onKeyDown((key) => {
if (key === "Enter") {
this.searchButton.click();
}
});
this.searchButton = new Button("Search");
this.searchButton.setPosition(85, 5, 10, 20);
this.searchButton.onClick(async () => {
const messages = await API.search(this.searchField.getValue());
console.log(messages);
this.renderResults(messages);
})
this.resultsList = new List("Results");
this.resultsList.setPosition(5, 20, 90, 70);
this.closeButton = new Button("Close");
this.closeButton.setPosition(5, 90, 90, 5);
this.closeButton.onClick(() => this.cancel());
this.add(this.searchField);
this.add(this.searchButton);
this.add(this.resultsList);
this.add(this.closeButton);
}
private renderResults(messages: IMessage[]) {
this.resultsList.clear();
messages.forEach((message) => {
const itm = new ListItem(`${message.content}; ${message.createdAt}`);
itm.onClick(() => this.choose({ messageId: message.id, channelId: message.channelId! }));
this.resultsList.add(itm);
});
this.resultsList.focus();
}
}

View File

@@ -0,0 +1,23 @@
import { Button } from "../ui";
import { Dialog } from "../ui/dialog";
import { state } from "../state";
export class SettingsDialog extends Dialog<void> {
private resetButton: Button;
public constructor() {
super("Settings");
this.resetButton = new Button("Reset frontend");
this.resetButton.setPosition(30, 20, 30, 30);
this.resetButton.onClick(() => {
this.reset();
});
this.add(this.resetButton);
}
private reset() {
state.clear().then(() => {
window.location.reload();
});
}
}

View File

@@ -0,0 +1,30 @@
import { API } from "../api";
import { state } from "../state";
import { Button } from "../ui";
import { Camera } from "../ui/camera";
import { Dialog } from "../ui/dialog";
export class TakePhotoDialog extends Dialog<Blob> {
private camera: Camera;
private takePhotoButton: Button;
private discardButton: Button;
constructor() {
super("Take photo", false);
this.camera = new Camera("Photo camera");
this.camera.setPosition(10, 15, 80, 75);
this.camera.startCamera();
this.takePhotoButton = new Button("Take photo");
this.takePhotoButton.setPosition(10, 90, 80, 10);
this.discardButton = new Button("Cancel");
this.discardButton.setPosition(5, 5, 10, 10);
this.discardButton.onClick(() => this.cancel());
this.add(this.camera);
this.add(this.takePhotoButton);
this.add(this.discardButton);
this.takePhotoButton.onClick(async () => {
const photo = await this.camera.savePhotoToBlob();
if (photo) this.choose(photo);
});
}
}

View File

@@ -0,0 +1,28 @@
export type MessageCreated = {
channelId: string,
id: string,
content: string,
};
export type MessageDeleted = {
channelId: string,
messageId: string,
};
export type MessageUpdated = {
id: string,
content: string,
};
export type ChannelCreated = {
name: string,
};
export type ChannelDeleted = {
channelId: string,
};
export type ChannelUpdated = {
channelId: string,
name: string,
};

View File

@@ -0,0 +1,60 @@
export type Message<T> = {
type: string,
data?: T,
};
export type MessageHandler<T> = (message: Message<T>) => void;
export class MessagingSystem {
private handlers: Record<string, MessageHandler<any>[]> = {};
public registerHandler<T>(type: string, handler: MessageHandler<T>): void {
if (!this.handlers[type]) {
this.handlers[type] = [];
}
if (!this.handlers[type].includes(handler)) {
this.handlers[type].push(handler);
}
}
public unregisterHandler<T>(type: string, handler: MessageHandler<T>): void {
if (this.handlers[type]) {
this.handlers[type] = this.handlers[type].filter(h => h !== handler);
}
}
public registerHandlerOnce<T>(type: string, handler: MessageHandler<T>): void {
const wrappedHandler = (message: Message<T>) => {
handler(message);
this.unregisterHandler(type, wrappedHandler);
};
this.registerHandler(type, wrappedHandler);
}
public waitForMessage<T>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => {
const handler = (message: Message<T>) => {
if (timer) clearTimeout(timer);
resolve(message.data!);
this.unregisterHandler(type, handler);
};
this.registerHandler(type, handler);
let timer: ReturnType<typeof setTimeout> | undefined;
if (timeout) {
timer = setTimeout(() => {
this.unregisterHandler(type, handler);
reject(new Error(`Timeout waiting for message of type '${type}'`));
}, timeout);
}
});
}
public sendMessage<T>(message: Message<T>): void {
const handlers = this.handlers[message.type];
if (handlers) {
handlers.forEach(handler => handler(message));
}
}
}

22
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,22 @@
import './style.css'
import { MainView } from "./views/main";
import { ViewManager } from './views/view-manager';
import { AuthorizeView } from './views/authorize';
import { state } from './state';
import { API } from './api';
document.addEventListener("DOMContentLoaded", async () => {
await state.load();
const vm = new ViewManager();
setInterval(() => {
state.save();
}, 10000);
if (state.token === "" || state.apiUrl === "") {
vm.push(new AuthorizeView(vm));
} else {
vm.push(new MainView(vm));
}
document.body.appendChild(vm.render() as HTMLElement);
});

View File

@@ -0,0 +1,46 @@
import { Channel, IChannel } from "./channel";
export interface IChannelList {
channels: IChannel[]
}
export class ChannelList implements IChannelList {
channels: Channel[] = [];
constructor(channels?: IChannelList) {
this.channels = channels?.channels?.map((chan) => new Channel(chan)) || [];
}
public addChannel(channel: Channel): void {
this.channels.push(channel);
}
public removeChannel(channelId: number): void {
this.channels = this.channels.filter(channel => channel.id !== channelId);
}
public getChannel(channelId: number): Channel|undefined {
return this.channels.find(channel => channel.id === channelId);
}
public getChannelByName(channelName: string): IChannel|undefined {
return this.channels.find(channel => channel.name === channelName);
}
public getChannels(): Channel[] {
return this.channels;
}
public getChannelIds(): number[] {
return this.channels.map(channel => channel.id);
}
public getChannelNames(): string[] {
return this.channels.map(channel => channel.name);
}
public getChannelId(channelName: string): number|undefined {
const channel = this.getChannelByName(channelName);
return channel ? channel.id : undefined;
}
}

View File

@@ -0,0 +1,60 @@
import { IMessage, Message } from "./message";
export interface IChannel {
id: number;
name: string;
messages: IMessage[];
createdAt: number;
}
export class Channel implements IChannel {
id: number;
name: string;
messages: Message[];
createdAt: number;
private messageToIdMap: Map<number, Message>;
constructor(channel: IChannel) {
this.id = channel.id;
this.name = channel.name;
this.messages = [];
this.messageToIdMap = new Map();
channel.messages?.forEach((msg) => this.addMessage(new Message(msg)));
this.createdAt = channel.createdAt;
}
public addMessage(message: Message): void {
this.messages.push(message);
this.messageToIdMap.set(message.id, message);
}
public removeMessage(messageId: number): void {
this.messages = this.messages.filter(message => message.id !== messageId);
this.messageToIdMap.delete(messageId);
}
public getMessage(messageId: number): Message|undefined {
return this.messageToIdMap.get(messageId);
}
public getMessageByContent(content: string): Message|undefined {
return this.messages.find(message => message.content === content);
}
public getMessages(): Message[] {
return this.messages;
}
public getMessageIds(): number[] {
return this.messages.map(message => message.id);
}
public getMessageContents(): string[] {
return this.messages.map(message => message.content);
}
public getMessageId(content: string): number|undefined {
const message = this.getMessageByContent(content);
return message ? message.id : undefined;
}
}

View File

@@ -0,0 +1,33 @@
export interface IMessage {
id: number;
channelId?: number;
content: string;
fileId?: number;
fileType?: string;
filePath?: string;
fileSize?: number;
originalName?: string;
createdAt: string;
}
export class Message implements IMessage {
id: number;
content: string;
fileId?: number;
fileType?: string;
filePath?: string;
fileSize?: number;
originalName?: string;
createdAt: string;
constructor(message: IMessage) {
this.id = message.id;
this.content = message.content;
this.fileId = message.fileId;
this.fileType = message.fileType;
this.filePath = message.filePath;
this.fileSize = message.fileSize;
this.originalName = message.originalName;
this.createdAt = message.createdAt;
}
}

View File

@@ -0,0 +1,10 @@
import { IChannelList } from "./channel-list";
import { IUnsentMessage } from "./unsent-message";
export interface IState {
token: string;
apiUrl: string;
defaultChannelId: number;
channelList: IChannelList;
unsentMessages: IUnsentMessage[];
}

View File

@@ -0,0 +1,23 @@
export interface IUnsentMessage {
id: number;
content: string;
blob?: Blob;
createdAt: string;
channelId: number;
}
export class UnsentMessage implements IUnsentMessage {
id: number;
content: string;
blob?: Blob;
createdAt: string;
channelId: number;
constructor(message: IUnsentMessage) {
this.id = message.id;
this.content = message.content;
this.blob = message.blob;
this.createdAt = message.createdAt;
this.channelId = message.channelId;
}
}

View File

@@ -0,0 +1,62 @@
const CACHE_NAME = 'notebrook-cache-v1';
const urlsToCache = [
'/',
'/index.html',
'/favicon.ico',
'/intro.wav',
'/login.wav',
'/uploadfail.wav',
'/water1.wav',
'/water2.wav',
'/water3.wav',
'/water4.wav',
'/water5.wav',
'/water6.wav',
'/water7.wav',
'/water8.wav',
'/water9.wav',
'/water10.wav',
'/sent1.wav',
'/sent2.wav',
'/sent3.wav',
'/sent4.wav',
'/sent5.wav',
'/sent6.wav',
'/vite.svg',
'/src/main.ts'
];
self.addEventListener('install', (event: any) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', (event: any) => {
event.respondWith(
caches.match(event.request)
.then(response => {
// Return the cached response if found, otherwise fetch from network
return response || fetch(event.request);
})
);
});
self.addEventListener('activate', (event: any) => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

80
frontend/src/sound.ts Normal file
View File

@@ -0,0 +1,80 @@
const audioContext = new AudioContext();
const soundFiles = {
intro: 'intro.wav',
login: 'login.wav',
uploadFailed: 'uploadfail.wav'
} as const;
type SoundName = keyof typeof soundFiles;
const sounds: Partial<Record<SoundName, AudioBuffer>> = {};
const waterSounds: AudioBuffer[] = [];
const sentSounds: AudioBuffer[] = [];
async function loadSound(url: string): Promise<AudioBuffer> {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
return await audioContext.decodeAudioData(arrayBuffer);
}
async function loadAllSounds() {
for (const key in soundFiles) {
const soundName = key as SoundName;
sounds[soundName] = await loadSound(soundFiles[soundName]);
}
for (let i = 1; i <= 10; i++) {
const buffer = await loadSound(`water${i}.wav`);
waterSounds.push(buffer);
}
for (let i = 1; i <= 6; i++) {
const buffer = await loadSound(`sent${i}.wav`);
sentSounds.push(buffer);
}
}
function playSoundBuffer(buffer: AudioBuffer) {
if (audioContext.state === 'suspended') {
audioContext.resume();
}
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.start(0);
}
export function playSound(name: SoundName) {
const buffer = sounds[name];
if (buffer) {
playSoundBuffer(buffer);
} else {
console.error(`Sound ${name} not loaded.`);
}
}
export function playWater() {
if (waterSounds.length > 0) {
const sound = waterSounds[Math.floor(Math.random() * waterSounds.length)];
playSoundBuffer(sound);
} else {
console.error("Water sounds not loaded.");
}
}
export function playSent() {
if (sentSounds.length > 0) {
const sound = sentSounds[Math.floor(Math.random() * sentSounds.length)];
playSoundBuffer(sound);
} else {
console.error("Sent sounds not loaded.");
}
}
loadAllSounds().then(() => {
console.log('All sounds loaded and ready to play');
}).catch(error => {
console.error('Error loading sounds:', error);
});

14
frontend/src/speech.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Toast } from "./toast";
export function speak(text: string, interrupt: boolean = false) {
const utterance = new SpeechSynthesisUtterance(text);
if (interrupt) {
speechSynthesis.cancel();
}
speechSynthesis.speak(utterance);
}
export function showToast(message: string, timeout: number = 5000) {
const toast = new Toast(timeout);
toast.show(message);
}

137
frontend/src/state.ts Normal file
View File

@@ -0,0 +1,137 @@
import { MessagingSystem } from "./events/messaging-system";
import { IChannel, Channel } from "./model/channel";
import { IChannelList, ChannelList } from "./model/channel-list";
import { IState } from "./model/state";
import { IUnsentMessage, UnsentMessage } from "./model/unsent-message";
import { get, set, clear } from "idb-keyval";
export class State implements IState {
token!: string;
apiUrl!: string;
channelList!: ChannelList;
unsentMessages!: IUnsentMessage[];
currentChannel!: Channel | null;
defaultChannelId!: number;
public events: MessagingSystem;
constructor() {
this.token = "";
this.channelList = new ChannelList();
this.unsentMessages = [];
this.events = new MessagingSystem();
}
public getToken(): string {
return this.token;
}
public setToken(token: string): void {
this.token = token;
}
public getChannelList(): IChannelList {
return this.channelList;
}
public setChannelList(channelList: ChannelList): void {
this.channelList = channelList;
}
public getUnsentMessages(): IUnsentMessage[] {
return this.unsentMessages;
}
public setUnsentMessages(unsentMessages: IUnsentMessage[]): void {
this.unsentMessages = unsentMessages;
}
public async save(): Promise<void> {
// stringify everything here except the currentChannel object.
const { currentChannel, events, ...state } = this;
await set("notebrook", state);
}
public async load(): Promise<void> {
const saved = await get("notebrook");
if (saved) {
this.token = saved.token;
this.apiUrl = saved.apiUrl;
this.channelList = new ChannelList( saved.channelList);
this.unsentMessages = saved.unsentMessages.map((message: IUnsentMessage) => new UnsentMessage(message));
this.defaultChannelId = saved.defaultChannelId;
}
}
public async clear(): Promise<void> {
this.token = "";
this.channelList = new ChannelList();
this.unsentMessages = [];
this.currentChannel = null;
this.defaultChannelId = -1;
await clear();
}
public getChannelById(id: number) {
return this.channelList.getChannel(id);
}
public getChannelByName(name: string) {
return this.channelList.getChannelByName(name);
}
public findChannelByQuery(query: string) {
return this.channelList.channels.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
}
public addChannel(channel: Channel) {
if (!this.channelList.channels.find((c) => c.id === channel.id)) this.channelList.channels.push(channel);
}
public removeChannel(channel: IChannel) {
this.channelList.channels = this.channelList.channels.filter((c) => c.id !== channel.id);
}
public addUnsentMessage(message: UnsentMessage) {
this.unsentMessages.push(message);
}
public removeUnsentMessage(message: IUnsentMessage) {
this.unsentMessages = this.unsentMessages.filter((m) => m !== message);
}
public getChannels() {
return this.channelList.channels;
}
public getCurrentChannel() {
return this.currentChannel;
}
public setCurrentChannel(channel: Channel) {
this.currentChannel = channel;
}
public getDefaultChannelId() {
return this.defaultChannelId;
}
public setDefaultChannelId(id: number) {
this.defaultChannelId = id;
}
public getApiUrl() {
return this.apiUrl;
}
public setApiUrl(url: string) {
this.apiUrl = url;
}
public getMessageById(id: number) {
return this.currentChannel!.getMessage(id);
}
}
export const state = new State();

96
frontend/src/style.css Normal file
View File

@@ -0,0 +1,96 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.vanilla:hover {
filter: drop-shadow(0 0 2em #3178c6aa);
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

32
frontend/src/toast.ts Normal file
View File

@@ -0,0 +1,32 @@
export class Toast {
private container: HTMLElement;
private timeout: number;
constructor(timeout: number = 3000) {
this.container = document.querySelector('.toast-container') as HTMLElement;
this.timeout = timeout;
}
public show(message: string): void {
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
this.container.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => {
this.hide(toast);
}, this.timeout);
}
private hide(toast: HTMLElement): void {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => {
toast.remove();
});
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,76 @@
import { UINode } from "./node";
export class AudioRecorder extends UINode {
private audioElement: HTMLAudioElement;
private mediaRecorder: MediaRecorder | null;
private audioChunks: Blob[];
private stream: MediaStream | null;
private recording?: Blob;
public constructor(title: string) {
super(title);
this.audioElement = document.createElement("audio");
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.audioElement.setAttribute("controls", "true");
this.audioElement.setAttribute("aria-label", title);
this.element.appendChild(this.audioElement);
this.setRole("audio-recorder");
}
public async startRecording() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { autoGainControl: true, channelCount: 2, echoCancellation: false, noiseSuppression: false } });
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
this.mediaRecorder.onstop = () => {
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
this.recording = audioBlob;
this.audioChunks = [];
const audioUrl = URL.createObjectURL(audioBlob);
this.audioElement.src = audioUrl;
this.triggerRecordingComplete(audioUrl);
};
this.mediaRecorder.start();
} catch (error) {
console.error("Error accessing microphone:", error);
}
}
public stopRecording() {
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
this.mediaRecorder.stop();
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
}
public getElement(): HTMLElement {
return this.element;
}
public onRecordingComplete(callback: (audioUrl: string) => void) {
this.element.addEventListener("recording-complete", (event: Event) => {
const customEvent = event as CustomEvent;
callback(customEvent.detail.audioUrl);
});
return this;
}
protected triggerRecordingComplete(audioUrl: string) {
const event = new CustomEvent("recording-complete", { detail: { audioUrl } });
this.element.dispatchEvent(event);
return this;
}
public getRecording() {
return this.recording;
}
}

66
frontend/src/ui/audio.ts Normal file
View File

@@ -0,0 +1,66 @@
import { UINode } from "./node";
export class Audio extends UINode {
private audioElement: HTMLAudioElement;
public constructor(title: string, src: string | MediaStream = "") {
super(title);
this.audioElement = document.createElement("audio");
if (typeof src === "string") {
this.audioElement.src = src; // Set src if it's a string URL
} else if (src instanceof MediaStream) {
this.audioElement.srcObject = src; // Set srcObject if it's a MediaStream
}
this.audioElement.setAttribute("aria-label", title);
this.element.appendChild(this.audioElement);
this.setRole("audio");
}
public getElement(): HTMLElement {
return this.audioElement;
}
public setSource(src: string | MediaStream) {
if (typeof src === "string") {
this.audioElement.src = src;
} else if (src instanceof MediaStream) {
this.audioElement.srcObject = src;
}
return this;
}
public play() {
this.audioElement.play();
return this;
}
public pause() {
this.audioElement.pause();
return this;
}
public setControls(show: boolean) {
this.audioElement.controls = show;
return this;
}
public setLoop(loop: boolean) {
this.audioElement.loop = loop;
return this;
}
public setMuted(muted: boolean) {
this.audioElement.muted = muted;
return this;
}
public setAutoplay(autoplay: boolean) {
this.audioElement.autoplay = autoplay;
return this;
}
public setVolume(volume: number) {
this.audioElement.volume = volume;
return this;
}
}

39
frontend/src/ui/button.ts Normal file
View File

@@ -0,0 +1,39 @@
import { UINode } from "./node";
export class Button extends UINode {
private buttonElement: HTMLButtonElement;
public constructor(title: string, hasPopup: boolean = false) {
super(title);
this.buttonElement = document.createElement("button");
this.buttonElement.innerText = title;
if (hasPopup) this.buttonElement.setAttribute("aria-haspopup", "true");
this.element.appendChild(this.buttonElement);
this.element.setAttribute("aria-label", this.title);
}
public focus() {
this.buttonElement.focus();
return this;
}
public click() {
this.buttonElement.click();
return this;
}
public getElement(): HTMLElement {
return this.buttonElement;
}
public setText(text: string) {
this.title = text;
this.buttonElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
return this;
}
public setDisabled(val: boolean) {
this.buttonElement.disabled = val;
return this;
}
}

77
frontend/src/ui/camera.ts Normal file
View File

@@ -0,0 +1,77 @@
import { UINode } from "./node";
export class Camera extends UINode {
private videoElement: HTMLVideoElement;
private canvasElement: HTMLCanvasElement;
private stream: MediaStream | null;
public constructor(title: string) {
super(title);
this.videoElement = document.createElement("video");
this.canvasElement = document.createElement("canvas");
this.stream = null;
this.videoElement.setAttribute("aria-label", title);
this.element.appendChild(this.videoElement);
this.element.appendChild(this.canvasElement);
this.setRole("camera");
}
public async startCamera() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ video: true });
this.videoElement.srcObject = this.stream;
this.videoElement.play();
} catch (error) {
console.error("Error accessing camera:", error);
}
}
public stopCamera() {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
this.videoElement.pause();
this.videoElement.srcObject = null;
}
public takePhoto(): HTMLCanvasElement | null {
if (this.stream) {
const context = this.canvasElement.getContext("2d");
if (context) {
this.canvasElement.width = this.videoElement.videoWidth;
this.canvasElement.height = this.videoElement.videoHeight;
context.drawImage(this.videoElement, 0, 0, this.canvasElement.width, this.canvasElement.height);
return this.canvasElement;
}
}
return null;
}
public savePhoto(): string | null {
const photoCanvas = this.takePhoto();
if (photoCanvas) {
return photoCanvas.toDataURL("image/png");
}
return null;
}
public savePhotoToBlob(): Promise<Blob | null> {
return new Promise((resolve) => {
const photoCanvas = this.takePhoto();
if (photoCanvas) {
photoCanvas.toBlob((blob) => {
resolve(blob);
});
} else {
resolve(null);
}
});
}
public getElement(): HTMLElement {
return this.element;
}
}

26
frontend/src/ui/canvas.ts Normal file
View File

@@ -0,0 +1,26 @@
import { UINode } from "./node";
export class Canvas extends UINode {
private canvasElement: HTMLCanvasElement;
public constructor(title: string) {
super(title);
this.canvasElement = document.createElement("canvas");
this.canvasElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.canvasElement);
}
public focus() {
this.canvasElement.focus();
return this;
}
public click() {
this.canvasElement.click();
return this;
}
public getElement(): HTMLElement {
return this.canvasElement;
}
}

Some files were not shown because too many files have changed in this diff Show More