Я много использую Obsidian для работы. И я люблю плагин Dataview, но в нем довольно много недостатков. И одна из них — он не имеет доступ ко всем данным в заметках. Поэтому в этой статье я расскажу о новом плагине Datacore, который открывает практически безграничные возможности работы с заметками.

Содержание

  1. Проблема Dataview и преимущества Datacore

  2. Типичный проблемный выбор

  3. Установка Datacore

  4. Установка скрипта

  5. Описание настроек скрипта

  6. Пример игры на Datacore. Кликер

Проблема Dataview и преимущества Datacore

Если мне необходимо получить данные по ключу в Dataview, то это можно сделать некоторыми способами, описанными ниже.

В YAML (те самые Свойства в самом начале заметок) в виде:
---
key: value
key2:
- value1
- value2
---

В Inline field в любом месте заметок (кроме YAML) в виде:
[key:: value]
(key:: value)
key:: value

Причем первые два варианта Inline field можно вставлять в любом месте, а последний только на отдельной строке. И конечно же, ко всем остальным данным (за пределами YAML и данных с двумя двоеточиями) я просто не смогу получить доступ.

Ввиду этой проблемы на сцену выходит новый плагин — Datacore. Плагин от того же разработчика, который однозначно говорит, что его новый плагин является прямым приемником Dataview, но при этом плагин:

  • Мощнее (позволяется использовать React API в Javascript-коде, JSX для более полной поддержки React и имеется больше возможностей плагина в обработке и просмотре данных).

  • Значительно быстрее (до сотни раз).

  • Имеет возможность делать пагинацию из коробки.

  • Имеет возможность получить доступ к любым данным в заметках.

Типичный проблемный выбор

Давайте представим. Стандартная ситуация, выбираете телефон из трех вариантов.

Создали папку "Телефон", в нем под каждый телефон Вы создали заметку с характеристиками:

И в YAML вытащили все спецификации и в саму заметку добавили фото (через символы ![[]]):

Как сравнить телефон с другими? Можно использовать разделение экрана:

Но проблема, больше трех вариантов уже будет невозможно смотреть и анализировать и каждую заметку надо будет открывать вручную:

Конечно же, никто не запрещает использовать Canvas, но придется, опять же, каждую заметку добавлять отдельно. А также в заметках и в Canvas будет проблема с анализом одинаковых ячеек, придется прыгать взглядом влево-вправо и искать необходимый блок. Но в Холсте всё-таки будет чуть удобнее, чем с разделением экрана:

P.S. А как же Excel?

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

Как тогда сделать это ещё легче, проще и с автоматизацией? С помощью плагина Datacore.

Установка Datacore

Пропустите этот пункт, если у вас уже установлен плагин Datacore или Вы разбираетесь, как установить его самому из плагинов сообщества Obsidian.

А установить плагин Datacore в Obsidian можно таким образом:

  1. Зайдите в настройки (значок шестеренки около названия Хранилища):

  2. В пункте Сторонние плагины нажмите Включите плагины сообщества (если не включены до сих пор):

  3. Нажмите Обзор для поиска плагина:

  4. В поиске введите Datacore:

  5. Нажмите по найденному плагину и кликните Установить.

  6. И нажмите Включить. После этого настройки можете закрыть.

Установка скрипта

Создайте заметку в любом месте (но лучше не в папке с нужными файлами, в моем случае это Телефон) и вставьте в заметку следующий код:

---
Folder: Телефон
FrontColumns:
  - Процессор
  - Дисплей
NoteColumns:
  - Фото
  - Оценка
Level: 3
Font: 12
Lines: 0
Sorting: true
---
```datacorejsx
return function View() {
/*
 * © 2025 Jarwix
 *
 * Этот шаблон предоставляется по лицензии MIT.
 * Вы можете использовать, изменять и распространять его свободно,
 * при условии обязательного указания автора и включения этой лицензии.
 *
 * Author: Jarwix (https://t.me/sdvghack)
 */
    const frontmatter = dc.currentFile();
    const folderPath = frontmatter.value("Folder");
    const levelText = frontmatter.value("Level");
    const fontSize = frontmatter.value("Font");

    const getArrayFromValue = (val) => {
        if (Array.isArray(val)) return val.map(s => s.trim());
        if (typeof val === 'string') return val.split(',').map(s => s.trim()).filter(Boolean);
        return [];
    };

    const columnNames = getArrayFromValue(frontmatter.value("NoteColumns"));
    const frontmatterFields = getArrayFromValue(frontmatter.value("FrontColumns"));
    const rowsPerPage = parseInt(frontmatter.value("Lines")) || 0;
    const sortAsc = frontmatter.value("Sorting") === true;

    if (!folderPath || (columnNames.length === 0 && frontmatterFields.length === 0)) {
        return <div>Укажите Папку и (NoteColumns или FrontColumns) в Frontmatter.</div>;
    }

    const revision = dc.useIndexUpdates();
    const allData = dc.useMemo(() => {
        let fileQ = `path("${folderPath}")`;
        const files = dc.query(fileQ);

        let sections = [];
        if (columnNames.length > 0) {
            let secQ = `path("${folderPath}") and @section`;
            if (levelText) {
                secQ += ` and $level = ${levelText}`;
            }
            sections = dc.query(secQ);
        }

        return { files, sections };
    }, [revision]);

    const [rows, setRows] = dc.useState([]);
    const [loading, setLoading] = dc.useState(true);

    dc.useEffect(() => {
        const loadData = async () => {
            const { files, sections } = allData;
            const filePaths = files.map(f => f.$path);

            const sectionsByFile = new Map();
            if (sections.length > 0) {
                sections.forEach(section => {
                    const filePath = section.$file;
                    if (!sectionsByFile.has(filePath)) {
                        sectionsByFile.set(filePath, {});
                    }
                    const fileData = sectionsByFile.get(filePath);
                    for (const colName of columnNames) {
                        if (section.$name.toLowerCase() === colName.toLowerCase() && !fileData[colName]) {
                            fileData[colName] = section;
                            break;
                        }
                    }
                });
            }

            const fileLinesCache = new Map();
            const imageCache = new Map();

            const uniqueFilePaths = filePaths.filter(filePath => {
                const hasSectionMatch = sectionsByFile.has(filePath) && Object.keys(sectionsByFile.get(filePath)).length > 0;
                if (hasSectionMatch) return true;

                const tfile = app.vault.getAbstractFileByPath(filePath);
                const cache = tfile ? app.metadataCache.getFileCache(tfile) : null;
                const fileFrontmatter = cache?.frontmatter || {};
                const hasFrontMatch = frontmatterFields.some(name => {
                    for (const [key] of Object.entries(fileFrontmatter)) {
                        if (key.toLowerCase() === name.toLowerCase()) return true;
                    }
                    return false;
                });
                return hasFrontMatch;
            }).sort((a, b) => {
                const comparison = a.localeCompare(b);
                return sortAsc ? comparison : -comparison;
            });

            if (columnNames.length > 0) {
                const filesNeedingLines = uniqueFilePaths.filter(p => sectionsByFile.has(p));
                await Promise.all(filesNeedingLines.map(async (filePath) => {
                    const tfile = app.vault.getAbstractFileByPath(filePath);
                    if (tfile) {
                        const content = await app.vault.read(tfile);
                        fileLinesCache.set(filePath, content.split('\n'));
                    }
                }));
            }

            const resolveImagePath = (embedSyntax) => {
                if (imageCache.has(embedSyntax)) return imageCache.get(embedSyntax);

                const match = embedSyntax.match(/!\[\[([^\]\|]+)(?:\|[^\]]*)?\]\]/);
                if (!match) return null;
                const rawPath = match[1].trim();

                const IMG_EXTS = ["jpg", "jpeg", "png", "gif", "webp", "svg"];
                const normalize = (p) => p.replace(/^\//, "").replace(/\/$/, "");
                const q = normalize(rawPath);
                if (!q) return null;

                const hasExt = /\.[a-z0-9]+$/i.test(q);
                const qBase = hasExt ? q.replace(/\.[^/.]+$/, "") : q;
                const qName = qBase.split("/").pop();
                const dirFromQ = qBase.includes("/") ? qBase.split("/").slice(0, -1).join("/") : "";
                const dirs = [dirFromQ].filter(Boolean).map(normalize);
                if (!dirs.length) dirs.push("");

                const exts = hasExt ? [q.split(".").pop().toLowerCase()] : IMG_EXTS;

                let f = app.vault.getAbstractFileByPath(q);
                if (f) {
                    const src = app.vault.getResourcePath(f);
                    imageCache.set(embedSyntax, src);
                    return src;
                }

                for (const dir of dirs) {
                    for (const ext of exts) {
                        const p = dir ? `${dir}/${qName}.${ext}` : `${qName}.${ext}`;
                        f = app.vault.getAbstractFileByPath(p);
                        if (f) {
                            const src = app.vault.getResourcePath(f);
                            imageCache.set(embedSyntax, src);
                            return src;
                        }
                    }
                }

                const filesList = app.vault.getFiles();
                const candidate = filesList.find(f =>
                    IMG_EXTS.some(ext => f.name.toLowerCase() === `${qName}.${ext}`.toLowerCase())
                );
                if (candidate) {
                    const src = app.vault.getResourcePath(candidate);
                    imageCache.set(embedSyntax, src);
                    return src;
                }

                imageCache.set(embedSyntax, null);
                return null;
            };

            const loadedRows = uniqueFilePaths.map(filePath => {
                const tfile = app.vault.getAbstractFileByPath(filePath);
                const cache = tfile ? app.metadataCache.getFileCache(tfile) : null;
                const fileFrontmatter = cache?.frontmatter || {};
                const data = sectionsByFile.get(filePath) || {};
                const row = { file: dc.fileLink(filePath) };
                const lines = columnNames.length > 0 && sectionsByFile.has(filePath) ? (fileLinesCache.get(filePath) || []) : [];
                columnNames.forEach(name => {
                    row[name] = extractContent(data[name], lines, fontSize, resolveImagePath);
                });
                frontmatterFields.forEach(name => {
				    let foundValue = '—';
				    for (const [key, value] of Object.entries(fileFrontmatter)) {
				        if (key.toLowerCase() === name.toLowerCase()) {
				            foundValue = value ?? '—';
				            break;
				        }
				    }
				    row[name] = foundValue === '—' 
				        ? '—' 
				        : <div style={{
				              fontSize: `${fontSize}px`,
				              lineHeight: 1.4,
				              display: 'flex',
				              flexDirection: 'column',
				              gap: '4px'
				          }}>
				              {typeof foundValue === 'string' 
				                  ? foundValue.split('\n').map((line, i) => 
				                      <span key={i}>{line || <br/>}</span>
				                  )
				                  : foundValue}
				          </div>;
				});
                return row;
            });

            setRows(loadedRows);
            setLoading(false);
        };

        loadData();
    }, [revision]);

    const extractContent = (section, lines, fontSize, resolveImagePath) => {
        if (!section || lines.length === 0) return '—';

        const elements = [];

        section.$blocks.forEach(block => {
            if (block.$type === 'list') {
                block.$elements.forEach(item => {
                    elements.push(<div key={elements.length}>• {item.$text}</div>);
                });
            }
            else if (block.$type === 'task') {
                block.$elements.forEach(item => {
                    elements.push(<div key={elements.length}>- [ ] {item.$text}</div>);
                });
            }
            else if (block.$type === 'paragraph') {
                const start = block.$position.start;
                const end = block.$position.end;
                const rawText = lines.slice(start, end).join('\n');

                const parts = rawText.split(/(!\[\[[^\]]+\]\])/g);

                parts.forEach(part => {
                    if (!part) return;

                    if (part.startsWith('![[')) {
                        const src = resolveImagePath(part);
                        if (src) {
                            elements.push(
                                <img
                                    key={elements.length}
                                    src={src}
                                    alt={part.match(/!\[\[([^\]\|]+)/)?.[1] || 'image'}
                                    style={{
                                        maxWidth: '100%',
                                        maxHeight: '180px',
                                        objectFit: 'contain',
                                        borderRadius: '6px',
                                        margin: '6px 0',
                                        display: 'block'
                                    }}
                                />
                            );
                        } else {
                            elements.push(<span key={elements.length}>[Фото не найдено]</span>);
                        }
                    } else {
                        const text = part.trim();
                        if (text) elements.push(<span key={elements.length}>{text}</span>);
                    }
                });
            }
        });
        if (elements.length === 0) return '—';
        return (
            <div style={{
                fontSize: `${fontSize}px`,
                lineHeight: 1.4,
                display: 'flex',
                flexDirection: 'column',
                gap: '4px'
            }}>
                {elements}
            </div>
        );
    };

    if (loading) {
        return <div>Загрузка данных...</div>;
    }

    const COLUMNS = [
        { id: "Файл", value: row => row.file },
        ...frontmatterFields.map(name => ({
            id: name,
            value: row => row[name]
        })),
        ...columnNames.map(name => ({
            id: name,
            value: row => row[name]
        }))
    ];

    return <dc.Table {...(rowsPerPage > 0 ? { paging: rowsPerPage } : {})} columns={COLUMNS} rows={rows} />;
};
```

Либо скопируйте/скачайте код с GitHub: https://github.com/jarwix/Datacore-Example/

Далее проверьте, поля FrontColumns и NoteColumns должны быть списками:

Folder - текстом:

Sorting - флажком:

Все остальные (Level, Font, Lines) - числами:

Я добавил в заметки пункт Оценка и сразу добавил её в графу NoteColumns, чтобы после анализа якобы оценивать телефоны и это сразу же показывалось в таблице.

И краткое пояснение по значимым вещам в коде:

dc.useIndexUpdates() — это React-хук, который будет обновляться при каждом обновлении хранилища (заметок, добавление и удаление файлов и т.д.).

dc.useMemo() — React-хук для тяжелых вещей. Он запоминает результат (например, запросы dc.query() к файлам в папке) и обновляется только если зависимость изменилась — в моём случае он обновляется только при изменении useIndexUpdates.

dc.useEffect() — React-хук для всяких побочек после рендера DOM, типа загрузки или парсинга. В моём коде он асинхронно читает контент файлов из папки и также только с useIndexUpdates зависимостью (т.е. только при изменении хранилища).

API плагина находится здесь:
https://blacksmithgu.github.io/
Часть именно про хуки и API:
https://blacksmithgu.github.io/datacore/code-views/local-api

Имейте ввиду, если Вы не будете использовать эти хуки в своём коде — ваш компьютер будет давать повышенную нагрузку на ЦП из-за постоянного поиска данных и перерендера.

Описание настроек скрипта

Настраивать скрипт довольно просто, необходимо только менять значения во Frontmatter.

Folder — это папка, в которой будут искаться все заметки хотя бы с одним совпадением в столбцах. Если совпадений вообще нет — заметка в таблице не покажется. Если вам необходимо делать поиск по всему хранилищу — ставьте косую черту (/) в поле.

FrontColumns — столбцы, которые будут искать контент по ключу во всех Frontmatter (YAML-разметке) заметок.

NoteColumns — столбцы, которые будут искать контент по ключу в телах заметок через совпадение с заголовком абзаца.

Level — уровень заголовков абзацев, по которым будет производиться поиск для NoteColumns. 3 — значит перед заголовком были ###.

Font — шрифт для всех столбцов, кроме первого. При большом количестве столбцов информация иначе может не влезть.

Lines — максимальное количество строк на одну страницу. 0 — значит ограничений нет и всё покажется на единственной странице.

Sorting — если галочка стоит, то сортировка будет по алфавиту, без галочки — также по алфавиту, но в обратную сторону. Сделал этот пункт, потому что с ним очень удобно обрабатывать ежедневные заметки.

Думаю, что это довольно удобно:

Пример игры на Datacore. Кликер

Для примера действительно широких возможностей плагина можете поиграть в кликер в новой заметке:

Для этого добавьте следующий код:

```datacorejsx
return function View() {
/*
 * © 2025 Jarwix
 *
 * Этот шаблон предоставляется по лицензии MIT.
 * Вы можете использовать, изменять и распространять его свободно,
 * при условии обязательного указания автора и включения этой лицензии.
 *
 * Author: Jarwix (https://t.me/sdvghack)
 */

  const currentFile = dc.currentFile();

  const getFilePath = () => {
    return currentFile.value("$path") || currentFile.path || currentFile.file?.path;
  };

  const getFmValue = (key, def) => {

    const val = currentFile.value(key);
    return val !== undefined && val !== null ? Number(val) : def;
  };

  const getFmUpgrades = () => {
    const val = currentFile.value("clicker_upgrades");
    return val ? val : {};
  };

  const [game, setGame] = dc.useState(() => {
    const path = getFilePath();
    console.log("DEBUG: Init path:", path);
    return {
      score: getFmValue("clicker_score", 0),
      autoPerSec: getFmValue("clicker_auto", 0),
      clickPower: getFmValue("clicker_power", 1),
      upgrades: getFmUpgrades()
    };
  });

  const gameRef = dc.useRef(game);

  dc.useEffect(() => {
    gameRef.current = game;
  }, [game]);

  const saveToFrontmatter = async () => {

    const data = gameRef.current;

    const app = window.app;
    if (!app) return;

    const path = getFilePath();
    if (!path) {
      console.error("CRITICAL: No path");
      return;
    }

    const tFile = app.vault.getAbstractFileByPath(path);

    if (tFile) {
      try {
        console.log(`DEBUG: Saving score ${data.score.toFixed(2)} to FM...`); 
        await app.fileManager.processFrontMatter(tFile, (fm) => {

          fm.clicker_score = data.score; 
          fm.clicker_auto = data.autoPerSec;
          fm.clicker_power = data.clickPower;
          fm.clicker_upgrades = data.upgrades;
        });

      } catch (e) {
        console.error("Save Error:", e);
      }
    }
  };

  dc.useEffect(() => {
    const interval = setInterval(() => {

      if (gameRef.current.score > 0 || gameRef.current.autoPerSec > 0) {
          saveToFrontmatter();
      }
    }, 5000);
    return () => clearInterval(interval);
  }, []); 

  dc.useEffect(() => {
    if (game.autoPerSec > 0) {
      const interval = setInterval(() => {

        setGame(prev => ({ ...prev, score: prev.score + prev.autoPerSec }));
      }, 1000);
      return () => clearInterval(interval);
    }
  }, [game.autoPerSec]);

  const click = () => {
    setGame(prev => ({ ...prev, score: prev.score + prev.clickPower }));
  };

  const upgradesList = [
    { id: "doubleClick", name: "Двойной клик", baseCost: 50, effect: (g) => ({ clickPower: g.clickPower + 1 }) },
    { id: "auto1", name: "Робот-помощник", baseCost: 100, effect: (g) => ({ autoPerSec: g.autoPerSec + 1 }) },
    { id: "megaClick", name: "Мега-клик ×5", baseCost: 500, effect: (g) => ({ clickPower: g.clickPower + 5 }) },
    { id: "factory", name: "Фабрика печенья", baseCost: 2000, effect: (g) => ({ autoPerSec: g.autoPerSec + 10 }) },
  ];

  const buyUpgrade = (upg) => {
    const owned = game.upgrades[upg.id] || 0;
    const cost = Math.floor(upg.baseCost * Math.pow(1.15, owned));

    if (game.score >= cost) {
      const effectResult = upg.effect(game);
      setGame(prev => ({
        ...prev,
        score: prev.score - cost,
        upgrades: { ...prev.upgrades, [upg.id]: owned + 1 },
        ...effectResult 
      }));
    }
  };

  const resetGame = async () => {
    const cleanState = { score: 0, autoPerSec: 0, clickPower: 1, upgrades: {} };
    setGame(cleanState);

    const app = window.app;
    const path = getFilePath();
    if (path) {
        const tFile = app.vault.getAbstractFileByPath(path);
        if (tFile) {
            await app.fileManager.processFrontMatter(tFile, (fm) => {
                delete fm.clicker_score;
                delete fm.clicker_auto;
                delete fm.clicker_power;
                delete fm.clicker_upgrades;
            });
        }
    }
  };

  return (
    <div style={{ 
      fontFamily: "var(--font-interface)", 
      textAlign: "center", 
      padding: "20px", 
      maxWidth: "500px", 
      margin: "0 auto",
      border: "1px solid var(--background-modifier-border)",
      borderRadius: "10px",
      background: "var(--background-secondary)"
    }}>
      <h3>? Кликер</h3>

      <div style={{ fontSize: "2.5em", margin: "20px 0", fontWeight: "bold", color: "var(--text-accent)" }}>
        {}
        {Math.floor(game.score)}
      </div>

      <div style={{ display: "flex", justifyContent: "center", gap: "20px", marginBottom: "20px", fontSize: "0.9em", color: "var(--text-muted)" }}>
         <span>? Сила: {game.clickPower}</span>
         <span>⚡ Авто: {game.autoPerSec}/сек</span>
      </div>

      <button 
        onClick={click}
        style={{
          fontSize: "40px", 
          width: "120px", 
          height: "120px", 
          borderRadius: "50%",
          background: "var(--interactive-accent)", 
          border: "none", 
          cursor: "pointer",
          boxShadow: "0 4px 15px rgba(0,0,0,0.2)",
          transition: "transform 0.1s"
        }}
        onMouseDown={e => e.currentTarget.style.transform = "scale(0.95)"}
        onMouseUp={e => e.currentTarget.style.transform = "scale(1)"}
        onMouseLeave={e => e.currentTarget.style.transform = "scale(1)"}
      >
        ?
      </button>

      <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px", margin: "30px 0" }}>
        {upgradesList.map(upg => {
            const owned = game.upgrades[upg.id] || 0;
            const cost = Math.floor(upg.baseCost * Math.pow(1.15, owned));
            const canBuy = game.score >= cost;

            return (
                <button 
                    key={upg.id}
                    onClick={() => buyUpgrade(upg)}
                    disabled={!canBuy}
                    style={{
                        padding: "10px",
                        background: canBuy ? "var(--interactive-normal)" : "var(--background-modifier-form-field)",
                        opacity: canBuy ? 1 : 0.5,
                        border: "1px solid var(--background-modifier-border)",
                        borderRadius: "8px",
                        cursor: canBuy ? "pointer" : "default",
                        display: "flex",
                        flexDirection: "column",
                        alignItems: "center"
                    }}
                >
                    <span style={{fontWeight: "bold"}}>{upg.name}</span>
                    <span style={{fontSize: "0.8em"}}>Ур. {owned} | ? {cost}</span>
                </button>
            )
        })}
      </div>

      <div style={{borderTop: "1px solid var(--background-modifier-border)", paddingTop: "15px"}}>
        <button 
            onClick={resetGame}
            style={{
                fontSize: "12px",
                background: "var(--background-modifier-error)",
                color: "white",
                border: "none",
                padding: "5px 10px",
                borderRadius: "4px",
                cursor: "pointer"
            }}
        >
            Сброс прогресса
        </button>
        <div style={{fontSize: "10px", color: "var(--text-muted)", marginTop: "5px"}}>
            Файл: {getFilePath() || "..."}
        </div>
      </div>
    </div>
  );
};
```

С GitHub можно скачать там же: https://github.com/jarwix/Datacore-Example/

Я вывел запись данных каждые 5 секунд во frontmatter, ведь иначе Electron будет обнулять значения в localStorage при переключении на другую заметку. Да, это потенциальная угроза для "взлома" игры, но в данном случае это пример использования ваших данных из заметки для визуализации. Также из-за постоянной перезаписи и постоянного перерандера может быть повышенная нагрузка на ЦП.

P.S. По поводу вайбкодинга — придется чуть помучаться, потому что некоторые системы будут путать плагин с Dataview, до сих пор думать что плагин в бете и доступен только через BRAT или просто не знать как плагин работает с внутренним API Obsidian.


Я веду Telegram-канал, где описываю лайфхаки по управлению жизнью (для СДВГшников и помешанных на личной эффективности) и делюсь своими разработками:
https://t.me/sdvghack

Комментарии (4)


  1. katamoto
    03.12.2025 16:34

    Про сравнение телефонов - разве bases, который core plugin из коробки, не позволяет сделать то-же самое в несколько кликов, без вот этой вот простыни кода?


    1. Jarwix Автор
      03.12.2025 16:34

      Нет доступа к заметке за пределами YAML. Только к ссылкам, тегам и вставкам.


      1. katamoto
        03.12.2025 16:34

        В данном случае и не надо, ссылку на фото можно тоже в yaml добавить. Наверное Datacore и мощная штука, но пример явно неудачный.


  1. uvelichitel
    03.12.2025 16:34

    Немного offtop. Давно использую obsidian для заметок. Удобно. Разьве что открывается не мгновенно, electron все таки... И чем больше плагинов тем не мгновенней.
    А заметок поднакопилось. И вот, хочу собирать из них, заметок, крупные формы, ну там папер в pdf, не дай Бог роман, да просто пост для хабра.
    А у obsidian нет инструментов компиляции манускрипта. Asciidoctor обсидиановский маркдаун не ест, pandoc не ест, новомодный typst не ест, хабр не ест, в latex не конвертируется, в docbook не конвертируется. Да никакой инструмент публикации не ест.
    Может насоветуете компилятор обсидиановского маркдауна для крупных форм. Workflow какой нибудь...