Используя Obsidian более двух лет, я привык организовывать в нём все свои заметки, в том числе и по проектам. Хоть Obsidian и предлагает широкий набор сторонних плагинов для расширения своего функционала, но мне так и не удалось найти идеальный для управления проектами и задачами. Это подтолкнуло меня к созданию нескольких автоматизаций, о которых и пойдет речь дальше.
Алгоритм работы с проектом
-
Создаем заметку с типом проект.
-
Проект автоматически добавляется в заметку Homepage.
-
В проекте отображаются все его задачи с датами, статусами и ссылками.
-
-
Создаем заметку с типом задача.
-
Автоматически создается ежедневная заметка.
-
В задаче отображается все содержание из ежедневных заметок, относящееся к этой задаче.
-
-
Вносим записи в ежедневную заметку.
Требования к окружению Obsidian
Заметка Homepage
Это центральная заметка, где лежат ссылки на все существующие проекты. Заметка должна содержать заголовок третьего уровня (###), а проекты должны быть в виде списка:
В Homepage удобно хранить ссылки на центральные заметки хранилища Obsidian, которые агрегируют под собой заметки (ссылки на приложения, обучение, языки и прочее).
Шаблоны
-
Создаем папку templates. Все шаблоны будут находиться в ней.
-
Создаем четыре шаблона: main, daily, project и task.
Плагины для Obsidian
Чтобы установить плагины, нужно зайти в настройки Obsidian и в меню слева перейти в раздел Community plugin.
Templater
Шаблон main должен применяться ко всем вновь созданным заметкам. Это можно реализовать в настройках плагина templater. В разделе Folder templates в качестве директории нужно выбрать / (корень), а в качестве шаблона — main.md:
Calendar и Periodic Notes
В Periodic Notes я использую только ежедневные заметки. Тут надо задать формат даты и папку для хранения ежедневных заметок:
А для удобства создания и управления ежедневными заметками уже используется плагин Calendar.
Kanban
Доску я назвал Рабочие задачи. Я использовал следующие колонки: Backlog, To do, В работе, Тестирование, Done, Canceled и Повторяющиеся. Это важно, так как имя доски и статусы будут далее использоваться в коде автоматизаций.
Dataview
Тут надо включить поддержку JS:
CSS
Ограничимся двумя файлами стилей: wide-page.css и table-styling.css. Оба нужны, чтобы сделать заметки более читаемыми и удобными в работе.
wide-page
Растягивает страницу на всю ширину.
body .wide-page { --file-line-width: 100%; }
table-styling
Добавляет в таблицу разделители. Помогает лучше воспринимать таблицу в заметках с проектами.
.table-divider table tr { border-bottom: 1px solid #444; }
Новые CSS добавляются в Obsidian в папку .obsidian\snippets (если папки нет, ее надо создать). Активировать стили надо в настройках:
Общий синтаксис
Templater
Шаблоны для плагина Templater пишутся внутри двойных фигурных скобок с процентами: <<% шаблон %>>
. Но если мы хотим использовать в шаблоне JS код, то надо добавить звездочку: <<%* шаблон с JS кодом %>>
.
Obsidian
Для хранения метаданных в Obsidian заметках используется YAML заголовок (front matter), который находится в блоке из тройных дефисов до и после него:
--- метаданные ---
Dataview
Для работы с JS кодом в плагине Dataview используется расширенная функция DataviewJS:
```dataviewjs JS код ```
Обертка dataviewjs обязательна для корректной интерпретации кода, но в статье я не буду ее далее использовать, так она ломает подсветку в блоке кода. Понять, когда используется dataviewjs можно по заголовку.
Содержание шаблонов
Использование чужих шаблонов в Obsidian может быть опасным из-за рисков вредоносного кода, утечки данных. Проверяйте источник и код перед использованием.
При написании шаблонов я использовал:
Шаблон main
Это центральный шаблон.
main
<%* try { // Проверяем, содержит ли каталог заметки имя periodic/daily const isDaily = tp.file.folder(true).includes("periodic/daily"); // Создаем массив с возможными типами заметок const options = ["задача", "проект"]; // Проверяем, является ли заметка ежедневной if (isDaily) { // Если это ежедневная заметка, то применяем на нее шаблон daily tR += await tp.file.include("[[templates/daily]]"); } else { // Предлагаем выбрать тип заметки const chosenOption = await tp.system.suggester(options, options); let noteName; let fileExists; // Цикл для проверки имени заметки на уникальность do { // Предлагаем ввести новое имя для заметки noteName = await tp.system.prompt("Введите новое имя для файла:"); if (noteName) { // Проверяем, существует ли заметка с таким именем fileExists = await tp.file.exists(noteName + ".md"); if (fileExists) { // Выводим уведомление, если заметка существует new Notice("Заметка с таким именем уже существует. Пожалуйста, выберите другое имя."); } } else { // Выводим уведомление, если пользователь отменил ввод имени заметки new Notice("Переименование отменено."); break; } } while (fileExists); if (noteName && !fileExists) { // Переименовываем заметку await tp.file.rename(noteName); } if (chosenOption === "задача") { // Если тип заметки "задача", применяем к ней шаблон task tR += await tp.file.include("[[templates/task]]"); } else if (chosenOption === "проект") { // Если тип заметки "проект", применяем к ней шаблон project tR += await tp.file.include("[[templates/project]]"); } } } catch (error) { console.error("Templater Error:", error); } %>
Пояснения к работе кода:
-
При создании ежедневной заметки, к ней будет автоматически применен шаблон daily. Во всех остальных случаях будет предложено выбрать тип создаваемой заметки.
-
Предлагается два типа заметок, проект и задача. Можно создать пустую заметку, нажав Esc. Как только выбор сделан, будет предложено поменять имя заметки (можно оставить текущее имя, нажав Esc).
-
В зависимости от выбранного типа заметки, к ней будут применен соответствующий шаблон. Дальнейшие действия зависят от шаблона.
Шаблон daily
Шаблон для ежедневных заметок.
daily
<%* try { // Получаем имя текущей ежедневной заметки const noteName = tp.file.title; // Разбиваем полученное имя на компоненты даты const [day, month, year] = noteName.split('-').map(Number); // Создаём объект Date на основе поученных компонентов const currentNoteDate = new Date(year, month - 1, day); // Вычисляем предыдущий и следующий день let previousDayDate = new Date(currentNoteDate.setDate(currentNoteDate.getDate() - 1)); let nextDayDate = new Date(currentNoteDate.setDate(currentNoteDate.getDate() + 2)); // Форматируем дату обратно в "DD-MM-YYYY" const formatDate = (date) => { const dd = String(date.getDate()).padStart(2, '0'); const mm = String(date.getMonth() + 1).padStart(2, '0'); const yyyy = date.getFullYear(); return `${dd}-${mm}-${yyyy}`; }; const previousDay = formatDate(previousDayDate); const nextDay = formatDate(nextDayDate); // Формируем ссылки const baseFolder = tp.file.folder(true); const previousNotePath = `${baseFolder}/${previousDay}.md`; const nextNotePath = `${baseFolder}/${nextDay}.md`; // Выводим даты в виде ссылок tR += `← [[${previousNotePath}|${previousDay}]] | [[${nextNotePath}|${nextDay}]] →`; } catch (error) { console.error("Templater Error:", error); } %>
Пояснения к работе кода:
-
В ежедневной заметке создается навигация:
← вчерашний день | завтрашний день →
. -
При переходе по этой навигации, в случае если ежедневнfя заметка существует, она будет открыта. Если ежедневная заметка не существует, она будет создана в директории для ежедневных заметок (periodic/daily).
Шаблон project
Шаблон для заметок с типом проект.
Шаблон состоит из двух блоков: properties и dataviewjs.
Блок properties шаблона project
project properties
--- project: <%* try { // Получаем путь до заметки Homepage const homepageFile = await app.vault.getAbstractFileByPath('Homepage.md'); // Читаем содержимое заметки Homepage const content = await app.vault.cachedRead(homepageFile); // Определяем название секции с проектами const sectionTitle = 'Проекты'; // Создаём динамическое регулярное выражение для извлечения нужной секции const sectionRegex = new RegExp(`### ${sectionTitle}:\n([\\s\\S]*?)(?=\\n###|$)`); // Извлекаем содержимое секции const sectionMatch = sectionRegex.exec(content); const sectionContent = sectionMatch?.[1] || ''; // Ищем все ссылки на проекты в квадратных скобках const matchesIterator = sectionContent.matchAll(/- \[\[(.*?)\]\]/g); // Преобразуем итератор в массив названий проектов const projects = Array.from(matchesIterator, m => m[1]); // Получаем имя текущей заметки const currentNoteName = app.workspace.getActiveFile()?.basename; // Проверяем, есть ли создаваемый проект в общем списке проектов if (projects.includes(currentNoteName)) { new Notice(`Проект "${currentNoteName}" уже существует. Добавление отменено.`); } else { // Добавляем новый проект в список проектов const newSectionContent = sectionContent.trim() + `\n- [[${currentNoteName}]]\n`; // Обновляем содержимое списка проектов, добавляя новый проект const updatedContent = content.replace(sectionRegex, `### ${sectionTitle}:\n${newSectionContent}`); await app.vault.modify(homepageFile, updatedContent); new Notice(`Проект "${currentNoteName}" добавлен в секцию "${sectionTitle}".`); } tR += currentNoteName; } catch (error) { console.error("Templater Error:", error); } %> cssclasses: - wide-page ---
В properties задаются параметры project и cssclasses. Project будет использоваться в dataviewjs блоке, для поиска нужны заметок.
Пояснения к работе кода:
-
Вычитывается содержимое заметки Homepage, для получения списка проектов по ключевому слову «Проекты».
-
Если проекта с именем заметки еще нет, то он добавляется в список проектов в Homepage.
-
Project подставляется автоматически и равен имени заметки.
Блок dataviewjs шаблона project
project dataviewjs
try { // Получаем имя заметки const filterProject = app.workspace.getActiveFile()?.basename.toLowerCase(); const currentPath = dv.current().file.path; // Функция для преобразования строки в дату function parseDate(dateStr) { return moment(dateStr, 'DD-MM-YYYY').toDate(); } // Функция для преобразования даты в строку function formatDate(date) { return moment(date).format('DD-MM-YYYY'); } // Функция для получения иконки по статусу задачи function getStatusIcon(status) { const icons = { 'backlog': '?️', 'to do': '?', 'canceled': '?', 'в работе': '⚙️', 'тестирование': '?', 'done': '☑️' }; return icons[status.toLowerCase()] || '❓'; } // Функция для получения даты из имени ежедневной заметки async function getEventDatesFromDailyNotes(taskName) { const dailyNotes = dv.pages('"periodic/daily"').values; const eventDates = []; for (const page of dailyNotes) { const file = app.vault.getAbstractFileByPath(page.file.path); if (file?.extension === 'md') { const fileContent = await app.vault.cachedRead(file); const taskHeaderPattern = new RegExp(`###\\s*[^\\n]*\\[\\[${taskName}(#[^\\]]+)?\\]\\]`, 'i'); if (taskHeaderPattern.test(fileContent)) { const dateStr = page.file.name; const date = parseDate(dateStr); if (date) { eventDates.push(date); } } } } return eventDates; } // Проверяем наличие Kanban доски const kanbanFile = app.vault.getAbstractFileByPath("Рабочие задачи.md"); if (!kanbanFile) { dv.paragraph("Kanban доска не найдена."); return; } // Получаем содержимое Kanban доски const kanbanContent = await app.vault.cachedRead(kanbanFile); const taskStatusMap = {}; let currentStatus = null; // Разбираем содержимого Kanban доски по строкам kanbanContent.split('\n').forEach(line => { // Ищем заголовки статусов const headingMatch = line.match(/^##\s+(.*)/); if (headingMatch) { // Устанавливаем текущий статус currentStatus = headingMatch[1].trim(); } else if (currentStatus) { // Ищем ссылки на задачи const linkMatch = line.match(/\[\[([^\]]+)\]\]/); // Сопоставляем задачу со статусом if (linkMatch) taskStatusMap[linkMatch[1].trim()] = currentStatus; } }); // Фильтруем страницы по проекту const pages = dv.pages().filter(p => p.project && p.project.toLowerCase() === filterProject && p.file.path !== currentPath); let data = []; for (let page of pages) { // Получаем даты событий из ежедневных заметок let eventDates = await getEventDatesFromDailyNotes(page.file.name); // Если даты нет, используем дату страницы if (!eventDates.length && page.date) eventDates.push(parseDate(page.date)); // Определяем начальную дату let startDate = eventDates.length ? new Date(Math.min(...eventDates)) : null; // Определяем конечную дату let endDate = eventDates.length ? new Date(Math.max(...eventDates)) : null; const taskName = page.file.name; // Получаем текущий статус задачи const status = taskStatusMap[taskName] || "Не указано"; // Получаем иконку статуса const statusIcon = getStatusIcon(status); // Определяем формат времени выполнения let executionTime; if (startDate && endDate && startDate.getTime() !== endDate.getTime()) { // Если диапазон дат executionTime = `${formatDate(startDate)} — ${formatDate(endDate)}`; } else if (startDate) { // Если одна дата executionTime = formatDate(startDate); } else { // Если даты нет executionTime = "Нет даты"; } // Заполняем массив данными для таблицы data.push({ note: page.file.link, instance: page.instance || "Не указано", status: `${status} ${statusIcon}`, executionTime, startDate }); } // Сортируем данные по дате начала задачи data.sort((a, b) => (a.startDate || Infinity) - (b.startDate || Infinity)); if (data.length) { // Отображаем таблицу с данными dv.table( ["Заметка", "Инстанс", "Статус", "Время выполнения"], data.map(d => [d.note, d.instance, d.status, d.executionTime]) ); } else { // Выводим сообщение, если данных нет dv.paragraph("Нет данных для отображения."); } } catch (error) { console.error("Templater Error:", error); }
В результате получаем таблицу со всеми задачами проекта.
Пояснения к работе кода:
-
Добавляются все заметки, содержащие мету project со значением текущего проекта.
-
Статус задачи берется из Kanban доски «Рабочие задачи».
-
Даты времени выполнения берутся из ежедневных заметок, в которых есть заголовок третьего уровня (###) и ссылка ([[ ]]) на текущую заметку (задачу).
Шаблон task
Шаблон для заметок с типом задача.
Шаблон состоит из трех блоков: properties, дополнительных шаблон и dataviewjs.
Блок properties шаблона task
task properties
--- project: <%* try { // Подключаем содержимое заметки Homepage const content = await tp.file.include("[[Homepage]]"); // Определяем название секции с проектами const sectionTitle = 'Проекты'; // Создаём динамическое регулярное выражение для извлечения нужной секции const sectionRegex = new RegExp(`### ${sectionTitle}:\n([\\s\\S]*?)(?=\n###|$)`); // Извлекаем содержимое секции const section = sectionRegex.exec(content)?.[1]; if (section) { // Ищем все строки с квадратными скобками const matchesIterator = section.matchAll(/- \[\[(.*?)\]\]/g); // Преобразуем итератор в массив названий проектов const projects = Array.from(matchesIterator, m => m[1]); // Предлагаем выбрать проект из списка const selectedProject = await tp.system.suggester(projects, projects); // Выводим выбранный проект в заметку tR += `${selectedProject}`; } else { console.log("Секция не найдена."); } } catch (error) { console.error("Templater Error:", error); } %> instance: <%* try { const instanceValue = await tp.system.prompt("Введите значение для instance:"); if (instanceValue !== null) { tR += instanceValue + " "; } } catch (error) { console.error("Templater Error:", error); } %> date: <% tp.date.now("YYYY-MM-DD") %> cssclasses: - wide-page ---
Тут в properties, помимо project и cssclasses, задается еще instance и date. Project будет использоваться для связи заметки с проектом. Instance не влияет на прямую на работу кода. Значение будет просто выводиться в таблице проекта напротив задачи. date нужен как вспомогательный источник даты, на случай отсутствия ежедневной заметки.
Пояснения к работе кода:
-
Вычитывается содержимое заметки Homepage, для получения списка проектов по ключевому слову «Проекты».
-
Из полученного списка предлагается выбрать проект. Выбранный проект станет значением опции project в front matter.
-
Появляется предложение ввести имя инстанса (это опционально).
Блок с дополнительным шаблоном task
task дополнительный шаблон
<%* try { // Формируем полный путь к сегодняшней ежедневной заметке const dailyNoteCatalog = 'periodic/daily'; const currentDate = tp.date.now("DD-MM-YYYY"); const dailyNotePath = `${dailyNoteCatalog}/${currentDate}`; const dailyNotePathMd = `${dailyNotePath}.md`; let dailyNoteFile; // Проверяем, существует ли ежедневная заметка const dailyNoteExists = await tp.file.exists(dailyNotePathMd); if (dailyNoteExists) { // Если существует, получаем ее полный адрес dailyNoteFile = app.vault.getAbstractFileByPath(dailyNotePathMd); } else { // Если не существует, создаем ее с применением шаблона daily dailyNoteFile = await tp.file.create_new(tp.file.find_tfile("daily"), dailyNotePath); } // Получаем имя текущей заметки const currentNoteName = app.workspace.getActiveFile()?.basename; // Читаем содержимое ежедневной заметки const dailyNoteContent = await app.vault.read(dailyNoteFile); // Подготавливаем заголовок для добавления в ежедневную заметку const headingToAdd = `### [[${currentNoteName}]]`; // Проверяем, есть ли уже заголовок с именем текущей заметки if (!dailyNoteContent.includes(headingToAdd)) { // Если нет, то добавляем заголовок в конец файла await app.vault.append(dailyNoteFile, `\n${headingToAdd}\n`); } // Проверяем, открыта ли ежедневная заметка let leaf = app.workspace.getLeavesOfType('markdown').find( (leaf) => leaf.view.file && leaf.view.file.path === dailyNoteFile.path ); if (leaf) { // Если заметка уже открыта, переходим в нее app.workspace.setActiveLeaf(leaf); } else { // Если заметка не открыта, открываем ее в новой вкладке await app.workspace.getLeaf('tab').openFile(dailyNoteFile); } } catch (error) { console.error("Templater Error:", error); } %>
Пояснения к работе кода:
-
Создается ежедневная заметка с текущей датой.
-
В ежедневную заметку добавляется ссылку на текущую заметку (задачу). Это нужно для связи с задачей.
Этот блок опциональный. Его можно не добавлять, если функционал не нужен.
Блок dataviewjs шаблона task
task dataviewjs
try { // Получаем имя текущей заметки const currentNoteName = dv.current().file.name; // Получаем все ежедневные заметки в виде массива let pages = dv.pages('"periodic/daily"').array(); // Функция для извлечения даты из имени ежедневной заметки function datesFromDailyNotes(filename) { // Конвертируем строку формата "DD-MM-YYYY" в объект Date return moment(filename, 'DD-MM-YYYY').toDate(); } // Сортируем ежедневные заметки по дате pages.sort((a, b) => datesFromDailyNotes(a.file.name) - datesFromDailyNotes(b.file.name)); // Создаем массивы для оглавления и основного контента let tableOfContents = []; let mainContent = []; // Функция для подготовки заголовка в виде ссылки function escapeHeadingForLink(heading) { // Убираем из заголовка двойные квадратные скобки return heading.slice(2, -2); } // Проверяем, содержит ли заголовок имя текущей заметки function headingLinksToCurrentNote(heading, currentNoteName) { return heading.includes(currentNoteName); } // Проходим по каждой ежедневной заметке for (const page of pages) { // Получаем значение file.path заметки const file = app.vault.getAbstractFileByPath(page.file.path); // Получаем кэшированные метаданные файла const fileCache = app.metadataCache.getFileCache(file); // Проверяем, есть ли в полученном кэше заголовки if (fileCache?.headings) { // Если заголовки есть, то получаем их const headings = fileCache.headings; // Получаем содержимое ежедневной заметки const fileContent = await app.vault.cachedRead(file); // Проходим по каждому заголовку в ежедневной заметке for (let i = 0; i < headings.length; i++) { const heading = headings[i]; // Если заголовок в ежедененой заметке совпадает с именем текущуей заметки if (headingLinksToCurrentNote(heading.heading, currentNoteName)) { // Определяем начало секции с заголовком const startOffset = heading.position.start.offset; // По умолчанию конец секции - конец заметки let endOffset = fileContent.length; // Ищем конец текущей секции for (let j = i + 1; j < headings.length; j++) { // Если нашли заголовок третьего, второго или первого уровня, то считаем его началом следующей секции if (headings[j].level <= heading.level) { endOffset = headings[j].position.start.offset; break; } } // Извлекаем содержимое секции const sectionContent = fileContent.substring(startOffset, endOffset).trim(); // Удаляем первую строку (сам заголовок) из содержимого const contentWithoutHeading = sectionContent.split('\n').slice(1).join('\n').trim(); // Получаем дату из имени заметки const formattedDate = page.file.name; // Подготавливаем заголовок для вставки в ссылку const encodedHeading = escapeHeadingForLink(heading.heading); // Создаем ссылку, указывающую на секцию ежедневной заметки const dateLink = `[[${page.file.path}#${encodedHeading}|${formattedDate}]]`; // Добавляем содержимое секции в основной контент mainContent.push(`**${dateLink}**\n${contentWithoutHeading}`); // Добавляем ссылку на данную секцию в оглавление tableOfContents.push(dateLink); } } } } // Если список оглавления не пустой, выводим его if (tableOfContents.length > 0) { dv.header(3, "Оглавление"); dv.paragraph(tableOfContents.join(' -> ')); } // Если основной контент не пустой, выводим его if (mainContent.length > 0) { dv.header(3, "Заметки"); dv.paragraph(mainContent.join('\n\n')); } } catch (error) { console.error("Templater Error:", error); }
Тут мы получим текст всех ежедневных заметок, которые относятся к задаче.
Пояснения к работе кода:
-
Создается оглавление из ссылок на ежедневные заметки.
-
Добавляется заголовок в виде ссылки на ежедневную задачу и текст, относящийся к задаче.
Итоги
Эти шаблоны позволяют автоматизировать создание и управление проектами в Obsidian. Благодаря им, создание и организация проектов становятся более структурированными и эффективными. Благодарю всех за внимание.
ссылка на оригинал статьи https://habr.com/ru/articles/852246/
Добавить комментарий