Я много использую Obsidian для работы. И я люблю плагин Dataview, но в нем довольно много недостатков. И одна из них — он не имеет доступ ко всем данным в заметках. Поэтому в этой статье я расскажу о новом плагине 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 можно таким образом:
-
Зайдите в настройки (значок шестеренки около названия Хранилища):

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

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

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

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

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

Установка скрипта
Создайте заметку в любом месте (но лучше не в папке с нужными файлами, в моем случае это Телефон) и вставьте в заметку следующий код:
---
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)

uvelichitel
03.12.2025 16:34Немного offtop. Давно использую obsidian для заметок. Удобно. Разьве что открывается не мгновенно, electron все таки... И чем больше плагинов тем не мгновенней.
А заметок поднакопилось. И вот, хочу собирать из них, заметок, крупные формы, ну там папер в pdf, не дай Бог роман, да просто пост для хабра.
А у obsidian нет инструментов компиляции манускрипта. Asciidoctor обсидиановский маркдаун не ест, pandoc не ест, новомодный typst не ест, хабр не ест, в latex не конвертируется, в docbook не конвертируется. Да никакой инструмент публикации не ест.
Может насоветуете компилятор обсидиановского маркдауна для крупных форм. Workflow какой нибудь...
katamoto
Про сравнение телефонов - разве bases, который core plugin из коробки, не позволяет сделать то-же самое в несколько кликов, без вот этой вот простыни кода?
Jarwix Автор
Нет доступа к заметке за пределами YAML. Только к ссылкам, тегам и вставкам.
katamoto
В данном случае и не надо, ссылку на фото можно тоже в yaml добавить. Наверное Datacore и мощная штука, но пример явно неудачный.