commit 0352f53ed9b9b85d236eb365d5a27114644ec398 Author: Евгений Храмов Date: Wed Jan 14 12:56:29 2026 +0300 Initial commit: TODO Everywhere Logseq plugin - Синхронизация задач из PPDB - Синхронизация issues из Gitea - Автоматическая настройка config.edn - Slash-команды и кнопка в тулбаре - Конвертация приоритетов и статусов diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5706ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.DS_Store +*.log +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc8f8df --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# TODO Everywhere + +Logseq плагин для синхронизации задач из внешних источников. + +## Возможности + +- Синхронизация задач из PPDB (PortProton Database) +- Синхронизация issues из Gitea репозиториев +- Автоматическая настройка config.edn при установке +- Конвертация приоритетов и статусов в формат Logseq +- Периодическая синхронизация (опционально) + +## Установка + +1. Склонируйте репозиторий или скачайте релиз +2. Установите зависимости и соберите плагин: + ```bash + npm install + npm run build + ``` +3. В Logseq: `Settings → Plugins → Load unpacked plugin` +4. Выберите папку с плагином + +## Настройка + +### PPDB + +1. Получите API ключ на сервере PPDB +2. В настройках плагина включите "PPDB - Включить синхронизацию" +3. Укажите URL сервера и API ключ + +### Gitea + +1. Создайте токен доступа в Gitea (`Settings → Applications → Generate Token`) +2. В настройках плагина включите "Gitea - Включить синхронизацию" +3. Добавьте источники в формате JSON: + ```json + [ + { + "name": "MyProject", + "url": "https://gitea.example.com", + "token": "your-token-here", + "owner": "username", + "repo": "repository", + "enabled": true + } + ] + ``` + +## Использование + +### Slash-команды + +- `/sync-tasks` — синхронизировать все включенные источники +- `/sync-ppdb` — синхронизировать только PPDB +- `/sync-gitea` — синхронизировать только Gitea + +### Кнопка в тулбаре + +Нажмите на кнопку синхронизации в верхней панели Logseq. + +## Структура задач + +Плагин создаёт отдельные страницы для каждого источника: + +- `PPDB - TODO` — задачи из PPDB +- `Gitea - {name} - TODO` — issues из Gitea + +Задачи автоматически появляются в журнале в соответствующих секциях (PPDB, Gitea). + +## Конвертация статусов + +| Источник | Logseq | +|----------|--------| +| new | TODO | +| in_progress | DOING | +| completed | DONE | +| rejected | DONE | +| open (Gitea) | TODO | + +## Конвертация приоритетов + +| PPDB/Gitea | Logseq | +|------------|--------| +| critical | [#A] | +| high | [#A] | +| normal | [#B] | +| low | [#C] | + +## Разработка + +```bash +# Режим разработки с автопересборкой +npm run dev + +# Сборка для продакшена +npm run build +``` + +## Лицензия + +MIT diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..974766a --- /dev/null +++ b/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..9fd34e2 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "logseq-todo-everywhere", + "version": "1.0.0", + "description": "Sync tasks from external sources (PPDB, Gitea) to Logseq", + "main": "dist/index.js", + "scripts": { + "dev": "npx vite build --watch", + "build": "npx vite build" + }, + "keywords": [ + "logseq", + "plugin", + "todo", + "tasks", + "gitea", + "sync" + ], + "author": "xpamych", + "license": "MIT", + "logseq": { + "id": "logseq-todo-everywhere", + "title": "TODO Everywhere", + "icon": "./icon.svg" + }, + "devDependencies": { + "@logseq/libs": "^0.0.17", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "vite-plugin-logseq": "^1.1.2" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3610a5c --- /dev/null +++ b/src/index.ts @@ -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 { + 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: ` + + + + + + ` + }) + + 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 { + 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 { + 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 { + 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 = { + 'TODO': [], + 'DOING': [], + 'DONE': [] + } + + for (const task of tasks) { + const status = task.logseq_status + if (byStatus[status]) { + byStatus[status].push(task) + } + } + + // Сортируем по приоритету внутри каждого статуса + const priorityOrder: Record = { '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 = { '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) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1b0362c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..37e3a0f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import logseqPlugin from 'vite-plugin-logseq' + +export default defineConfig({ + plugins: [logseqPlugin()], + build: { + target: 'esnext', + minify: 'esbuild' + } +})