javascript

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

  • вторник, 22 октября 2024 г. в 00:00:02
https://habr.com/ru/articles/852246/

Используя 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

Это центральный шаблон.

<%*
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

Шаблон для ежедневных заметок.

<%*
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: <%*
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

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

---
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

<%*
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

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. Благодаря им, создание и организация проектов становятся более структурированными и эффективными. Благодарю всех за внимание.