Initial commit: TODO Everywhere Logseq plugin

- Синхронизация задач из PPDB
- Синхронизация issues из Gitea
- Автоматическая настройка config.edn
- Slash-команды и кнопка в тулбаре
- Конвертация приоритетов и статусов
This commit is contained in:
2026-01-14 12:56:29 +03:00
commit 0352f53ed9
7 changed files with 824 additions and 0 deletions

650
src/index.ts Normal file
View File

@@ -0,0 +1,650 @@
import '@logseq/libs'
import { SettingSchemaDesc } from '@logseq/libs/dist/LSPlugin'
// Шаблоны для config.edn
const PPDB_QUERY = `{:title "PPDB"
:query [:find (pull ?h [:db/id :block/uuid :block/content :block/marker :block/priority :block/created-at :block/updated-at {:block/page [:block/journal-day]}])
:where
[?p :block/original-name "PPDB - TODO"]
[?h :block/marker ?marker]
[(contains? #{"NOW" "DOING" "TODO"} ?marker)]
[?h :block/page ?p]
[?p :block/journal? false]]
:result-transform (fn [result]
(sort-by (fn [h]
[(get h :block/priority "Z")
(or (get h :block/created-at) 999999999999999)]) result))
:collapsed? true}`
const GITEA_QUERY = `{:title "Gitea"
:query [:find (pull ?h [:db/id :block/uuid :block/content :block/marker :block/priority :block/created-at :block/updated-at {:block/page [:block/journal-day :block/original-name]}])
:where
[?p :block/original-name ?name]
[(clojure.string/starts-with? ?name "Gitea -")]
[?h :block/marker ?marker]
[(contains? #{"NOW" "DOING" "TODO"} ?marker)]
[?h :block/page ?p]
[?p :block/journal? false]]
:result-transform (fn [result]
(sort-by (fn [h]
[(get h :block/priority "Z")
(or (get h :block/created-at) 999999999999999)]) result))
:collapsed? true}`
// Интерфейсы
interface PPDBTask {
id: number
title: string
description: string | null
url: string | null
category: string
priority: string
task_type: string | null
status: string
created_at: string
updated_at: string | null
completed_at: string | null
admin_comment: string | null
author_username: string
assigned_to_username: string | null
logseq_priority: string
logseq_status: string
}
interface GiteaIssue {
id: number
number: number
title: string
body: string
state: string
labels: { name: string; color: string }[]
milestone: { title: string } | null
assignee: { login: string } | null
created_at: string
updated_at: string
html_url: string
repository: {
full_name: string
}
}
interface GiteaSource {
name: string
url: string
token: string
owner: string
repo: string
enabled: boolean
}
// Настройки плагина
const settingsSchema: SettingSchemaDesc[] = [
{
key: 'ppdbEnabled',
type: 'boolean',
default: false,
title: 'PPDB - Включить синхронизацию',
description: 'Синхронизировать задачи из PPDB'
},
{
key: 'ppdbUrl',
type: 'string',
default: 'https://ppdb.linux-gaming.ru',
title: 'PPDB - URL сервера',
description: 'Базовый URL PPDB API'
},
{
key: 'ppdbApiKey',
type: 'string',
default: '',
title: 'PPDB - API ключ',
description: 'Ключ для доступа к API'
},
{
key: 'ppdbPageName',
type: 'string',
default: 'PPDB - TODO',
title: 'PPDB - Имя страницы',
description: 'Название страницы для задач PPDB'
},
{
key: 'ppdbCategories',
type: 'string',
default: '',
title: 'PPDB - Категории (через запятую)',
description: 'Фильтр по категориям: ppdb, linux-gaming, portproton, portprotonqt. Пусто = все'
},
{
key: 'giteaEnabled',
type: 'boolean',
default: false,
title: 'Gitea - Включить синхронизацию',
description: 'Синхронизировать issues из Gitea'
},
{
key: 'giteaSources',
type: 'string',
default: '[]',
title: 'Gitea - Источники (JSON)',
description: 'JSON массив источников: [{"name":"MyRepo","url":"https://gitea.example.com","token":"xxx","owner":"user","repo":"repo","enabled":true}]'
},
{
key: 'syncOnStartup',
type: 'boolean',
default: true,
title: 'Синхронизация при запуске',
description: 'Автоматически синхронизировать при запуске Logseq'
},
{
key: 'syncInterval',
type: 'number',
default: 0,
title: 'Интервал синхронизации (минуты)',
description: '0 = только вручную'
},
{
key: 'configVersion',
type: 'number',
default: 0,
title: 'Версия конфигурации (не изменять)',
description: 'Внутренняя переменная для отслеживания настройки config.edn'
}
]
const CURRENT_CONFIG_VERSION = 1
// Настройка config.edn
async function setupConfig(): Promise<boolean> {
try {
const graph = await logseq.App.getCurrentGraph()
if (!graph?.path) {
console.error('Cannot get graph path')
return false
}
const configPath = `${graph.path}/logseq/config.edn`
// Читаем текущий конфиг
const configContent = await (window as any).logseq.FileStorage.getItem(configPath)
if (!configContent) {
console.error('Cannot read config.edn')
return false
}
let modified = false
let newContent = configContent
// Проверяем и добавляем PPDB query
if (!configContent.includes(':title "PPDB"')) {
const insertPos = findQueryInsertPosition(newContent)
if (insertPos > 0) {
newContent = newContent.slice(0, insertPos) + '\n ' + PPDB_QUERY + newContent.slice(insertPos)
modified = true
}
}
// Проверяем и добавляем Gitea query
if (!newContent.includes(':title "Gitea"')) {
const insertPos = findQueryInsertPosition(newContent)
if (insertPos > 0) {
newContent = newContent.slice(0, insertPos) + '\n ' + GITEA_QUERY + newContent.slice(insertPos)
modified = true
}
}
// Обновляем исключения в существующих queries
// Добавляем PPDB - TODO в исключения
if (!newContent.includes('"PPDB - TODO"')) {
// Ищем паттерн (not [(contains? #{"...", "..."} ?name)]) и добавляем PPDB - TODO
newContent = newContent.replace(
/\(not \[\(contains\? #\{([^}]*)"РЕД СОФТ - TODO"([^}]*)\} \?name\)\]\)/g,
'(not [(contains? #{$1"РЕД СОФТ - TODO" "PPDB - TODO"$2} ?name)])'
)
modified = true
}
// Добавляем исключение для Gitea страниц
if (!newContent.includes('clojure.string/starts-with? ?name "Gitea -"')) {
// После (not [(contains? ... добавляем проверку на Gitea
newContent = newContent.replace(
/(\(not \[\(contains\? #\{[^}]*"PPDB - TODO"[^}]*\} \?name\)\]\))(\s*)(\[?\?h :block\/marker)/g,
'$1\n (not [(clojure.string/starts-with? ?name "Gitea -")])$2$3'
)
modified = true
}
// Добавляем PPDB - TODO в favorites
if (!newContent.includes('"PPDB - TODO"')) {
newContent = newContent.replace(
/:favorites \[([^\]]*)"РЕД СОФТ - TODO"([^\]]*)\]/,
':favorites [$1"РЕД СОФТ - TODO" "PPDB - TODO"$2]'
)
modified = true
}
if (modified) {
await (window as any).logseq.FileStorage.setItem(configPath, newContent)
console.log('config.edn updated successfully')
return true
}
return false
} catch (error) {
console.error('Error setting up config:', error)
return false
}
}
// Находит позицию для вставки нового query (перед последним query в :journals)
function findQueryInsertPosition(content: string): number {
// Ищем паттерн {:title "Остальные дела" или последний query перед закрывающим ]}
const patterns = [
/:title "Остальные дела"/,
/:title "Остальные"/,
]
for (const pattern of patterns) {
const match = content.match(pattern)
if (match && match.index) {
// Возвращаем позицию перед найденным query
// Ищем начало блока { перед :title
let pos = match.index
while (pos > 0 && content[pos] !== '{') {
pos--
}
return pos
}
}
// Если не нашли, ищем конец journals array
const journalsEnd = content.search(/\]\s*\}\s*;;\s*Add custom commands/)
if (journalsEnd > 0) {
return journalsEnd
}
return -1
}
// Главная функция
async function main() {
logseq.useSettingsSchema(settingsSchema)
// Проверяем нужно ли настроить config.edn
const configVersion = logseq.settings?.configVersion as number || 0
if (configVersion < CURRENT_CONFIG_VERSION) {
logseq.UI.showMsg('TODO Everywhere: настройка config.edn...', 'info')
const success = await setupConfig()
if (success) {
logseq.updateSettings({ configVersion: CURRENT_CONFIG_VERSION })
logseq.UI.showMsg('config.edn обновлён. Перезапустите Logseq для применения изменений.', 'success')
}
}
// Регистрируем команды
logseq.Editor.registerSlashCommand('sync-tasks', async () => {
await syncAllTasks()
})
logseq.Editor.registerSlashCommand('sync-ppdb', async () => {
await syncPPDBTasks()
})
logseq.Editor.registerSlashCommand('sync-gitea', async () => {
await syncGiteaTasks()
})
// Кнопка в тулбаре
logseq.App.registerUIItem('toolbar', {
key: 'todo-everywhere-sync',
template: `
<a class="button" data-on-click="syncAll" title="Sync External Tasks">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
</svg>
</a>
`
})
logseq.provideModel({
async syncAll() {
await syncAllTasks()
}
})
// Синхронизация при запуске
if (logseq.settings?.syncOnStartup) {
setTimeout(() => syncAllTasks(), 3000)
}
// Периодическая синхронизация
const interval = logseq.settings?.syncInterval as number
if (interval > 0) {
setInterval(() => syncAllTasks(), interval * 60 * 1000)
}
console.log('TODO Everywhere plugin loaded')
}
// Синхронизация всех источников
async function syncAllTasks() {
logseq.UI.showMsg('Синхронизация задач...', 'info')
try {
const results: string[] = []
if (logseq.settings?.ppdbEnabled) {
const ppdbResult = await syncPPDBTasks()
results.push(ppdbResult)
}
if (logseq.settings?.giteaEnabled) {
const giteaResult = await syncGiteaTasks()
results.push(giteaResult)
}
if (results.length === 0) {
logseq.UI.showMsg('Нет включенных источников', 'warning')
} else {
logseq.UI.showMsg(results.join('\n'), 'success')
}
} catch (error) {
console.error('Sync error:', error)
logseq.UI.showMsg(`Ошибка синхронизации: ${error}`, 'error')
}
}
// Синхронизация PPDB
async function syncPPDBTasks(): Promise<string> {
const url = logseq.settings?.ppdbUrl as string
const apiKey = logseq.settings?.ppdbApiKey as string
const pageName = logseq.settings?.ppdbPageName as string || 'PPDB - TODO'
const categories = (logseq.settings?.ppdbCategories as string || '').split(',').map(s => s.trim()).filter(Boolean)
if (!url || !apiKey) {
throw new Error('PPDB: не настроены URL или API ключ')
}
// Получаем задачи
let apiUrl = `${url}/api/logseq/tasks?api_key=${apiKey}`
if (categories.length === 1) {
apiUrl += `&category=${categories[0]}`
}
const response = await fetch(apiUrl)
if (!response.ok) {
throw new Error(`PPDB API error: ${response.status}`)
}
const data = await response.json()
let tasks: PPDBTask[] = data.tasks
// Фильтруем по категориям если указано несколько
if (categories.length > 1) {
tasks = tasks.filter(t => categories.includes(t.category))
}
// Генерируем контент страницы
const content = generatePPDBPageContent(tasks)
// Записываем в страницу
await writeToPage(pageName, content)
return `PPDB: ${tasks.length} задач`
}
// Синхронизация Gitea
async function syncGiteaTasks(): Promise<string> {
const sourcesJson = logseq.settings?.giteaSources as string || '[]'
let sources: GiteaSource[]
try {
sources = JSON.parse(sourcesJson)
} catch {
throw new Error('Gitea: неверный формат JSON источников')
}
const enabledSources = sources.filter(s => s.enabled)
if (enabledSources.length === 0) {
return 'Gitea: нет активных источников'
}
const results: string[] = []
for (const source of enabledSources) {
try {
const issues = await fetchGiteaIssues(source)
const pageName = `Gitea - ${source.name} - TODO`
const content = generateGiteaPageContent(issues, source)
await writeToPage(pageName, content)
results.push(`${source.name}: ${issues.length}`)
} catch (error) {
console.error(`Gitea ${source.name} error:`, error)
results.push(`${source.name}: ошибка`)
}
}
return `Gitea: ${results.join(', ')}`
}
// Получение issues из Gitea
async function fetchGiteaIssues(source: GiteaSource): Promise<GiteaIssue[]> {
const url = `${source.url}/api/v1/repos/${source.owner}/${source.repo}/issues?state=open&type=issues`
const response = await fetch(url, {
headers: {
'Authorization': `token ${source.token}`,
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Gitea API error: ${response.status}`)
}
return await response.json()
}
// Генерация контента для PPDB страницы
function generatePPDBPageContent(tasks: PPDBTask[]): string {
const lines: string[] = []
lines.push(`source:: PPDB`)
lines.push(`synced:: ${new Date().toISOString()}`)
lines.push('')
// Группируем по статусам
const byStatus: Record<string, PPDBTask[]> = {
'TODO': [],
'DOING': [],
'DONE': []
}
for (const task of tasks) {
const status = task.logseq_status
if (byStatus[status]) {
byStatus[status].push(task)
}
}
// Сортируем по приоритету внутри каждого статуса
const priorityOrder: Record<string, number> = { 'A': 0, 'B': 1, 'C': 2 }
for (const status of Object.keys(byStatus)) {
byStatus[status].sort((a, b) =>
(priorityOrder[a.logseq_priority] || 1) - (priorityOrder[b.logseq_priority] || 1)
)
}
// Генерируем TODO блоки
for (const task of [...byStatus['DOING'], ...byStatus['TODO'], ...byStatus['DONE']]) {
const priorityStr = task.logseq_priority ? `[#${task.logseq_priority}] ` : ''
const typeTag = task.task_type ? `#${task.task_type} ` : ''
const categoryTag = `#${task.category} `
let line = `- ${task.logseq_status} ${priorityStr}${typeTag}${categoryTag}${task.title}`
// Добавляем свойства
lines.push(line)
lines.push(` id:: ppdb-${task.id}`)
if (task.url) {
lines.push(` url:: ${task.url}`)
}
if (task.description) {
// Описание как вложенный блок
const descLines = task.description.split('\n').filter(l => l.trim())
for (const descLine of descLines) {
lines.push(` - ${descLine}`)
}
}
if (task.admin_comment) {
lines.push(` - **Комментарий:** ${task.admin_comment}`)
}
if (task.assigned_to_username) {
lines.push(` assignee:: ${task.assigned_to_username}`)
}
lines.push(` author:: ${task.author_username}`)
lines.push(` created:: ${task.created_at.split('T')[0]}`)
}
return lines.join('\n')
}
// Генерация контента для Gitea страницы
function generateGiteaPageContent(issues: GiteaIssue[], source: GiteaSource): string {
const lines: string[] = []
lines.push(`source:: Gitea`)
lines.push(`repo:: ${source.owner}/${source.repo}`)
lines.push(`synced:: ${new Date().toISOString()}`)
lines.push('')
// Определяем приоритет по меткам
const getPriority = (issue: GiteaIssue): string => {
const labels = issue.labels.map(l => l.name.toLowerCase())
if (labels.some(l => l.includes('critical') || l.includes('urgent') || l.includes('p0'))) return 'A'
if (labels.some(l => l.includes('high') || l.includes('important') || l.includes('p1'))) return 'A'
if (labels.some(l => l.includes('low') || l.includes('minor') || l.includes('p3'))) return 'C'
return 'B'
}
// Определяем тип по меткам
const getType = (issue: GiteaIssue): string => {
const labels = issue.labels.map(l => l.name.toLowerCase())
if (labels.some(l => l.includes('bug') || l.includes('fix'))) return 'bug'
if (labels.some(l => l.includes('feature') || l.includes('enhancement'))) return 'feature'
return ''
}
// Сортируем по приоритету
const priorityOrder: Record<string, number> = { 'A': 0, 'B': 1, 'C': 2 }
issues.sort((a, b) =>
(priorityOrder[getPriority(a)] || 1) - (priorityOrder[getPriority(b)] || 1)
)
for (const issue of issues) {
const priority = getPriority(issue)
const type = getType(issue)
const typeTag = type ? `#${type} ` : ''
const labelTags = issue.labels.map(l => `#${l.name.replace(/\s+/g, '-')}`).join(' ')
let line = `- TODO [#${priority}] ${typeTag}#${issue.number} ${issue.title}`
if (labelTags) {
line += ` ${labelTags}`
}
lines.push(line)
lines.push(` id:: gitea-${source.name}-${issue.number}`)
lines.push(` url:: ${issue.html_url}`)
if (issue.assignee) {
lines.push(` assignee:: ${issue.assignee.login}`)
}
if (issue.milestone) {
lines.push(` milestone:: ${issue.milestone.title}`)
}
lines.push(` created:: ${issue.created_at.split('T')[0]}`)
// Тело issue как вложенный блок
if (issue.body) {
const bodyLines = issue.body.split('\n').filter(l => l.trim()).slice(0, 5)
for (const bodyLine of bodyLines) {
lines.push(` - ${bodyLine.substring(0, 200)}`)
}
if (issue.body.split('\n').length > 5) {
lines.push(` - ...`)
}
}
}
return lines.join('\n')
}
// Запись контента в страницу
async function writeToPage(pageName: string, content: string) {
// Получаем или создаём страницу
let page = await logseq.Editor.getPage(pageName)
if (!page) {
page = await logseq.Editor.createPage(pageName, {}, { redirect: false })
}
if (!page) {
throw new Error(`Не удалось создать страницу ${pageName}`)
}
// Получаем блоки страницы
const blocks = await logseq.Editor.getPageBlocksTree(pageName)
// Удаляем все существующие блоки
for (const block of blocks) {
await logseq.Editor.removeBlock(block.uuid)
}
// Вставляем новый контент
const contentLines = content.split('\n')
// Первый блок
if (contentLines.length > 0) {
const firstBlock = await logseq.Editor.insertBlock(page.uuid, contentLines[0], { sibling: false })
if (firstBlock) {
// Остальные блоки добавляем как siblings
let prevBlockUuid = firstBlock.uuid
for (let i = 1; i < contentLines.length; i++) {
const line = contentLines[i]
// Пропускаем пустые строки
if (!line.trim()) continue
// Определяем уровень вложенности
const indent = line.search(/\S/)
if (indent <= 0 || line.startsWith('-')) {
// Верхний уровень
const newBlock = await logseq.Editor.insertBlock(prevBlockUuid, line.replace(/^-\s*/, ''), { sibling: true })
if (newBlock) {
prevBlockUuid = newBlock.uuid
}
} else {
// Вложенный блок (свойства или контент)
const cleanLine = line.trim()
if (cleanLine.includes('::')) {
// Это свойство - добавляем к предыдущему блоку
await logseq.Editor.upsertBlockProperty(prevBlockUuid, cleanLine.split('::')[0].trim(), cleanLine.split('::')[1].trim())
} else if (cleanLine.startsWith('-')) {
// Вложенный контент
await logseq.Editor.insertBlock(prevBlockUuid, cleanLine.replace(/^-\s*/, ''), { sibling: false })
}
}
}
}
}
}
// Запуск плагина
logseq.ready(main).catch(console.error)