feat: not uygulaması ve altyapısını ekle

- iOS Memos benzeri PWA ön yüz eklendi (React, Tailwind)
- Express tabanlı arka uç, AnythingLLM API entegrasyonu ve senkronizasyon kuyruğu oluşturuldu
- Docker, TypeScript ve proje konfigürasyonları tanımlandı
This commit is contained in:
2025-12-28 23:37:38 +03:00
commit 05bbe307e0
58 changed files with 2142 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import { promises as fs } from "fs";
import path from "path";
import { config } from "../config.js";
import type { AnythingLLMUploadMetadata, AnythingLLMUploadResponse } from "./anythingllm.types.js";
function baseHeaders(): Record<string, string> {
return {
Authorization: `Bearer ${config.ANYTHINGLLM_API_KEY}`
};
}
async function parseJsonResponse<T>(response: Response, label: string): Promise<T> {
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) {
const text = await response.text();
throw new Error(`${label} beklenmeyen yanit: ${response.status} ${text.slice(0, 200)}`);
}
return (await response.json()) as T;
}
export async function uploadDocument(filePath: string, metadata: AnythingLLMUploadMetadata): Promise<AnythingLLMUploadResponse> {
const form = new FormData();
const fileBuffer = await fs.readFile(filePath);
const fileBlob = new Blob([fileBuffer], { type: "text/markdown" });
form.append("file", fileBlob, path.basename(filePath));
form.append("addToWorkspaces", config.ANYTHINGLLM_WORKSPACE_SLUG);
form.append("metadata", JSON.stringify(metadata));
const response = await fetch(`${config.ANYTHINGLLM_ROOT_API_URL}/v1/document/upload`, {
method: "POST",
headers: {
...baseHeaders()
},
body: form
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload basarisiz: ${response.status} ${text}`);
}
return await parseJsonResponse<AnythingLLMUploadResponse>(response, "Upload");
}
export async function updateEmbeddings(adds: string[], deletes: string[]): Promise<void> {
const response = await fetch(
`${config.ANYTHINGLLM_ROOT_API_URL}/v1/workspace/${config.ANYTHINGLLM_WORKSPACE_SLUG}/update-embeddings`,
{
method: "POST",
headers: {
...baseHeaders(),
"Content-Type": "application/json"
},
body: JSON.stringify({ adds, deletes })
}
);
if (!response.ok) {
const text = await response.text();
throw new Error(`Embeddings guncelleme basarisiz: ${response.status} ${text}`);
}
await parseJsonResponse(response, "Embeddings guncelleme");
}
export async function removeDocuments(locations: string[]): Promise<void> {
const response = await fetch(`${config.ANYTHINGLLM_ROOT_API_URL}/v1/system/remove-documents`, {
method: "DELETE",
headers: {
...baseHeaders(),
"Content-Type": "application/json"
},
body: JSON.stringify({ names: locations })
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Dokuman temizleme basarisiz: ${response.status} ${text}`);
}
await parseJsonResponse(response, "Dokuman temizleme");
}

View File

@@ -0,0 +1,14 @@
import type { AnythingLLMUploadMetadata } from "./anythingllm.types.js";
import { removeDocuments, updateEmbeddings, uploadDocument } from "./anythingllm.client.js";
export async function uploadNote(filePath: string, metadata: AnythingLLMUploadMetadata) {
return uploadDocument(filePath, metadata);
}
export async function updateWorkspaceEmbeddings(adds: string[], deletes: string[]) {
await updateEmbeddings(adds, deletes);
}
export async function cleanupDocuments(locations: string[]) {
await removeDocuments(locations);
}

View File

@@ -0,0 +1,17 @@
export type AnythingLLMUploadMetadata = {
title: string;
docAuthor: string;
description: string;
docSource: string;
};
export type AnythingLLMUploadResponse = {
documents: Array<{
id: string;
location: string;
}>;
};
export type AnythingLLMEmbeddingsResponse = {
success: boolean;
};

View File

@@ -0,0 +1,32 @@
import type { Request, Response, NextFunction } from "express";
import bcrypt from "bcryptjs";
import { config } from "../config.js";
function unauthorized(res: Response): void {
res.status(401).json({ error: "Yetkisiz" });
}
export async function basicAuth(req: Request, res: Response, next: NextFunction): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Basic ")) {
unauthorized(res);
return;
}
const encoded = authHeader.slice("Basic ".length).trim();
const decoded = Buffer.from(encoded, "base64").toString("utf-8");
const [username, password] = decoded.split(":");
if (!username || !password || username !== config.AUTH_USERNAME) {
unauthorized(res);
return;
}
const isValid = await bcrypt.compare(password, config.AUTH_PASSWORD_HASH);
if (!isValid) {
unauthorized(res);
return;
}
next();
}

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

@@ -0,0 +1,24 @@
import dotenv from "dotenv";
import path from "path";
dotenv.config();
const PORT = Number(process.env.PORT || 8080);
const NOTES_DIR = process.env.NOTES_DIR || path.resolve("/data/notes");
const AUTH_USERNAME = process.env.AUTH_USERNAME || "admin";
const AUTH_PASSWORD_HASH = process.env.AUTH_PASSWORD_HASH || "";
const ANYTHINGLLM_ROOT_API_URL = process.env.ANYTHINGLLM_ROOT_API_URL || "";
const ANYTHINGLLM_API_KEY = process.env.ANYTHINGLLM_API_KEY || "";
const ANYTHINGLLM_WORKSPACE_SLUG = process.env.ANYTHINGLLM_WORKSPACE_SLUG || "";
export const config = {
PORT,
NOTES_DIR,
AUTH_USERNAME,
AUTH_PASSWORD_HASH,
ANYTHINGLLM_ROOT_API_URL,
ANYTHINGLLM_API_KEY,
ANYTHINGLLM_WORKSPACE_SLUG
};

View File

@@ -0,0 +1,65 @@
import { Router } from "express";
import { createNote, deleteNote, getNote, listNotes, manualSync, updateNote } from "./notes.service.js";
import type { CleanupQueue } from "../queue/queue.js";
export function notesRoutes(queue: CleanupQueue): Router {
const router = Router();
router.get("/notes", async (_req, res) => {
const notes = await listNotes();
res.json(notes);
});
router.get("/notes/:id", async (req, res) => {
const note = await getNote(req.params.id);
if (!note) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.json(note);
});
router.post("/notes", async (req, res) => {
const { title, content } = req.body as { title?: string; content?: string };
if (!title) {
res.status(400).json({ error: "Baslik gerekli" });
return;
}
const note = await createNote(title, content ?? "", queue);
res.status(201).json(note);
});
router.put("/notes/:id", async (req, res) => {
const { title, content } = req.body as { title?: string; content?: string };
if (!title) {
res.status(400).json({ error: "Baslik gerekli" });
return;
}
const note = await updateNote(req.params.id, title, content ?? "", queue);
if (!note) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.json(note);
});
router.delete("/notes/:id", async (req, res) => {
const ok = await deleteNote(req.params.id, queue);
if (!ok) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.status(204).send();
});
router.post("/notes/:id/sync", async (req, res) => {
const ok = await manualSync(req.params.id, queue);
if (!ok) {
res.status(404).json({ error: "Not bulunamadi" });
return;
}
res.status(202).json({ status: "syncing" });
});
return router;
}

View File

@@ -0,0 +1,174 @@
import { v4 as uuidv4 } from "uuid";
import type { NoteIndexItem, NoteContent } from "./notes.types.js";
import { readIndex, writeIndex, readNoteContent, writeNoteContent, deleteNoteFile } from "./notes.storage.js";
import { uploadDocument, updateEmbeddings } from "../anythingllm/anythingllm.client.js";
import { config } from "../config.js";
import { logError } from "../utils/logger.js";
import type { CleanupQueue } from "../queue/queue.js";
function nowIso(): string {
return new Date().toISOString();
}
function buildMetadata(title: string, content: string) {
const description = content.replace(/\s+/g, " ").slice(0, 120);
return {
title,
docAuthor: "Note App",
description,
docSource: "notes-app"
};
}
export async function listNotes(): Promise<NoteIndexItem[]> {
const index = await readIndex();
return index.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function getNote(id: string): Promise<NoteContent | null> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return null;
}
return readNoteContent(item.id, item.filename);
}
export async function createNote(title: string, content: string, queue: CleanupQueue): Promise<NoteIndexItem> {
const id = uuidv4();
const filename = `${id}.md`;
const timestamp = nowIso();
await writeNoteContent(filename, title, content);
const newItem: NoteIndexItem = {
id,
title,
filename,
createdAt: timestamp,
updatedAt: timestamp,
sync: {
status: "syncing"
}
};
const index = await readIndex();
index.push(newItem);
await writeIndex(index);
void syncNote(id, queue);
return newItem;
}
export async function updateNote(id: string, title: string, content: string, queue: CleanupQueue): Promise<NoteIndexItem | null> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return null;
}
await writeNoteContent(item.filename, title, content);
item.title = title;
item.updatedAt = nowIso();
item.sync.status = "syncing";
item.sync.lastError = undefined;
await writeIndex(index);
void syncNote(id, queue);
return item;
}
export async function deleteNote(id: string, queue: CleanupQueue): Promise<boolean> {
const index = await readIndex();
const itemIndex = index.findIndex((note) => note.id === id);
if (itemIndex === -1) {
return false;
}
const [item] = index.splice(itemIndex, 1);
await writeIndex(index);
await deleteNoteFile(item.filename);
if (item.sync.anythingllmLocation) {
try {
await updateEmbeddings([], [item.sync.anythingllmLocation]);
await queue.enqueue({
id: item.id,
oldLocation: item.sync.anythingllmLocation,
attempts: 0,
nextRunAt: new Date().toISOString()
});
} catch (error) {
logError("Silme senkron hatasi", { error: (error as Error).message });
}
}
return true;
}
export async function manualSync(id: string, queue: CleanupQueue): Promise<boolean> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return false;
}
item.sync.status = "syncing";
item.sync.lastError = undefined;
await writeIndex(index);
void syncNote(id, queue);
return true;
}
async function syncNote(id: string, queue: CleanupQueue): Promise<void> {
const index = await readIndex();
const item = index.find((note) => note.id === id);
if (!item) {
return;
}
const oldLocation = item.sync.anythingllmLocation;
try {
const note = await readNoteContent(item.id, item.filename);
const metadata = buildMetadata(note.title, note.content);
const uploadResponse = await uploadDocument(`${config.NOTES_DIR}/${item.filename}`, metadata);
const newDoc = uploadResponse.documents[0];
if (!newDoc) {
throw new Error("Upload cevabinda dokuman bulunamadi");
}
await updateEmbeddings([newDoc.location], oldLocation ? [oldLocation] : []);
item.sync.status = "synced";
item.sync.anythingllmLocation = newDoc.location;
item.sync.anythingllmDocumentId = newDoc.id;
item.sync.lastSyncedAt = nowIso();
item.sync.cleanupPending = false;
item.sync.lastError = undefined;
await writeIndex(index);
if (oldLocation) {
item.sync.cleanupPending = true;
await writeIndex(index);
await queue.enqueue({
id: item.id,
oldLocation,
attempts: 0,
nextRunAt: new Date().toISOString()
});
}
} catch (error) {
logError("Senkronizasyon hatasi", {
noteId: item.id,
error: (error as Error).message,
oldLocation: oldLocation ?? null
});
item.sync.status = "error";
item.sync.lastError = (error as Error).message;
await writeIndex(index);
}
}

View File

@@ -0,0 +1,51 @@
import path from "path";
import { promises as fs } from "fs";
import { ensureDir, readJsonFile, writeJsonFile, readTextFile, writeTextFile, deleteFileIfExists } from "../utils/fileUtils.js";
import type { NoteIndexItem, NoteContent } from "./notes.types.js";
import { config } from "../config.js";
const indexFilePath = path.join(config.NOTES_DIR, "index.json");
export async function initNotesStorage(): Promise<void> {
await ensureDir(config.NOTES_DIR);
const index = await readJsonFile<NoteIndexItem[]>(indexFilePath, []);
await writeJsonFile(indexFilePath, index);
}
export async function readIndex(): Promise<NoteIndexItem[]> {
return readJsonFile<NoteIndexItem[]>(indexFilePath, []);
}
export async function writeIndex(items: NoteIndexItem[]): Promise<void> {
await writeJsonFile(indexFilePath, items);
}
export function getNoteFilePath(filename: string): string {
return path.join(config.NOTES_DIR, filename);
}
export async function readNoteContent(id: string, filename: string): Promise<NoteContent> {
const raw = await readTextFile(getNoteFilePath(filename));
const [firstLine, ...rest] = raw.split("\n");
const title = firstLine.replace(/^#\s*/, "").trim();
const content = rest.join("\n").trimStart();
return { id, title, content };
}
export async function writeNoteContent(filename: string, title: string, content: string): Promise<void> {
const body = `# ${title}\n\n${content}`.trimEnd() + "\n";
await writeTextFile(getNoteFilePath(filename), body);
}
export async function deleteNoteFile(filename: string): Promise<void> {
await deleteFileIfExists(getNoteFilePath(filename));
}
export async function fileExists(filename: string): Promise<boolean> {
try {
await fs.access(getNoteFilePath(filename));
return true;
} catch (error) {
return false;
}
}

View File

@@ -0,0 +1,23 @@
export type SyncStatus = "pending" | "syncing" | "synced" | "error";
export type NoteIndexItem = {
id: string;
title: string;
filename: string;
createdAt: string;
updatedAt: string;
sync: {
status: SyncStatus;
lastSyncedAt?: string;
anythingllmLocation?: string;
anythingllmDocumentId?: string;
lastError?: string;
cleanupPending?: boolean;
};
};
export type NoteContent = {
id: string;
title: string;
content: string;
};

View File

@@ -0,0 +1,27 @@
import { removeDocuments } from "../anythingllm/anythingllm.client.js";
import type { CleanupJob } from "./queue.js";
import { logError } from "../utils/logger.js";
import { readIndex, writeIndex } from "../notes/notes.storage.js";
export async function handleCleanupJob(job: CleanupJob): Promise<void> {
try {
await removeDocuments([job.oldLocation]);
const index = await readIndex();
const updated = index.map((item) => {
if (item.id === job.id) {
return {
...item,
sync: {
...item.sync,
cleanupPending: false
}
};
}
return item;
});
await writeIndex(updated);
} catch (error) {
logError("Temizlik kuyrugu hatasi", { error: (error as Error).message });
throw error;
}
}

View File

@@ -0,0 +1,86 @@
import path from "path";
import { readJsonFile, writeJsonFile } from "../utils/fileUtils.js";
import { config } from "../config.js";
import { logError, logInfo } from "../utils/logger.js";
export type CleanupJob = {
id: string;
oldLocation: string;
attempts: number;
nextRunAt: string;
};
const backoffSeconds = [5, 15, 30];
export class CleanupQueue {
private jobs: CleanupJob[] = [];
private running = false;
private timer?: NodeJS.Timeout;
private readonly filePath: string;
private readonly handler: (job: CleanupJob) => Promise<void>;
constructor(handler: (job: CleanupJob) => Promise<void>) {
this.handler = handler;
this.filePath = path.join(config.NOTES_DIR, "cleanup-queue.json");
}
async load(): Promise<void> {
this.jobs = await readJsonFile<CleanupJob[]>(this.filePath, []);
}
async persist(): Promise<void> {
await writeJsonFile(this.filePath, this.jobs);
}
async enqueue(job: CleanupJob): Promise<void> {
this.jobs.push(job);
await this.persist();
}
start(): void {
if (this.timer) {
return;
}
this.timer = setInterval(() => void this.tick(), 1000);
logInfo("Temizlik kuyrugu calisiyor");
}
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
}
private async tick(): Promise<void> {
if (this.running) {
return;
}
const now = Date.now();
const jobIndex = this.jobs.findIndex((job) => new Date(job.nextRunAt).getTime() <= now);
if (jobIndex === -1) {
return;
}
const job = this.jobs[jobIndex];
this.running = true;
try {
await this.handler(job);
this.jobs.splice(jobIndex, 1);
await this.persist();
logInfo("Temizlik basarili", { jobId: job.id });
} catch (error) {
job.attempts += 1;
if (job.attempts >= backoffSeconds.length) {
logError("Temizlik en fazla deneme sayisina ulasti", { jobId: job.id });
this.jobs.splice(jobIndex, 1);
} else {
const delay = backoffSeconds[job.attempts - 1] * 1000;
job.nextRunAt = new Date(Date.now() + delay).toISOString();
}
await this.persist();
} finally {
this.running = false;
}
}
}

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

@@ -0,0 +1,45 @@
import express from "express";
import cors from "cors";
import morgan from "morgan";
import path from "path";
import { fileURLToPath } from "url";
import { config } from "./config.js";
import { basicAuth } from "./auth/basicAuth.js";
import { notesRoutes } from "./notes/notes.routes.js";
import { initNotesStorage } from "./notes/notes.storage.js";
import { CleanupQueue } from "./queue/queue.js";
import { handleCleanupJob } from "./queue/cleanup.worker.js";
import { logInfo } from "./utils/logger.js";
const app = express();
app.use(cors());
app.use(express.json({ limit: "2mb" }));
app.use(morgan("dev"));
await initNotesStorage();
const cleanupQueue = new CleanupQueue(handleCleanupJob);
await cleanupQueue.load();
cleanupQueue.start();
const apiRouter = express.Router();
apiRouter.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
apiRouter.use(notesRoutes(cleanupQueue));
app.use("/api", basicAuth, apiRouter);
if (process.env.NODE_ENV === "production") {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const publicDir = path.resolve(__dirname, "public");
app.use(express.static(publicDir));
app.get("*", (_req, res) => {
res.sendFile(path.join(publicDir, "index.html"));
});
}
app.listen(config.PORT, "0.0.0.0", () => {
logInfo(`Sunucu basladi: ${config.PORT}`);
});

View File

@@ -0,0 +1,41 @@
import { promises as fs } from "fs";
import path from "path";
export async function ensureDir(dirPath: string): Promise<void> {
await fs.mkdir(dirPath, { recursive: true });
}
export async function readJsonFile<T>(filePath: string, fallback: T): Promise<T> {
try {
const raw = await fs.readFile(filePath, "utf-8");
return JSON.parse(raw) as T;
} catch (error) {
return fallback;
}
}
export async function writeJsonFile<T>(filePath: string, data: T): Promise<void> {
const dir = path.dirname(filePath);
await ensureDir(dir);
const tmpPath = `${filePath}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), "utf-8");
await fs.rename(tmpPath, filePath);
}
export async function readTextFile(filePath: string): Promise<string> {
return fs.readFile(filePath, "utf-8");
}
export async function writeTextFile(filePath: string, content: string): Promise<void> {
const dir = path.dirname(filePath);
await ensureDir(dir);
await fs.writeFile(filePath, content, "utf-8");
}
export async function deleteFileIfExists(filePath: string): Promise<void> {
try {
await fs.unlink(filePath);
} catch (error) {
return;
}
}

View File

@@ -0,0 +1,15 @@
export function logInfo(message: string, meta?: Record<string, unknown>): void {
if (meta) {
console.log(`[info] ${message}`, meta);
return;
}
console.log(`[info] ${message}`);
}
export function logError(message: string, meta?: Record<string, unknown>): void {
if (meta) {
console.error(`[error] ${message}`, meta);
return;
}
console.error(`[error] ${message}`);
}

View File

@@ -0,0 +1,6 @@
export function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "");
}