javascript

2 года в Obsidian: мой workflow, архитектура заметок, плагины и синхронизация

  • понедельник, 27 июля 2026 г. в 00:00:17
https://habr.com/ru/articles/1063080/

Введение

Около двух лет я использую Obsidian как основное приложение для ведения заметок, планирования и написания статей. В этой статье я хочу поделиться своим полным workflow: от распределения заметок по папкам и горячих клавиш до детального Cheatsheet по хранилищу и организации метода Zettelkasten.

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

Ниже — краткий план того, о чём будет статья:

  1. Архитектура папок (где и что хранится, а также подробный Cheatsheet.md, который я составлял для себя).

  2. Плагины, которые я использую.

  3. Настройка Homepage и каталога книг.

  4. Синхронизация ПК + телефон (плагин Git + Termux на телефоне, у меня репозиторий в Gitea).

Архитектура

Это исчерпывающее руководство по моей системе заметок и записей (Obsidian и Habitica). Если я что-то забуду — вся информация здесь.

Obsidian

Термины и правила

  1. Файлы-якоря (taglinks) — замена тегов в их привычном понимании. Смысл в том, что это те же теги, но в виде отдельных файлов. Их преимущество — явная наглядность и возможность добавить внутреннюю логику (например, указать прямые ссылки на другие файлы для быстрого поиска или расписать описание самого тега). Имеют вид [[Мой якорь]], где скобки обозначают ссылку на файл.

  2. Индекс (index) — taglink с расширенной внутренней логикой. Как правило, у такого index-файла много связей с другими «полноценными» заметами. В начале имени любого индекса ставится 00, а название пишется с большой буквы. Например, taglink [[физика]] превращается в [[00 Физика]]. Это нужно для того, чтобы с первого взгляда отделить индекс от обычного taglink.

  3. Теги (tags) — классические теги с решёткой (например: #тег). Используются для неявного указания свойств файла. Я указываю тип файла (#plan, #note, #zettel, #book, #index) для быстрого поиска. Если это #zettel-файл, я добавляю теги прогресса #baby, #teen, #adult:

    • #baby — заметка только на стадии зарождения;

    • #teen — заметка в процессе написания;

    • #adult — заметка самодостаточна и всегда полезна (вечнозелёная).

    Важно помнить: даже если заметка уже «выросла», её всё равно можно дополнять. Для этого создаётся новый файл со ссылкой на старый, который раскрывает тему дальше или с другого ракурса.

Типы файлов

  • note — для простых и оперативных записей (которые со временем могут потерять актуальность);

  • plan — для планов, программ и проектов;

  • zettel — для постоянных знаний в Zettelkasten (статусы: #baby, #teen, #adult);

  • book — для конспектов и карточек книг;

  • index — для индексов.

Файловая структура

_files

Здесь хранятся все медиафайлы, используемые в заметках (фотографии, схемы, рисунки и т.д.).

_templates

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

  1. [[Book temp]] — шаблон для книг в папке Books. Состоит из:

    • title (название книги),

    • cover (ссылка на обложку),

    • author (автор),

    • genre (жанр),

    • tags (#book),

    • taglinks (ссылки на файлы-якоря),

    • status (статус: прочитана / читаю / в планах),

    • current_page (текущая страница),

    • total_pages (всего страниц),

    • date_started (дата начала чтения),

    • date_ended (дата окончания чтения),

    • other_internal_links (дополнительные ссылки на связанные файлы; по умолчанию стоит ссылка на каталог книг),

    • other_external_links (дополнительные ссылки на внешние ресурсы).

  2. [[Index temp]] — шаблон для индексов в папке Indexes. Состоит из:

    • created_at (дата и время создания),

    • tags (#index),

    • other_internal_links (дополнительные внутренние ссылки),

    • other_external_links (дополнительные внешние ссылки).

  3. [[Note temp]] — шаблон для заметок в папке Plans & Notes. Состоит из:

    • created_at (дата и время создания),

    • tags (#note),

    • taglinks (ссылки на файлы-якоря),

    • other_internal_links (дополнительные внутренние ссылки),

    • other_external_links (дополнительные внешние ссылки).

  4. [[Plan temp]] — шаблон для планов и программ в папке Plans & Notes. Состоит из:

    • created_at (дата и время создания),

    • tags (#plan),

    • taglinks (ссылки на файлы-якоря),

    • other_internal_links (дополнительные внутренние ссылки),

    • other_external_links (дополнительные внешние ссылки).

  5. [[Zettel temp]] — шаблон для записей в папке Zettelkasten. Состоит из:

    • created_at (дата и время создания),

    • tags (#zettel и #baby, который затем меняется на #teen и #adult по мере готовности),

    • taglinks (ссылки на файлы-якоря),

    • other_internal_links (дополнительные внутренние ссылки),

    • other_external_links (дополнительные внешние ссылки).

Taglinks

Папка для хранения всех файлов-якорей.

Indexes

Хранилище всех индексов (файлов с внутренней логикой). Позволяет быстро находить нужные темы через систему связей.

Plans & Notes

Здесь хранятся все планы, программы (plans) и обычные заметки (notes). Отдельная папка нужна, чтобы не засорять основную базу, так как эти записи не имеют бесконечной ценности.

Zettelkasten

Главная база знаний. Сюда попадают идеи и материалы, содержащие ценные умозаключения, которые пригодятся в будущем.

Главный критерий: «Хотел бы я, чтобы эта информация осталась со мной надолго?». Если ответ «да» — материал ценен и его нужно зафиксировать.

  1. Rough Notes (Черновики):

    • Что это: Папка для «сырых» и только зарождающихся заметок. Все записи в Zettelkasten начинаются отсюда.

    • Что внутри: Заметки с потенциалом, еще не получившие законченную форму. Это «рассада» моей базы знаний.

    • Критерий: Если заметка неполная или требует доработки — она остаётся здесь.

  2. Grown Notes (Выросшие заметки):

    • Что это: Основное хранилище ценных и сформированных заметок.

    • Что внутри: Доработанные записи с законченной мыслью, понятные без стороннего контекста.

    • Критерий: Как только заметка становится самодостаточной, я перемещаю её из Rough Notes в Grown Notes.

Books

Папка для прочитанных книг. Главный файл — Каталог, в котором наглядно отображаются все книги.

  • Создаётся файл с названием книги, применяется и заполняется шаблон. Ниже заголовка можно писать краткие мысли. Если требуется полноценный разбор книги, создаётся отдельный файл, который отправляется в Zettelkasten.

Остальные файлы

  • [[Cheatsheet.md]] — исчерпывающая шпаргалка по системе (этот файл);

  • [[Homepage.md]] — домашняя страница с ключевыми ссылками;

  • [[Inbox.md]] — файл для быстрых заметок и невыполненных за день дел;

  • Bookmarks — встроенные закладки Obsidian для мгновенного доступа к частым файлам.

Основные горячие клавиши

  • Alt + S — вставка шаблона

  • Alt + Q — вставка Callout

  • Ctrl + O — открыть Quick Switcher

  • Ctrl + P — открыть Command Palette

  • Ctrl + E — переключить режим просмотра (Current View)

  • Ctrl + ЛКМ — открыть ссылку в новой вкладке

  • Ctrl + Shift + F — поиск по всем файлам

  • Ctrl + ↑ — переместить строку вверх

  • Ctrl + ↓ — переместить строку вниз

  • Alt + 1 — замена сниппета (Snippet Replacement)

  • Alt + A — свернуть все списки и заголовки

  • Alt + D — развернуть все списки и заголовки

Как правильно записывать информацию?

  1. Найти интересный материал.

  2. Прочитать и разбирать его до полного понимания.

  3. Закрыть источник и записать суть в Obsidian в удобном формате (рисунок, таблица, список, текст).

  4. Задать себе вопросы: «Будет ли польза от этой заметки в будущем?» и «В каком конкретном сценарии я это применю?». Если ответы положительные — оставляем.

  5. Проставить ссылки (taglinks, other_internal_links), теги и проверить связность с другими файлами (если это не #index).

  6. Открыть первоисточник и сверить точность записей, при необходимости скорректировать.

  7. Перечитать заметку и проверить, понятен ли материал. Если есть пробелы — повторить шаг 6, пока заметка не станет на 100% понятной и автономной.

  8. При возникновении сопутствующих вопросов создать новую связанную заметку и пройти алгоритм с 1-го пункта.

Habitica

Привычки (habits)

Регулярные или пассивные действия, которые выполняются в течение дня.

Дела (tasks)

Быстрые разовые задачи с коротким сроком жизни (1–2 дня): загрузить посудомойку, помыть пол и т.д.

Ежедневные дела (recurring tasks)

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

Отложенные (deferred)

Задачи на перспективу («когда-нибудь»). Находятся в мобильном приложении (HabitNow / Habitica) для оперативного доступа с телефона. Задачи из Obsidian Inbox касаются именно рабочих процессов за ПК, поэтому их удобнее вести за компьютером.

Плагины, которые я использую

  1. Better Word Count — подсчёт слов и символов.

  2. Commander — кастомизация интерфейса и добавление быстрых команд.

  3. Dataview — агрегация и отображение сложных данных через запросы.

  4. Excalidraw — рукописные схемы и подготовка к экзаменам на графическом планшете.

  5. Git — автоматическая синхронизация с репозиторием.

  6. Homepage — настройка стартовой домашней страницы.

  7. Ink — быстрое создание рукописных заметок.

  8. List Callouts — стильное оформление списков и акцентов.

  9. Templater — расширенная работа с шаблонами и скриптами.

  10. Text Snippets — автозамена текста на заранее заготовленные шаблоны.

Настройка Homepage

Файл домашней страницы (Homepage.md):

---
cssclasses: dashboard
---

<div class="fade-banner">
  <img src="_files/sandpile.jpg" alt="Banner">
  <div class="banner-gradient"></div>
</div>

# 🏛️ My Dashboard
- ## 💻 [[3 - Plans & Notes/NetDevSecOps.canvas|NetDevSecOps]]
- ## 📚 [[5 - Books/00 Каталог.md|Каталог книг]]
- ## 📬 [[Inbox]]
---
- ### 🏋️ Тренировки
	- [[Тренировки зал full-body]]
	- [[3 - Plans & Notes/Тренировки лето новые.md|Тренировки лето]]
	- [[Доп. Тренировки (3 в неделю)]]
- ### 🎯 Цели и Планы
	- [[3 - Plans & Notes/Цели на 2026.md|Цели на 2026]]
	- [[3 - Plans & Notes/Еженедельный план 2026.md|Еженедельный план 2026]]
	- [[2 - Indexes/00 Хочу.md|Все что я хочу]]
- ### 📔 Полезные материалы
	- [[Interview Questions|Вопросы DevOps Advanced]]
	- [[DevOps Questions Basic Level|Вопросы DevOps Basic]]
	- [[Механизм минимальных затрат]]

CSS-сниппет для оформления панели (простой стиль без лишних элементов):

.fade-banner {
    position: relative;
    width: 100%;
    height: 200px;
    overflow: hidden;
    margin-bottom: 25px;
    border-radius: 8px;
}

.fade-banner img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.banner-gradient {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: linear-gradient(to bottom, transparent 50%, var(--background-primary) 100%);
}

.dashboard {
    font-family: inherit;
    padding-left: 25px !important;
    padding-right: 25px !important;
    padding-top: 20px !important;
}

.dashboard .markdown-preview-section {
    max-width: 100%;
}

.dashboard h1 {
    font-size: 2em;
    font-weight: 600;
    margin-bottom: 20px;
}

.dashboard div>ul>li>b,
.dashboard div>ul>li>strong {
    display: block;
    font-size: 1.2em;
    font-weight: 600;
    margin-bottom: 12px;
    color: var(--text-normal);
}

.dashboard div>ul>li {
    background-color: transparent !important;
    border: none !important;
    box-shadow: none !important;
    padding: 10px !important;
}

.dashboard div>ul {
    list-style: none;
    display: flex;
    column-gap: 40px;
    row-gap: 25px;
    flex-flow: row wrap;
    padding-left: 0px !important;
}

.dashboard div>ul>li {
    min-width: 260px;
    flex: 1;
}

.dashboard div.markdown-preview-section>div>ul>li>.list-bullet {
    display: none !important;
}

.dashboard.markdown-rendered.show-indentation-guide li>ul::before,
.dashboard.markdown-rendered.show-indentation-guide li>ol::before {
    display: none;
}

div.markdown-preview-section>div>ul.has-list-bullet>li {
    padding-left: 0px !important;
}

.dashboard ul.dataview {
    row-gap: 0px !important;
    list-style-type: disc !important;
}

.dashboard-welcome {
    font-size: 1.25em;
    font-weight: 800;
    letter-spacing: 7px;
    text-transform: uppercase;
    color: #FFFFFF !important;

    text-shadow: 0px 2px 4px rgba(255, 255, 255, 0.6),
                 0px 4px 10px rgba(0, 0, 0, 0.4);

    margin-bottom: -15px;
    opacity: 1;
    text-align: center;
    display: block;
}

Настройка каталога книг

Основной файл каталога (работает на dataviewjs):

const bookPages = dv.pages('"5 - Books"').where(p => p.title);
const container = this.container;

const style = document.createElement("style");
style.textContent = `
    .book-table {
        width: 100%;
        border-collapse: collapse;
        font-family: Cascadia Mono, monospace;
        margin-top: 1rem;
        font-size: 15px;
    }
    .book-table th {
        background: rgba(138, 43, 226, 0.15);
        color: #d6bcfa;
        padding: 12px 14px;
        border-bottom: 2px solid rgba(214, 188, 250, 0.3);
        font-weight: 600;
        text-align: left;
    }
    .book-table td {
        padding: 12px 14px;
        border-bottom: 1px solid rgba(214, 188, 250, 0.15);
        color: #e9d8fd;
        vertical-align: middle;
    }
    .book-table tr {
        transition: all 0.2s ease;
    }
    .book-table tr:hover {
        background: rgba(138, 43, 226, 0.12) !important;
    }
    .filter-container {
        background: rgba(138, 43, 226, 0.1);
        backdrop-filter: blur(10px);
        border-radius: 12px;
        padding: 1.2rem;
        margin-bottom: 2rem;
        border: 1px solid rgba(214, 188, 250, 0.2);
        display: flex;
        gap: 1rem;
        align-items: center;
        flex-wrap: wrap;
    }
    .filter-select, .filter-search {
        padding: 10px 14px;
        border-radius: 10px;
        border: 1px solid rgba(214, 188, 250, 0.3);
        background: rgba(26, 26, 32, 0.8);
        color: #e9d8fd;
        font-size: 14px;
        outline: none;
        transition: all 0.2s ease;
        height: 40px;
        box-sizing: border-box;
        font-family: Cascadia Mono, monospace;
    }
    .filter-select:hover, .filter-search:focus {
        border-color: rgba(214, 188, 250, 0.6);
        box-shadow: 0 0 0 4px rgba(138, 43, 226, 0.15);
    }
    .filter-search {
        flex: 1;
        min-width: 250px;
    }
    .book-title-link {
        color: #d6bcfa;
        text-decoration: none;
        font-weight: 600;
        font-size: 16px;
        transition: color 0.2s;
        line-height: 1.3;
    }
    .book-title-link:hover {
        color: #e9d8fd;
    }
    .book-meta-text {
        font-size: 12px;
        color: rgba(214, 188, 250, 0.7);
        margin-top: 3px;
    }
    .status-dropdown {
        padding: 4px 10px;
        border-radius: 14px;
        font-size: 13px;
        font-weight: 600;
        border: 1px solid transparent;
        outline: none;
        cursor: pointer;
        transition: all 0.2s;
        width: 130px;
        text-align: center;
        height: 36px;
        box-sizing: border-box;
        font-family: Cascadia Mono, monospace;
    }
    .page-num-input {
        width: 55px;
        background: rgba(138, 43, 226, 0.15);
        border: 1px solid rgba(214, 188, 250, 0.3);
        color: #e9d8fd;
        border-radius: 6px;
        padding: 4px 6px;
        text-align: center;
        font-size: 13px;
        font-family: Cascadia Mono, monospace;
        outline: none;
        transition: all 0.2s;
        box-sizing: border-box;
    }
    .page-num-input:focus {
        border-color: rgba(214, 188, 250, 0.6);
        background: rgba(26, 26, 32, 0.9);
        box-shadow: 0 0 0 3px rgba(138, 43, 226, 0.2);
    }
    .page-num-input::-webkit-outer-spin-button,
    .page-num-input::-webkit-inner-spin-button {
        -webkit-appearance: none;
        margin: 0;
    }
    .page-num-input {
        -moz-appearance: textfield;
    }
    .progress-bar-container {
        background: rgba(214, 188, 250, 0.15);
        border-radius: 12px;
        height: 6px;
        width: 100%;
        overflow: hidden;
    }
    .progress-bar-fill {
        background: linear-gradient(90deg, #8A2BE2, #9370DB);
        height: 100%;
        transition: width 0.3s ease;
    }
    .book-cover-img {
        width: 75px;
        height: 108px;
        border-radius: 8px;
        object-fit: cover;
        box-shadow: 0 6px 14px rgba(0,0,0,0.4);
        border: 1px solid rgba(214, 188, 250, 0.2);
        transition: transform 0.2s;
        cursor: pointer;
    }
    .book-cover-img:hover {
        transform: scale(1.05);
    }
    .total-page-label {
        font-size: 12px;
        color: rgba(214, 188, 250, 0.8);
        white-space: nowrap;
    }

    /* Мобильная оптимизация */
    @media (max-width: 600px) {
        .book-table th, .book-table td {
            padding: 8px 6px;
            font-size: 13px;
        }
        .book-cover-img {
            width: 50px;
            height: 72px;
        }
        .book-title-link {
            font-size: 14px;
        }
        .book-meta-text {
            font-size: 11px;
        }
        .status-dropdown {
            width: 105px;
            font-size: 12px;
            height: 32px;
            padding: 2px 4px;
        }
        .page-num-input {
            width: 45px;
            font-size: 12px;
            padding: 3px 2px;
        }
        .total-page-label {
            font-size: 11px;
        }
        .filter-container {
            padding: 0.8rem;
            flex-direction: column;
            align-items: stretch;
            gap: 0.6rem;
        }
        .filter-select, .filter-search {
            width: 100%;
        }
    }
`;
document.head.appendChild(style);

const filterContainer = document.createElement("div");
filterContainer.className = "filter-container";

const genreSelect = document.createElement("select");
genreSelect.className = "filter-select";
const allOption = document.createElement("option");
allOption.textContent = "Все жанры";
allOption.value = "";
genreSelect.appendChild(allOption);

const allGenres = new Set();
for (let p of bookPages) {
    if (p.genre) {
        const genres = Array.isArray(p.genre) ? p.genre : [p.genre];
        genres.forEach(g => g && allGenres.add(g));
    }
}
for (let genre of Array.from(allGenres).sort()) {
    const opt = document.createElement("option");
    opt.textContent = genre;
    opt.value = genre;
    opt.style.background = "#1a1a20";
    genreSelect.appendChild(opt);
}
filterContainer.appendChild(genreSelect);

const statusSelect = document.createElement("select");
statusSelect.className = "filter-select";
const statusOptions = [
    { value: "", text: "Все статусы" },
    { value: "reading", text: "Читаю" },
    { value: "completed", text: "Прочитано" },
    { value: "planned", text: "В планах" }
];
statusOptions.forEach(o => {
    const opt = document.createElement("option");
    opt.textContent = o.text;
    opt.value = o.value;
    opt.style.background = "#1a1a20";
    statusSelect.appendChild(opt);
});
filterContainer.appendChild(statusSelect);

const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.className = "filter-search";
searchInput.placeholder = "🔍 Поиск по названию или автору...";
filterContainer.appendChild(searchInput);

container.appendChild(filterContainer);

function determineBookStatus(page) {
    if (page.status) return page.status;
    const today = new Date().toISOString().split('T')[0];
    if (page.date_ended && page.date_ended <= today) return "completed";
    if (page.date_started) return "reading";
    const total = page.total_pages || 100;
    const current = page.current_page || 0;
    if (total > 0 && current >= total) return "completed";
    if (page.current_page && page.current_page > 0) return "reading";
    return "planned";
}

function applyStatusStyle(selectElement, status) {
    if (status === "completed") {
        selectElement.style.background = "rgba(102, 221, 170, 0.15)";
        selectElement.style.color = "#66ddaa";
        selectElement.style.borderColor = "rgba(102, 221, 170, 0.25)";
    } else if (status === "reading") {
        selectElement.style.background = "rgba(147, 112, 219, 0.15)";
        selectElement.style.color = "#9370db";
        selectElement.style.borderColor = "rgba(147, 112, 219, 0.25)";
    } else {
        selectElement.style.background = "rgba(214, 188, 250, 0.15)";
        selectElement.style.color = "#d6bcfa";
        selectElement.style.borderColor = "rgba(214, 188, 250, 0.25)";
    }
}

async function updatePageProperty(page, property, value) {
    try {
        const file = app.vault.getAbstractFileByPath(page.file.path);
        if (!file) return false;
        await app.fileManager.processFrontMatter(file, (fm) => {
            if (value === undefined || value === "") { delete fm[property]; } 
            else { fm[property] = value; }
        });
        page[property] = value;
        return true;
    } catch (e) { console.error(e); return false; }
}

async function removePageProperty(page, property) {
    try {
        const file = app.vault.getAbstractFileByPath(page.file.path);
        if (!file) return false;
        await app.fileManager.processFrontMatter(file, (fm) => { delete fm[property]; });
        delete page[property];
        return true;
    } catch (e) { console.error(e); return false; }
}

function renderTable(selectedGenre = "", selectedStatus = "", searchTerm = "") {
    const oldTable = container.querySelector(".book-table");
    if (oldTable) oldTable.remove();

    const table = document.createElement("table");
    table.className = "book-table";

    const header = table.insertRow();
    ["Обложка", "Книга", "Статус", "Прогресс"].forEach(t => {
        const th = document.createElement("th");
        th.textContent = t;
        header.appendChild(th);
    });

    for (let p of bookPages) {
        const genreMatch = !selectedGenre || (p.genre && ((Array.isArray(p.genre) && p.genre.includes(selectedGenre)) || p.genre === selectedGenre));
        const actualStatus = determineBookStatus(p);
        const statusMatch = !selectedStatus || actualStatus === selectedStatus;
        const searchMatch = !searchTerm || (p.title && p.title.toLowerCase().includes(searchTerm.toLowerCase())) || (p.author && p.author.toLowerCase().includes(searchTerm.toLowerCase()));

        if (!(genreMatch && statusMatch && searchMatch)) continue;

        let total = p.total_pages || 100;
        let current = p.current_page || 0;
        let percent = total > 0 ? Math.round((current / total) * 100) : 0;

        const row = table.insertRow();
        row.onclick = (e) => {
            if (!e.target.closest('a') && !e.target.closest('select') && !e.target.closest('input') && !e.target.closest('img')) {
                window.location.href = p.file.path;
            }
        };

        const cellCover = row.insertCell();
        if (p.cover) {
            const img = document.createElement("img");
            img.src = p.cover;
            img.className = "book-cover-img";
            img.onclick = () => window.location.href = p.file.path;
            cellCover.appendChild(img);
        } else {
            const placeholder = document.createElement("div");
            placeholder.className = "book-cover-img";
            placeholder.style.background = "rgba(138, 43, 226, 0.12)";
            placeholder.style.display = "flex";
            placeholder.style.alignItems = "center";
            placeholder.style.justifyContent = "center";
            placeholder.style.fontSize = "22px";
            placeholder.style.border = "1px dashed rgba(214, 188, 250, 0.2)";
            placeholder.innerHTML = "📖";
            placeholder.onclick = () => window.location.href = p.file.path;
            cellCover.appendChild(placeholder);
        }

        const cellInfo = row.insertCell();
        const infoWrapper = document.createElement("div");
        infoWrapper.style.display = "flex";
        infoWrapper.style.flexDirection = "column";

        const link = document.createElement("a");
        link.href = p.file.path;
        link.textContent = p.title || p.file.name;
        link.className = "internal-link book-title-link";

        const metaText = document.createElement("div");
        metaText.className = "book-meta-text";
        const author = p.author || "Автор не указан";
        const genre = Array.isArray(p.genre) ? p.genre.join(", ") : (p.genre || "");
        metaText.textContent = author + (genre ? ` • ${genre}` : "");

        infoWrapper.appendChild(link);
        infoWrapper.appendChild(metaText);
        cellInfo.appendChild(infoWrapper);

        const cellStatus = row.insertCell();
        const sel = document.createElement("select");
        sel.className = "status-dropdown";
        
        const opts = [
            { value: "planned", text: "📚 В планах" },
            { value: "reading", text: "📖 Читаю" },
            { value: "completed", text: "✓ Готово" }
        ];
        opts.forEach(o => {
            const opt = document.createElement("option");
            opt.value = o.value;
            opt.textContent = o.text;
            opt.style.background = "#1a1a20";
            if (o.value === actualStatus) opt.selected = true;
            sel.appendChild(opt);
        });
        applyStatusStyle(sel, actualStatus);
        cellStatus.appendChild(sel);

        sel.onchange = async () => {
            const newStatus = sel.value;
            applyStatusStyle(sel, newStatus);
            if (newStatus !== actualStatus) {
                let success = await updatePageProperty(p, "status", newStatus);
                if (success) {
                    if (actualStatus === "completed" && newStatus !== "completed") {
                        const newCurrent = Math.max(0, Math.floor(total * 0.9));
                        await updatePageProperty(p, "current_page", newCurrent);
                        await removePageProperty(p, "date_ended");
                    } else if (newStatus === "completed") {
                        await updatePageProperty(p, "current_page", total);
                        await updatePageProperty(p, "date_ended", new Date().toISOString().split('T')[0]);
                    }
                    renderTable(genreSelect.value, statusSelect.value, searchInput.value);
                }
            }
        };

        const cellProgress = row.insertCell();
        const progressWrapper = document.createElement("div");
        progressWrapper.style.display = "flex";
        progressWrapper.style.flexDirection = "column";
        progressWrapper.style.gap = "6px";
        progressWrapper.style.minWidth = "120px";

        const barContainer = document.createElement("div");
        barContainer.className = "progress-bar-container";
        const barFill = document.createElement("div");
        barFill.className = "progress-bar-fill";
        barFill.style.width = `${percent}%`;
        barContainer.appendChild(barFill);
        progressWrapper.appendChild(barContainer);

        const inputRow = document.createElement("div");
        inputRow.style.display = "flex";
        inputRow.style.alignItems = "center";
        inputRow.style.gap = "4px";
        inputRow.style.justifyContent = "flex-end";

        const numInput = document.createElement("input");
        numInput.type = "number";
        numInput.className = "page-num-input";
        numInput.value = current;
        numInput.min = 0;
        numInput.max = total;

        const label = document.createElement("span");
        label.className = "total-page-label";
        label.textContent = `/${total} (${percent}%)`;

        inputRow.appendChild(numInput);
        inputRow.appendChild(label);
        progressWrapper.appendChild(inputRow);
        cellProgress.appendChild(progressWrapper);

        const saveProgressValue = async () => {
            let val = parseInt(numInput.value);
            if (isNaN(val)) val = current;
            val = Math.max(0, Math.min(val, total));

            if (val !== current) {
                let success = await updatePageProperty(p, "current_page", val);
                if (success) {
                    if (val >= total) {
                        await updatePageProperty(p, "status", "completed");
                        await updatePageProperty(p, "date_ended", new Date().toISOString().split('T')[0]);
                    } else if (actualStatus === "completed" && val < total) {
                        await updatePageProperty(p, "status", "reading");
                        await removePageProperty(p, "date_ended");
                    } else if (actualStatus === "planned" && val > 0) {
                        await updatePageProperty(p, "status", "reading");
                        await updatePageProperty(p, "date_started", new Date().toISOString().split('T')[0]);
                    }
                    renderTable(genreSelect.value, statusSelect.value, searchInput.value);
                }
            } else {
                numInput.value = current;
            }
        };

        numInput.onblur = saveProgressValue;
        numInput.onkeydown = (ev) => {
            if (ev.key === "Enter") numInput.blur();
            if (ev.key === "Escape") { numInput.value = current; numInput.blur(); }
        };
    }

    container.appendChild(table);
}

genreSelect.onchange = () => renderTable(genreSelect.value, statusSelect.value, searchInput.value);
statusSelect.onchange = () => renderTable(genreSelect.value, statusSelect.value, searchInput.value);
searchInput.oninput = () => renderTable(genreSelect.value, statusSelect.value, searchInput.value);

renderTable();

Синхронизация ПК и смартфона (Git + Termux + Gitea)

Для независимой синхронизации без сторонних платных сервисов я использую связку Git, собственный инстанс Gitea и Termux на Android:

  1. На ПК: В самом Obsidian настроен плагин Obsidian Git. Он автоматически коммитит и отправляет (push) изменения в мой закрытый репозиторий на Gitea при закрытии программы или каждые 10–15 минут.

  2. На телефоне:

    • Настроен Termux с установленным пакетом git и SSH-ключами для доступа к Gitea.

    • Синхронизацию я запускаю через комманду (alias) gsync в ~/.zshrc (или ~/.bashrc), которая делает сначала pull, потом push репозитория. Да, приходится заходить в терминал и писать gsync, но меня пока что устраивает (alias gsync="cd ~/storage/shared/Obsidian/Vault && git pull origin main && git add . && git commit -m 'mobile auto-sync' && git push origin main")

    • Также уточню, что git-crypt я не использую, так как репозиторий находится в моем self-hosted Gitea, данные никуда не утекают.

  3. Почему именно Gitea: Полный контроль над данными, отсутствие ограничений на размер файлов и бесплатный приватный сервер.

Обязательно стоит добавить .gitignore: (чтобы не было конфликтов веток)

.obsidian/workspace.json
.obsidian/workspace-mobile.json
.obsidian/cache
.trash/

Также вот неплохая статья с гайдом по настройке (подход немного отличается от моего)

Заключение

Эта система складывалась постепенно и продолжает эволюционировать. Главный вывод за два года использования — хранилище не должно быть идеальным с первого дня. Оно должно помогать думать и работать, а не забирать всё время на свою настройку.

Надеюсь, мой workflow подаст вам пару интересных идей для организации собственного цифрового сада в Obsidian. Если у вас есть вопросы по работе со скриптами или структурой — пишите в комментариях!

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