Привет читатель!
В данной статье я раскрою один из секретов Полишинеля, точнее то как создать и редактировать .md файлы на бэке в Nest.js, да звучит банально, но для таких же свитчеров как я думаю будет полезно.
Но для начала немного отойдем от сабжа и обсудим зачем, если кратко то я сейчас разрабатываю пет проект криптотрекера, и под впечатлением от Obsidian решил добавить функцию заметок себе. Ну и конечно чтобы это все работало и у меня и в Obsidian. Плюс уже на фронте можно прикрутить красоты с помощью LaTeX + Math.js и Mermaid, что актуально для финансовых приложений типа моего.

Пример ниже сделан на стеке: npm Nest.js Prisma SQLite.
Дополнительно используются пакеты: uuid и gray-matter.
Для справки:

  • uuid - генератор id, использую чтобы заранее сгенерировать id до записи в бд.

  • gray-matter - парсер для YAML, использую в примере для записи метаданных в заметку.

Можно конечно обойтись и без них, но не всегда стоит придумывать велосипед.
В примерах ниже я опустил DTO чтобы не раздувать по попусту текст, если они вам нужны то их можно найти у меня в гите вместе со схемами для бд, ссылка в профиле.


Настройка:

  • gray-matter

npm install --save gray-matter
import * as matter from 'gray-matter';
  • uuid

npm i uuid
import { v4 as uuidv4 } from 'uuid';
  • Дефолтные node фичи

import * as fs from 'fs';//для создания файла и работы с файловой системой
import * as path from 'path';//для работы с директориями

Код для всех функций из класса:

@Injectable()
export class NotesService {
    private vaultDir = path.join(process.cwd(), 'vault', 'notes');// storage dir

    constructor(private prisma: PrismaService,) {
        if (!fs.existsSync(this.vaultDir)) {
            fs.mkdirSync(this.vaultDir, { recursive: true });
        }// check and create dir / { recursive: true } - create folder chain
    }
    ...
}

Здесь определен путь для хранения файлов и его создание.

Создание заметки:

async createNote(dto: CreateNoteDto){
        const id = uuidv4();//nead for meta in obs and db
        const now = new Date().toISOString();

        let tagNames: string[] = [];

        if (dto.marks?.length) {
            const tags = await this.prisma.marks.findMany({
                where: { id: { in: dto.marks } },
                select: { name: true },
            });
            tagNames = tags.map((t) => t.name);
        }// get tag names by id

        const frontmatter = {
            id,
            name: dto.name,
            linkedTo: dto.linkedTo,
            tags:  tagNames,
            createdAt: now,
            updatedAt: now,
        }//gen meta data for obsidian

        const mdContent = matter.stringify(dto.content || '', frontmatter);//gen file with meta data
        const fileName = dto.name + '.md';
        const filePath = path.join(this.vaultDir, fileName);

        fs.writeFileSync(filePath, mdContent, 'utf8');//save .md file

        const note = await this.prisma.notes.create({
            data: {
                id,
                name: dto.name,
                userId: dto.userId,
                linkedTo: dto.linkedTo,
                createdAt: new Date(now),
                updatedAt: new Date(now),
                path: filePath,
                marks: JSON.stringify(dto.marks)
            },
        });// save meta in db
        return note;
    }

В этом коде я заранее создаю id чтобы использовать его для записи в заметку, записываю полученные данные в файл который затем создается в указанной выше директории, при первом запуске директория создастся автоматически. Ну и в конце записываю данные в бд, для связывания с другими фичами приложения.

Тут есть важный момент, сам текст заметки передается в content строкой с переносами (/n) и имеет такой вид:

"content": "# BTC анализ\nПроверка уровня поддержки 60k.\n\n#btc #strategy\n\n```math\n2 * (3 + 4)\n```\n\n```mermaid\ngraph TD\nBTC-->ETH\nETH-->SOL\n```"

Здесь уже используется LaTeX + Math.js и Mermaid. Дальше в статье будет наглядный пример с результатом.

Редактирование заметки:

async updateNote(dto: UpdateNoteDto){
        const note = await this.prisma.notes.findFirst({ where: {
            id: dto.id,
            userId: dto.userId
        } });//check db

        if (!note) throw new NotFoundException('Note not found');
        
        const filePath = note.path;

        if (!fs.existsSync(filePath)) throw new NotFoundException('File not found');

        let tagNames: string[] = [];
        
        if (dto.marks?.length) {
            const tags = await this.prisma.marks.findMany({
                where: { id: { in: dto.marks } },
                select: { name: true },
            });
            tagNames = tags.map((t) => t.name);
        }// get tag names by id

        const fileContent = fs.readFileSync(filePath, 'utf8');//read file
        const parsed = matter(fileContent);

        const updatedFrontmatter = {
            ...parsed.data,
            name: dto.name ?? parsed.data.name,
            tags: tagNames,
            updatedAt: new Date().toISOString(),
        };// update meta

        const newContent = dto.content ?? parsed.content;
        const updatedFile = matter.stringify(newContent, updatedFrontmatter);// update content and meta

        fs.writeFileSync(filePath, updatedFile, 'utf8');// save .md file

        const updatedNote = await this.prisma.notes.update({
            where: { id: dto.id },
            data: {
                name: updatedFrontmatter.name,
                updatedAt: new Date(),
                marks: JSON.stringify(dto.marks)
            },
        });// update meta in db
        
        return updatedNote;
    }

Здесь представлен код для изменения заметки, в целом он работает по тому же принципу, только здесь нам нужно сначала прочесть файл а потом внести изменения, ну и конечно не забываем обновить данные в бд.
Важно помнить, что при изменении текста нужно передавать всю заметку целиком, точечного изменять данные в .md файлах мы не можем, вернее это сложно и большого смысла в этом нет.

Получение заметки:

async getNoteById(id: string) {
        const note = await this.prisma.notes.findFirst({ where: { id } });
        if (!note) throw new NotFoundException('Note not found');

        const fileContent = fs.readFileSync(note.path, 'utf8');//raed .md file
        const parsed = matter(fileContent);// note body

        return {
            note, 
            parsed
        }
    }
Ответ
[
  {
    "id": "08ffce73-4e68-4c90-8e09-c77722dc6c80",
    "userId": "08ffce73-4e68-4c90-8e09-c77722dc6c80",
    "name": "Test",
    "isArchived": false,
    "marks": [
      "35198333-3ff5-46c7-8eef-3c97d6b7652f",
      "034caa3f-042b-4f36-a0dd-f1cd2f2e6c31"
    ],
    "content": "# BTC анализ\nПроверка уровня поддержки 60k.\n\n#btc #strategy\n\n```math\n2 * (3 + 4)\n```\n\n```mermaid\ngraph TD\nBTC-->ETH\nETH-->SOL\n```"
  }
]

Код на получение очевидно делится на 2 части, получение из бд и из файла.

Удаление заметки:

async deleteNote(id: string) {
        const note = await this.prisma.notes.findUnique({ where: { id } });
        
        if (!note) throw new NotFoundException('Note not found');

        const filePath = note.path;

        if (fs.existsSync(filePath)) {
            fs.unlinkSync(filePath);// delete .md file
        }

        await this.prisma.notes.delete({ where: { id } });
        
        return { success: true, message: 'Note deleted' };
    }

Код на удаление максимально простой, ищет файл и удаляет его, ну и чистит базу.

Конечно все эти примеры не без недостатков, но это та база которая работает и на которой можно дальше строить свое приложения.


Ну и давайте посмотри что мы имеем в итоге:

Скрин заметки из Obsidian
Скрин заметки из Obsidian

На скриншоте у нас созданная заметка открытая в Obsidian.
Сверху у нас созданные YAML свойства, которые продублированы в бд.
Здесь у нас 2 экземпляра тегов, верхние теги идут из приложения, а нижние переданы вручную для демонстрации.
В теории можно использовать оба вида тегов.
Диаграмма создана с помощью Mermaid.

Ниже таблица с данными из бд.

id

name

userId

createdAt

updatedAt

isArchived

linkedTo

path

marks

b4094146-35d1-4d61-add9-a5695e32961f

Test

08ffce73-4e68-4c90-8e09-c77722dc6c80

1760605735388

1760605801852

0

08ffce73-4e68-4c90-8e09-c77722dc6c80

C:\Users\userName\Desktop\MoneyMovementMonitor\mmm-1\apps\backend\vault\notes\Test.md

["35198333-3ff5-46c7-8eef-3c97d6b7652f","034caa3f-042b-4f36-a0dd-f1cd2f2e6c31"]


В примере я уже использовал LaTeX (рендер формулы) и Mermaid (диаграмма), результат видно на скрине с Obsidian, они позволяют красиво оформить математические формулы и расчеты а также строить диаграммы с помощью markdown.
Подобным образом мы можем встроить заметки практически в любое приложение, будь то календарь, таск менеджер или криптотрекер. Дальше все зависит от вашей фантазии и здравого смысла.
Я же планирую в дальнейшем связать свои заметки с данными в бд, в частности с аналитикой, графиками, транзакциями.
Фронт часть я планирую описать когда перейду к разработке фронта, скорее всего там же будет блок с работой над ошибками и недочетами.
Надеюсь эта статья кому то будет полезна.

Полезные ссылки:

uuid - генератор ID
gray-matter - для YAML stringify/parse frontmatter.
LaTeX - для отображения формул
Math.js - для математических расчетов
Mermaid - для диаграмм