From 05bbe307e03918c7c45af4e8e841c40a32fa12a3 Mon Sep 17 00:00:00 2001 From: wisecolt Date: Sun, 28 Dec 2025 23:37:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20not=20uygulamas=C4=B1=20ve=20altyap?= =?UTF-8?q?=C4=B1s=C4=B1n=C4=B1=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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ı --- .env.example | 9 + .gitignore | 73 ++++++++ Dockerfile | 22 +++ README.md | 42 +++++ backend/Dockerfile | 21 +++ backend/package.json | 27 +++ backend/src/anythingllm/anythingllm.client.ts | 80 ++++++++ .../src/anythingllm/anythingllm.service.ts | 14 ++ backend/src/anythingllm/anythingllm.types.ts | 17 ++ backend/src/auth/basicAuth.ts | 32 ++++ backend/src/config.ts | 24 +++ backend/src/notes/notes.routes.ts | 65 +++++++ backend/src/notes/notes.service.ts | 174 ++++++++++++++++++ backend/src/notes/notes.storage.ts | 51 +++++ backend/src/notes/notes.types.ts | 23 +++ backend/src/queue/cleanup.worker.ts | 27 +++ backend/src/queue/queue.ts | 86 +++++++++ backend/src/server.ts | 45 +++++ backend/src/utils/fileUtils.ts | 41 +++++ backend/src/utils/logger.ts | 15 ++ backend/src/utils/slugify.ts | 6 + backend/tsconfig.json | 15 ++ frontend/Dockerfile | 13 ++ frontend/index.html | 16 ++ frontend/package.json | 30 +++ frontend/postcss.config.js | 6 + frontend/public/favicon.ico | Bin 0 -> 105 bytes frontend/public/icons/apple-touch-icon.png | Bin 0 -> 563 bytes frontend/public/icons/icon-192.png | Bin 0 -> 594 bytes frontend/public/icons/icon-512.png | Bin 0 -> 2201 bytes frontend/public/manifest.webmanifest | 25 +++ frontend/src/App.tsx | 79 ++++++++ frontend/src/api/apiClient.ts | 34 ++++ frontend/src/api/notesApi.ts | 59 ++++++ frontend/src/auth/authStore.ts | 14 ++ frontend/src/components/auth/LoginPage.tsx | 79 ++++++++ frontend/src/components/editor/EditorPane.tsx | 81 ++++++++ .../src/components/editor/MarkdownEditor.tsx | 26 +++ .../src/components/editor/PreviewPane.tsx | 17 ++ frontend/src/components/editor/SaveButton.tsx | 19 ++ frontend/src/components/editor/SyncBadge.tsx | 37 ++++ frontend/src/components/editor/TitleInput.tsx | 19 ++ frontend/src/components/editor/Toolbar.tsx | 109 +++++++++++ frontend/src/components/layout/AppShell.tsx | 35 ++++ .../src/components/layout/MobileHeader.tsx | 37 ++++ frontend/src/components/layout/Sidebar.tsx | 35 ++++ .../src/components/notes/NoteListItem.tsx | 50 +++++ frontend/src/components/notes/NoteSearch.tsx | 62 +++++++ frontend/src/components/notes/NotesList.tsx | 36 ++++ frontend/src/components/ui/Button.tsx | 10 + frontend/src/components/ui/Textarea.tsx | 16 ++ frontend/src/components/ui/radius.ts | 21 +++ frontend/src/main.tsx | 10 + frontend/src/store/notesStore.ts | 146 +++++++++++++++ frontend/src/styles.css | 35 ++++ frontend/tailwind.config.js | 13 ++ frontend/tsconfig.json | 16 ++ frontend/vite.config.ts | 48 +++++ 58 files changed, 2142 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/anythingllm/anythingllm.client.ts create mode 100644 backend/src/anythingllm/anythingllm.service.ts create mode 100644 backend/src/anythingllm/anythingllm.types.ts create mode 100644 backend/src/auth/basicAuth.ts create mode 100644 backend/src/config.ts create mode 100644 backend/src/notes/notes.routes.ts create mode 100644 backend/src/notes/notes.service.ts create mode 100644 backend/src/notes/notes.storage.ts create mode 100644 backend/src/notes/notes.types.ts create mode 100644 backend/src/queue/cleanup.worker.ts create mode 100644 backend/src/queue/queue.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/utils/fileUtils.ts create mode 100644 backend/src/utils/logger.ts create mode 100644 backend/src/utils/slugify.ts create mode 100644 backend/tsconfig.json create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/icons/apple-touch-icon.png create mode 100644 frontend/public/icons/icon-192.png create mode 100644 frontend/public/icons/icon-512.png create mode 100644 frontend/public/manifest.webmanifest create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/apiClient.ts create mode 100644 frontend/src/api/notesApi.ts create mode 100644 frontend/src/auth/authStore.ts create mode 100644 frontend/src/components/auth/LoginPage.tsx create mode 100644 frontend/src/components/editor/EditorPane.tsx create mode 100644 frontend/src/components/editor/MarkdownEditor.tsx create mode 100644 frontend/src/components/editor/PreviewPane.tsx create mode 100644 frontend/src/components/editor/SaveButton.tsx create mode 100644 frontend/src/components/editor/SyncBadge.tsx create mode 100644 frontend/src/components/editor/TitleInput.tsx create mode 100644 frontend/src/components/editor/Toolbar.tsx create mode 100644 frontend/src/components/layout/AppShell.tsx create mode 100644 frontend/src/components/layout/MobileHeader.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/notes/NoteListItem.tsx create mode 100644 frontend/src/components/notes/NoteSearch.tsx create mode 100644 frontend/src/components/notes/NotesList.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Textarea.tsx create mode 100644 frontend/src/components/ui/radius.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/store/notesStore.ts create mode 100644 frontend/src/styles.css create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8d85c1 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +PORT=8080 +NOTES_DIR=/data/notes + +AUTH_USERNAME=admin +AUTH_PASSWORD_HASH=$2b$10$changemehash + +ANYTHINGLLM_ROOT_API_URL=http://zimaos.bee:3086 +ANYTHINGLLM_API_KEY=changeme +ANYTHINGLLM_WORKSPACE_SLUG=my-workspace diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fac33c --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# Node modules +/node_modules/ +jspm_packages/ +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Runtime data +pids *.pid *.seed *.pid.lock + +# Coverage directory +.nyc_output/ +coverage/ +lcov-report/ + +# Environment variables +.env +.env.*.local +!.env.example + +# Build / output directories +/dist/ +/build/ +/output/ +/. svelte-kit/ +.svelte-kit/ +.vite/ +.tmp/ +.cache/ + +# Docker files / volumes +docker-compose.override.yml +docker-compose.*.yml +docker/*-volume/ +docker/*-data/ +*.tar +*.img + +# OS / IDE stuff +.DS_Store +Thumbs.db +desktop.ini +*.swp +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Media / Download directories (depending on your setup) +downloads/ +cache/ +movie/movieData/ +movie/movieData/**/subtitles/ +movie/movieData/**/poster.jpg +movie/movieData/**/backdrop.jpg +/data/notes + +# Generic placeholders +*.gitkeep + +# Torrent / upload temp files +/uploads/ +/uploads/* +*.torrent +*.part +*.temp + +# Other sensitive files +/key.pem +/cert.pem +*.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dbbd598 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS frontend-build +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm install +COPY frontend . +RUN npm run build + +FROM node:20-alpine AS backend-build +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm install +COPY backend . +RUN npm run build + +FROM node:20-alpine AS prod +WORKDIR /app/backend +COPY backend/package*.json ./ +RUN npm install --omit=dev +COPY --from=backend-build /app/backend/dist ./dist +COPY --from=frontend-build /app/frontend/dist ./dist/public +EXPOSE 8080 +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a354af --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Note AnythingLLM + +iOS Memos esinli, PWA destekli ve AnythingLLM senkronizasyonlu minimal not uygulamasi. + +## Gereksinimler + +- Docker + Docker Compose + +## Ortam Degiskenleri + +`.env` dosyasi olusturun (ornek: `.env.example`). + +```bash +cp .env.example .env +``` + +Bcrypt hash uretimi: + +```bash +node -e "console.log(require('bcryptjs').hashSync('pass', 10))" +``` + +## Gelistirme + +```bash +docker compose -f docker-compose.dev.yml up --build +``` + +- Frontend: http://localhost:5173 +- Backend: http://localhost:8080 + +## Uretim + +```bash +docker compose -f docker-compose.prod.yml up --build -d +``` + +## Notlar + +- Tum `/api/*` endpointleri Basic Auth ile korunur. +- Notlar `NOTES_DIR` altinda `.md` dosyalarina kaydedilir. +- AnythingLLM akisi: upload -> update-embeddings -> cleanup queue. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..cf8d8ad --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM node:20-alpine AS dev +WORKDIR /app/backend +COPY package*.json ./ +RUN npm install +COPY . . +CMD ["npm", "run", "dev"] + +FROM node:20-alpine AS build +WORKDIR /app/backend +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:20-alpine AS prod +WORKDIR /app/backend +COPY package*.json ./ +RUN npm install --omit=dev +COPY --from=build /app/backend/dist ./dist +EXPOSE 8080 +CMD ["node", "dist/server.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..771a4dd --- /dev/null +++ b/backend/package.json @@ -0,0 +1,27 @@ +{ + "name": "note-anythingllm-backend", + "version": "1.0.0", + "type": "module", + "main": "dist/server.js", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/server.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "form-data": "^4.0.0", + "morgan": "^1.10.0", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/morgan": "^1.9.9", + "@types/node": "^20.12.12", + "tsx": "^4.15.7", + "typescript": "^5.5.4" + } +} diff --git a/backend/src/anythingllm/anythingllm.client.ts b/backend/src/anythingllm/anythingllm.client.ts new file mode 100644 index 0000000..1a6c4bf --- /dev/null +++ b/backend/src/anythingllm/anythingllm.client.ts @@ -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 { + return { + Authorization: `Bearer ${config.ANYTHINGLLM_API_KEY}` + }; +} + +async function parseJsonResponse(response: Response, label: string): Promise { + 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 { + 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(response, "Upload"); +} + +export async function updateEmbeddings(adds: string[], deletes: string[]): Promise { + 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 { + 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"); +} diff --git a/backend/src/anythingllm/anythingllm.service.ts b/backend/src/anythingllm/anythingllm.service.ts new file mode 100644 index 0000000..4e8a6d8 --- /dev/null +++ b/backend/src/anythingllm/anythingllm.service.ts @@ -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); +} diff --git a/backend/src/anythingllm/anythingllm.types.ts b/backend/src/anythingllm/anythingllm.types.ts new file mode 100644 index 0000000..83f06f6 --- /dev/null +++ b/backend/src/anythingllm/anythingllm.types.ts @@ -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; +}; diff --git a/backend/src/auth/basicAuth.ts b/backend/src/auth/basicAuth.ts new file mode 100644 index 0000000..fb17e36 --- /dev/null +++ b/backend/src/auth/basicAuth.ts @@ -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 { + 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(); +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..305d605 --- /dev/null +++ b/backend/src/config.ts @@ -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 +}; diff --git a/backend/src/notes/notes.routes.ts b/backend/src/notes/notes.routes.ts new file mode 100644 index 0000000..0722021 --- /dev/null +++ b/backend/src/notes/notes.routes.ts @@ -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; +} diff --git a/backend/src/notes/notes.service.ts b/backend/src/notes/notes.service.ts new file mode 100644 index 0000000..e9ccb81 --- /dev/null +++ b/backend/src/notes/notes.service.ts @@ -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 { + const index = await readIndex(); + return index.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export async function getNote(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/backend/src/notes/notes.storage.ts b/backend/src/notes/notes.storage.ts new file mode 100644 index 0000000..86eedb7 --- /dev/null +++ b/backend/src/notes/notes.storage.ts @@ -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 { + await ensureDir(config.NOTES_DIR); + const index = await readJsonFile(indexFilePath, []); + await writeJsonFile(indexFilePath, index); +} + +export async function readIndex(): Promise { + return readJsonFile(indexFilePath, []); +} + +export async function writeIndex(items: NoteIndexItem[]): Promise { + 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 { + 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 { + const body = `# ${title}\n\n${content}`.trimEnd() + "\n"; + await writeTextFile(getNoteFilePath(filename), body); +} + +export async function deleteNoteFile(filename: string): Promise { + await deleteFileIfExists(getNoteFilePath(filename)); +} + +export async function fileExists(filename: string): Promise { + try { + await fs.access(getNoteFilePath(filename)); + return true; + } catch (error) { + return false; + } +} diff --git a/backend/src/notes/notes.types.ts b/backend/src/notes/notes.types.ts new file mode 100644 index 0000000..faaa16a --- /dev/null +++ b/backend/src/notes/notes.types.ts @@ -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; +}; diff --git a/backend/src/queue/cleanup.worker.ts b/backend/src/queue/cleanup.worker.ts new file mode 100644 index 0000000..6de21fe --- /dev/null +++ b/backend/src/queue/cleanup.worker.ts @@ -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 { + 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; + } +} diff --git a/backend/src/queue/queue.ts b/backend/src/queue/queue.ts new file mode 100644 index 0000000..68e0a5a --- /dev/null +++ b/backend/src/queue/queue.ts @@ -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; + + constructor(handler: (job: CleanupJob) => Promise) { + this.handler = handler; + this.filePath = path.join(config.NOTES_DIR, "cleanup-queue.json"); + } + + async load(): Promise { + this.jobs = await readJsonFile(this.filePath, []); + } + + async persist(): Promise { + await writeJsonFile(this.filePath, this.jobs); + } + + async enqueue(job: CleanupJob): Promise { + 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 { + 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; + } + } +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..fcb3d39 --- /dev/null +++ b/backend/src/server.ts @@ -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}`); +}); diff --git a/backend/src/utils/fileUtils.ts b/backend/src/utils/fileUtils.ts new file mode 100644 index 0000000..5cdc488 --- /dev/null +++ b/backend/src/utils/fileUtils.ts @@ -0,0 +1,41 @@ +import { promises as fs } from "fs"; +import path from "path"; + +export async function ensureDir(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }); +} + +export async function readJsonFile(filePath: string, fallback: T): Promise { + try { + const raw = await fs.readFile(filePath, "utf-8"); + return JSON.parse(raw) as T; + } catch (error) { + return fallback; + } +} + +export async function writeJsonFile(filePath: string, data: T): Promise { + 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 { + return fs.readFile(filePath, "utf-8"); +} + +export async function writeTextFile(filePath: string, content: string): Promise { + const dir = path.dirname(filePath); + await ensureDir(dir); + await fs.writeFile(filePath, content, "utf-8"); +} + +export async function deleteFileIfExists(filePath: string): Promise { + try { + await fs.unlink(filePath); + } catch (error) { + return; + } +} diff --git a/backend/src/utils/logger.ts b/backend/src/utils/logger.ts new file mode 100644 index 0000000..75abc42 --- /dev/null +++ b/backend/src/utils/logger.ts @@ -0,0 +1,15 @@ +export function logInfo(message: string, meta?: Record): void { + if (meta) { + console.log(`[info] ${message}`, meta); + return; + } + console.log(`[info] ${message}`); +} + +export function logError(message: string, meta?: Record): void { + if (meta) { + console.error(`[error] ${message}`, meta); + return; + } + console.error(`[error] ${message}`); +} diff --git a/backend/src/utils/slugify.ts b/backend/src/utils/slugify.ts new file mode 100644 index 0000000..72a7400 --- /dev/null +++ b/backend/src/utils/slugify.ts @@ -0,0 +1,6 @@ +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)+/g, ""); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..c67e4f7 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0ef7d5c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS dev +WORKDIR /app/frontend +COPY package*.json ./ +RUN npm install +COPY . . +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] + +FROM node:20-alpine AS build +WORKDIR /app/frontend +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8d26175 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Memos Notes + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9979239 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "note-anythingllm-frontend", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --host" + }, + "dependencies": { + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-fontawesome": "^0.2.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "rehype-raw": "^7.0.0", + "zustand": "^4.5.4" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.41", + "tailwindcss": "^3.4.10", + "typescript": "^5.5.4", + "vite": "^5.4.0", + "vite-plugin-pwa": "^0.20.5" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..ba80730 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f7325fdec38dea343b92ae1a9148a5dd2f2cfdc5 GIT binary patch literal 105 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz15X#nkcv5P&lw6bFmSLa{JwGG qUpjB^M{Z{8t(T{VFrk8%2bf
}oIII#$*m%-E3&t;ucLK6T1>KjA= literal 0 HcmV?d00001 diff --git a/frontend/public/icons/apple-touch-icon.png b/frontend/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6044a9664d285376d48a75099b9728c2515ba9e8 GIT binary patch literal 563 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6VEpCj;uumf=k2A9oD2#K2Mqpy zRQ`Y5{0pa}hUBL57dNuyR!Hn;exT|+IVmHE$G2;tr=f{vo7&L}OC)BAj0z6r0MT2} XJ+s08LBJPaVqoxe^>bP0l+XkK=qu3i literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..94aa8e03cd4fe0d6696555c62842a7f0c6ec8ddf GIT binary patch literal 594 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zf+;V3PE7aSW-L^Y*f#AcKOyfeqiA zmH)__Ij}h!r@X&pSslA#)_ukglNmjX88X=v&N3{KY%pP-kj8L{$6?l};4lu5Zwwvm WTf%I<%zFz=D-52lelF{r5}E)QDy$O# literal 0 HcmV?d00001 diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..beced4f7567789eedc7579248c7d7a32798646e1 GIT binary patch literal 2201 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&zE~)R&4YzZe)e5 state.loadNotes); + const notes = useNotesStore((state) => state.notes); + + useEffect(() => { + const handlePop = () => setRoute(window.location.pathname); + window.addEventListener("popstate", handlePop); + return () => window.removeEventListener("popstate", handlePop); + }, []); + + useEffect(() => { + if (!isAuthed) { + return; + } + void loadNotes(); + }, [loadNotes, isAuthed]); + + useEffect(() => { + if (!isAuthed) { + return; + } + const hasSyncing = notes.some((note) => note.sync.status === "syncing"); + if (!hasSyncing) { + return; + } + const timer = setInterval(() => { + void loadNotes(); + }, 2000); + return () => clearInterval(timer); + }, [isAuthed, notes, loadNotes]); + + const navigate = (path: string) => { + window.history.pushState({}, "", path); + setRoute(path); + }; + + const handleLoginSuccess = () => { + setIsAuthed(true); + navigate("/"); + }; + + const handleLogout = () => { + clearAuthHeader(); + setIsAuthed(false); + navigate("/login"); + }; + + if (!isAuthed || route === "/login") { + return ; + } + + return ( + setSidebarOpen(false)} />} + header={ + setSidebarOpen((prev) => !prev)} + onLogout={handleLogout} + /> + } + sidebarOpen={sidebarOpen} + onCloseSidebar={() => setSidebarOpen(false)} + > + + + ); +} diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts new file mode 100644 index 0000000..49dc91f --- /dev/null +++ b/frontend/src/api/apiClient.ts @@ -0,0 +1,34 @@ +import { clearAuthHeader, getAuthHeader } from "../auth/authStore"; + +export async function apiFetch(input: string, init?: RequestInit): Promise { + const base = import.meta.env.VITE_API_BASE ?? "/api"; + const authHeader = getAuthHeader(); + if (!authHeader) { + throw new Error("Kimlik bilgileri gerekli"); + } + + const response = await fetch(`${base}${input}`, { + ...init, + headers: { + "Content-Type": "application/json", + Authorization: authHeader, + ...(init?.headers ?? {}) + } + }); + + if (response.status === 401) { + clearAuthHeader(); + window.location.href = "/login"; + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || "API hatasi"); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} diff --git a/frontend/src/api/notesApi.ts b/frontend/src/api/notesApi.ts new file mode 100644 index 0000000..e544c90 --- /dev/null +++ b/frontend/src/api/notesApi.ts @@ -0,0 +1,59 @@ +import { apiFetch } from "./apiClient"; + +export type NoteSync = { + status: "pending" | "syncing" | "synced" | "error"; + lastSyncedAt?: string; + anythingllmLocation?: string; + anythingllmDocumentId?: string; + lastError?: string; + cleanupPending?: boolean; +}; + +export type NoteIndexItem = { + id: string; + title: string; + filename: string; + createdAt: string; + updatedAt: string; + sync: NoteSync; +}; + +export type NoteContent = { + id: string; + title: string; + content: string; +}; + +export async function fetchNotes(): Promise { + return apiFetch("/notes"); +} + +export async function fetchNote(id: string): Promise { + return apiFetch(`/notes/${id}`); +} + +export async function createNote(payload: { title: string; content: string }): Promise { + return apiFetch("/notes", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function updateNote(id: string, payload: { title: string; content: string }): Promise { + return apiFetch(`/notes/${id}`, { + method: "PUT", + body: JSON.stringify(payload) + }); +} + +export async function deleteNote(id: string): Promise { + await apiFetch(`/notes/${id}`, { + method: "DELETE" + }); +} + +export async function syncNote(id: string): Promise { + await apiFetch(`/notes/${id}/sync`, { + method: "POST" + }); +} diff --git a/frontend/src/auth/authStore.ts b/frontend/src/auth/authStore.ts new file mode 100644 index 0000000..ecf36c9 --- /dev/null +++ b/frontend/src/auth/authStore.ts @@ -0,0 +1,14 @@ +const AUTH_KEY = "notes-auth"; + +export function getAuthHeader(): string | null { + return localStorage.getItem(AUTH_KEY); +} + +export function setAuthHeader(username: string, password: string): void { + const token = btoa(`${username}:${password}`); + localStorage.setItem(AUTH_KEY, `Basic ${token}`); +} + +export function clearAuthHeader(): void { + localStorage.removeItem(AUTH_KEY); +} diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx new file mode 100644 index 0000000..60afabe --- /dev/null +++ b/frontend/src/components/auth/LoginPage.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { clearAuthHeader, getAuthHeader, setAuthHeader } from "../../auth/authStore"; +import Button from "../ui/Button"; + +type LoginPageProps = { + onSuccess: () => void; +}; + +export default function LoginPage({ onSuccess }: LoginPageProps) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!username.trim()) { + setError("Kullanici adi gerekli."); + return; + } + setAuthHeader(username.trim(), password); + try { + const base = import.meta.env.VITE_API_BASE ?? "/api"; + const authHeader = getAuthHeader(); + const response = await fetch(`${base}/health`, { + headers: { + Authorization: authHeader ?? "" + } + }); + if (!response.ok) { + throw new Error("Yetkisiz"); + } + setError(null); + onSuccess(); + } catch (error) { + clearAuthHeader(); + setError("Giris basarisiz. Kullanici adi veya sifre hatali."); + } + }; + + return ( +
+
+

Memos

+

Notlarina erismek icin giris yap.

+
+ + + {error &&

{error}

} + +
+
+
+ ); +} diff --git a/frontend/src/components/editor/EditorPane.tsx b/frontend/src/components/editor/EditorPane.tsx new file mode 100644 index 0000000..31c0548 --- /dev/null +++ b/frontend/src/components/editor/EditorPane.tsx @@ -0,0 +1,81 @@ +import { useRef, useState } from "react"; +import { useNotesStore } from "../../store/notesStore"; +import TitleInput from "./TitleInput"; +import Toolbar from "./Toolbar"; +import MarkdownEditor from "./MarkdownEditor"; +import PreviewPane from "./PreviewPane"; +import SyncBadge from "./SyncBadge"; +import SaveButton from "./SaveButton"; +import Button from "../ui/Button"; + +export default function EditorPane() { + const textareaRef = useRef(null); + const { activeNote, saveActive, previewMode, togglePreview, notes, activeId, error } = useNotesStore(); + const [tab, setTab] = useState<"edit" | "preview">("edit"); + + const activeIndex = notes.find((note) => note.id === activeId); + + if (!activeNote) { + return ( +
+ Not sec veya yeni bir not olustur. +
+ ); + } + + return ( +
+
+
+ +

+ Son guncelleme: {new Date(activeIndex?.updatedAt ?? Date.now()).toLocaleString("tr-TR")} +

+
+
+ + +
+
+ + + +
+ {previewMode ? : } + +
+ +
+
+ + +
+ {tab === "edit" ? : } +
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} diff --git a/frontend/src/components/editor/MarkdownEditor.tsx b/frontend/src/components/editor/MarkdownEditor.tsx new file mode 100644 index 0000000..6699176 --- /dev/null +++ b/frontend/src/components/editor/MarkdownEditor.tsx @@ -0,0 +1,26 @@ +import { forwardRef } from "react"; +import { useNotesStore } from "../../store/notesStore"; +import Textarea from "../ui/Textarea"; + +const MarkdownEditor = forwardRef((_props, ref) => { + const activeNote = useNotesStore((state) => state.activeNote); + const updateActive = useNotesStore((state) => state.updateActive); + + if (!activeNote) { + return null; + } + + return ( +