Управление проектами и задачами в Obsidian

от автора

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

Алгоритм работы с проектом

  1. Создаем заметку с типом проект.

    • Проект автоматически добавляется в заметку Homepage.

    • В проекте отображаются все его задачи с датами, статусами и ссылками.

  2. Создаем заметку с типом задача.

    • Автоматически создается ежедневная заметка.

    • В задаче отображается все содержание из ежедневных заметок, относящееся к этой задаче.

  3. Вносим записи в ежедневную заметку.

Требования к окружению Obsidian

Заметка Homepage

Это центральная заметка, где лежат ссылки на все существующие проекты. Заметка должна содержать заголовок третьего уровня (###), а проекты должны быть в виде списка:

заметка Homepage

заметка Homepage

В Homepage удобно хранить ссылки на центральные заметки хранилища Obsidian, которые агрегируют под собой заметки (ссылки на приложения, обучение, языки и прочее).

Шаблоны

  1. Создаем папку templates. Все шаблоны будут находиться в ней.

  2. Создаем четыре шаблона: 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *