Управление проектами и задачами в Obsidian
- вторник, 22 октября 2024 г. в 00:00:02
Используя Obsidian более двух лет, я привык организовывать в нём все свои заметки, в том числе и по проектам. Хоть Obsidian и предлагает широкий набор сторонних плагинов для расширения своего функционала, но мне так и не удалось найти идеальный для управления проектами и задачами. Это подтолкнуло меня к созданию нескольких автоматизаций, о которых и пойдет речь дальше.
Создаем заметку с типом проект.
Проект автоматически добавляется в заметку Homepage.
В проекте отображаются все его задачи с датами, статусами и ссылками.
Создаем заметку с типом задача.
Автоматически создается ежедневная заметка.
В задаче отображается все содержание из ежедневных заметок, относящееся к этой задаче.
Вносим записи в ежедневную заметку.
Это центральная заметка, где лежат ссылки на все существующие проекты. Заметка должна содержать заголовок третьего уровня (###), а проекты должны быть в виде списка:
В Homepage удобно хранить ссылки на центральные заметки хранилища Obsidian, которые агрегируют под собой заметки (ссылки на приложения, обучение, языки и прочее).
Создаем папку templates. Все шаблоны будут находиться в ней.
Создаем четыре шаблона: main, daily, project и task.
Чтобы установить плагины, нужно зайти в настройки Obsidian и в меню слева перейти в раздел Community plugin.
Шаблон main должен применяться ко всем вновь созданным заметкам. Это можно реализовать в настройках плагина templater. В разделе Folder templates в качестве директории нужно выбрать / (корень), а в качестве шаблона - main.md:
В Periodic Notes я использую только ежедневные заметки. Тут надо задать формат даты и папку для хранения ежедневных заметок:
А для удобства создания и управления ежедневными заметками уже используется плагин Calendar.
Доску я назвал Рабочие задачи. Я использовал следующие колонки: Backlog, To do, В работе, Тестирование, Done, Canceled и Повторяющиеся. Это важно, так как имя доски и статусы будут далее использоваться в коде автоматизаций.
Тут надо включить поддержку JS:
Ограничимся двумя файлами стилей: wide-page.css и table-styling.css. Оба нужны, чтобы сделать заметки более читаемыми и удобными в работе.
Растягивает страницу на всю ширину.
body .wide-page { --file-line-width: 100%; }
Добавляет в таблицу разделители. Помогает лучше воспринимать таблицу в заметках с проектами.
.table-divider table tr {
border-bottom: 1px solid #444;
}
Новые CSS добавляются в Obsidian в папку .obsidian\snippets (если папки нет, ее надо создать). Активировать стили надо в настройках:
Шаблоны для плагина Templater пишутся внутри двойных фигурных скобок с процентами: <<% шаблон %>>
. Но если мы хотим использовать в шаблоне JS код, то надо добавить звездочку: <<%* шаблон с JS кодом %>>
.
Для хранения метаданных в Obsidian заметках используется YAML заголовок (front matter), который находится в блоке из тройных дефисов до и после него:
---
метаданные
---
Для работы с JS кодом в плагине Dataview используется расширенная функция DataviewJS:
```dataviewjs
JS код
```
Обертка dataviewjs обязательна для корректной интерпретации кода, но в статье я не буду ее далее использовать, так она ломает подсветку в блоке кода. Понять, когда используется dataviewjs можно по заголовку.
Использование чужих шаблонов в Obsidian может быть опасным из-за рисков вредоносного кода, утечки данных. Проверяйте источник и код перед использованием.
При написании шаблонов я использовал:
Это центральный шаблон.
<%*
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).
В зависимости от выбранного типа заметки, к ней будут применен соответствующий шаблон. Дальнейшие действия зависят от шаблона.
Шаблон для ежедневных заметок.
<%*
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).
Шаблон для заметок с типом проект.
Шаблон состоит из двух блоков: properties и dataviewjs.
---
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 подставляется автоматически и равен имени заметки.
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 доски "Рабочие задачи".
Даты времени выполнения берутся из ежедневных заметок, в которых есть заголовок третьего уровня (###) и ссылка ([[ ]]) на текущую заметку (задачу).
Шаблон для заметок с типом задача.
Шаблон состоит из трех блоков: properties, дополнительных шаблон и dataviewjs.
---
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.
Появляется предложение ввести имя инстанса (это опционально).
<%*
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);
}
%>
Пояснения к работе кода:
Создается ежедневная заметка с текущей датой.
В ежедневную заметку добавляется ссылку на текущую заметку (задачу). Это нужно для связи с задачей.
Этот блок опциональный. Его можно не добавлять, если функционал не нужен.
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. Благодаря им, создание и организация проектов становятся более структурированными и эффективными. Благодарю всех за внимание.