import '@logseq/libs' import { SettingSchemaDesc } from '@logseq/libs/dist/LSPlugin' // Интерфейсы interface TaskBlock { sourceId: string content: string properties: Record children?: string[] } 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 } // Маппинг статусов Logseq → PPDB // API принимает: TODO, LATER, NOW, DOING, IN-PROGRESS, DONE, COMPLETED, CANCELLED, CANCELED const LOGSEQ_TO_PPDB_STATUS: Record = { 'TODO': 'TODO', 'LATER': 'LATER', 'NOW': 'NOW', 'DOING': 'DOING', 'DONE': 'DONE', 'CANCELLED': 'CANCELLED', 'CANCELED': 'CANCELED' } // Кэш последних статусов блоков для предотвращения дублирования запросов const blockStatusCache = new Map() // Отправка статуса на PPDB async function sendStatusToPPDB(taskId: number, newStatus: string): Promise { const url = logseq.settings?.ppdbUrl as string const apiKey = logseq.settings?.ppdbApiKey as string if (!url || !apiKey) { console.warn('[TE] PPDB не настроен, пропускаем обновление статуса') return false } const ppdbStatus = LOGSEQ_TO_PPDB_STATUS[newStatus] if (!ppdbStatus) { console.warn('[TE] Неизвестный статус:', newStatus) return false } try { // Формат: PUT /api/logseq/tasks/{task_id}/status?api_key=XXX&status=YYY const apiUrl = `${url}/api/logseq/tasks/${taskId}/status?api_key=${encodeURIComponent(apiKey)}&status=${encodeURIComponent(ppdbStatus)}` const response = await fetch(apiUrl, { method: 'PUT' }) if (response.ok) { console.log(`[TE] Статус задачи ppdb-${taskId} обновлён на ${ppdbStatus}`) return true } else { const errorText = await response.text() console.error(`[TE] Ошибка обновления статуса: ${response.status}`, errorText) return false } } catch (error) { console.error('[TE] Ошибка отправки статуса на PPDB:', error) return false } } // Обработка изменения блока async function handleBlockChange(block: any) { console.log('[TE] handleBlockChange вызван, блок:', block.uuid, 'marker:', block.marker) // Проверяем что это блок с PPDB задачей // Свойства могут быть в разных форматах const props = block.properties || block.propertiesTextValues || {} const sourceId = props['source-id'] || props['sourceId'] || props['source_id'] if (!sourceId) { console.log('[TE] Блок без source-id, пропускаем') return } console.log('[TE] source-id найден:', sourceId) if (!sourceId.startsWith('ppdb-')) { console.log('[TE] Не PPDB задача, пропускаем') return } // Получаем ID задачи const taskIdMatch = sourceId.match(/^ppdb-(\d+)$/) if (!taskIdMatch) { console.log('[TE] Не удалось извлечь ID из:', sourceId) return } const taskId = parseInt(taskIdMatch[1]) const currentMarker = block.marker console.log('[TE] Задача ppdb-' + taskId + ', текущий статус:', currentMarker) // Проверяем изменился ли статус const cachedStatus = blockStatusCache.get(sourceId) if (cachedStatus === currentMarker) { console.log('[TE] Статус не изменился (кэш), пропускаем') return } console.log('[TE] Статус изменился:', cachedStatus, '->', currentMarker) // Обновляем кэш и отправляем на PPDB blockStatusCache.set(sourceId, currentMarker) if (logseq.settings?.ppdbSyncBack) { console.log('[TE] Отправляю статус на PPDB...') const success = await sendStatusToPPDB(taskId, currentMarker) if (success) { logseq.UI.showMsg(`Статус задачи #${taskId} обновлён`, 'success') } } else { console.log('[TE] ppdbSyncBack выключен') } } // Настройки плагина 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: 'ppdbSyncBack', type: 'boolean', default: true, title: 'PPDB - Синхронизировать статусы обратно', description: 'Отправлять изменения статуса задач из Logseq в 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 - Источники (внутреннее хранилище)', description: 'Данные хранятся автоматически. Не редактируйте вручную.' }, { key: 'giteaManage', type: 'enum', enumChoices: ['Открыть'], enumPicker: 'radio', default: '', title: 'Gitea - Управление источниками', description: 'Нажмите для открытия окна управления источниками Gitea' }, { key: 'syncOnStartup', type: 'boolean', default: true, title: 'Синхронизация при запуске', description: 'Автоматически синхронизировать при запуске Logseq' }, { key: 'syncInterval', type: 'number', default: 0, title: 'Интервал синхронизации (минуты)', description: '0 = только вручную' } ] // CSS стили для модального окна const modalStyles = ` .te-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; z-index: 999; } .te-modal { background: var(--ls-primary-background-color, #fff); border-radius: 8px; padding: 20px; min-width: 500px; max-width: 600px; max-height: 80vh; overflow-y: auto; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); } .te-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .te-modal h2 { margin: 0; color: var(--ls-primary-text-color, #333); font-size: 18px; } .te-modal h3 { margin: 16px 0 8px 0; color: var(--ls-primary-text-color, #333); font-size: 14px; } .te-source-list { margin-bottom: 16px; } .te-source-item { display: flex; align-items: center; justify-content: space-between; padding: 10px; margin: 8px 0; background: var(--ls-secondary-background-color, #f5f5f5); border-radius: 6px; border: 1px solid var(--ls-border-color, #ddd); } .te-source-item.disabled { opacity: 0.5; } .te-source-info { flex: 1; } .te-source-name { font-weight: 600; color: var(--ls-primary-text-color, #333); } .te-source-url { font-size: 12px; color: var(--ls-secondary-text-color, #666); } .te-source-actions { display: flex; gap: 8px; } .te-btn { padding: 6px 12px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; transition: background 0.2s; } .te-btn-primary { background: var(--ls-link-text-color, #0066cc); color: white; } .te-btn-primary:hover { opacity: 0.9; } .te-btn-secondary { background: var(--ls-secondary-background-color, #e0e0e0); color: var(--ls-primary-text-color, #333); } .te-btn-secondary:hover { background: var(--ls-tertiary-background-color, #d0d0d0); } .te-btn-danger { background: #dc3545; color: white; } .te-btn-danger:hover { background: #c82333; } .te-btn-small { padding: 4px 8px; font-size: 12px; } .te-form-group { margin-bottom: 12px; } .te-form-group label { display: block; margin-bottom: 4px; font-weight: 500; color: var(--ls-primary-text-color, #333); font-size: 13px; } .te-form-group input { width: 100%; padding: 8px 10px; border: 1px solid var(--ls-border-color, #ccc); border-radius: 4px; font-size: 13px; background: var(--ls-primary-background-color, #fff); color: var(--ls-primary-text-color, #333); box-sizing: border-box; } .te-form-group input:focus { outline: none; border-color: var(--ls-link-text-color, #0066cc); } .te-form-group .te-hint { font-size: 11px; color: var(--ls-secondary-text-color, #666); margin-top: 4px; } .te-form-group .te-hint a { color: var(--ls-link-text-color, #0066cc); text-decoration: none; } .te-form-group .te-hint a:hover { text-decoration: underline; } .te-checkbox-group { display: flex; align-items: center; gap: 8px; } .te-checkbox-group input[type="checkbox"] { width: auto; } .te-form-row { display: flex; gap: 12px; } .te-form-row .te-form-group { flex: 1; } .te-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--ls-border-color, #ddd); } .te-empty-state { text-align: center; padding: 20px; color: var(--ls-secondary-text-color, #666); } .te-divider { border-top: 1px solid var(--ls-border-color, #ddd); margin: 16px 0; } ` // Функция получения URL для токена function getTokenUrl(giteaUrl: string): string { try { const url = new URL(giteaUrl) return `${url.origin}/user/settings/applications` } catch { return '' } } // Глобальное состояние для редактирования let editingSourceIndex: number | null = null // Рендер модального окна function renderGiteaModal(sources: GiteaSource[], editIndex: number | null = null): string { const editingSource = editIndex !== null && editIndex >= 0 ? sources[editIndex] : null const isAdding = editIndex === -1 const showForm = isAdding || editingSource !== null const tokenHintUrl = showForm ? getTokenUrl(editingSource?.url || '') : '' let sourcesListHtml = '' if (sources.length === 0) { sourcesListHtml = '
Нет настроенных источников. Нажмите "Добавить" чтобы создать первый.
' } else { sourcesListHtml = sources.map((s, i) => `
${escapeHtml(s.name)}
${escapeHtml(s.url)} → ${escapeHtml(s.owner)}/${escapeHtml(s.repo)}
`).join('') } let formHtml = '' if (showForm) { const s = editingSource || { name: '', url: 'https://', owner: '', repo: '', token: '', enabled: true } formHtml = `

${isAdding ? 'Добавить источник' : 'Редактировать источник'}

${tokenHintUrl ? `
Токен можно получить здесь: ${tokenHintUrl}
` : '
Введите URL для получения ссылки на создание токена
'}
` } return `

Управление источниками Gitea

${sourcesListHtml}
${!showForm ? '' : ''} ${formHtml}
` } // Экранирование HTML function escapeHtml(text: string): string { const div = document.createElement('div') div.textContent = text return div.innerHTML } // Получить источники из настроек function getGiteaSources(): GiteaSource[] { try { return JSON.parse(logseq.settings?.giteaSources as string || '[]') } catch { return [] } } // Сохранить источники в настройки function saveGiteaSources(sources: GiteaSource[]) { logseq.updateSettings({ giteaSources: JSON.stringify(sources) }) } // Показать модальное окно function showGiteaModal() { editingSourceIndex = null const sources = getGiteaSources() logseq.provideUI({ key: 'gitea-modal', template: renderGiteaModal(sources, null), style: { zIndex: 999 } }) logseq.showMainUI() } // Обновить модальное окно function updateGiteaModal(editIndex: number | null = null) { const sources = getGiteaSources() logseq.provideUI({ key: 'gitea-modal', template: renderGiteaModal(sources, editIndex), style: { zIndex: 999 } }) } // Закрыть модальное окно function hideGiteaModal() { editingSourceIndex = null logseq.hideMainUI() } // Получить document плагина (может быть в iframe) function getPluginDocument(): Document { // В Logseq плагины работают в iframe, элементы UI в top.document try { const topDoc = top?.document if (topDoc && topDoc.getElementById('te-source-name')) { return topDoc } } catch (e) { // Игнорируем ошибки доступа } // Пробуем parent try { const parentDoc = parent?.document if (parentDoc && parentDoc.getElementById('te-source-name')) { return parentDoc } } catch (e) { // Игнорируем ошибки доступа } // Fallback на текущий document return document } // Сохранить источник из формы function saveSourceFromForm() { const doc = getPluginDocument() const nameEl = doc.getElementById('te-source-name') as HTMLInputElement const urlEl = doc.getElementById('te-source-url') as HTMLInputElement const ownerEl = doc.getElementById('te-source-owner') as HTMLInputElement const repoEl = doc.getElementById('te-source-repo') as HTMLInputElement const tokenEl = doc.getElementById('te-source-token') as HTMLInputElement const enabledEl = doc.getElementById('te-source-enabled') as HTMLInputElement if (!nameEl || !urlEl || !ownerEl || !repoEl || !tokenEl) { logseq.UI.showMsg('Ошибка: не найдены поля формы', 'error') return } const name = nameEl.value.trim() const url = urlEl.value.trim().replace(/\/$/, '') // убираем trailing slash const owner = ownerEl.value.trim() const repo = repoEl.value.trim() const token = tokenEl.value.trim() const enabled = enabledEl?.checked ?? true // Валидация if (!name) { logseq.UI.showMsg('Введите название источника', 'warning') return } if (!url || !url.startsWith('http')) { logseq.UI.showMsg('Введите корректный URL (начинается с http)', 'warning') return } if (!owner) { logseq.UI.showMsg('Введите владельца репозитория', 'warning') return } if (!repo) { logseq.UI.showMsg('Введите название репозитория', 'warning') return } if (!token) { logseq.UI.showMsg('Введите API токен', 'warning') return } const newSource: GiteaSource = { name, url, owner, repo, token, enabled } const sources = getGiteaSources() if (editingSourceIndex === -1) { // Добавление нового sources.push(newSource) } else if (editingSourceIndex !== null && editingSourceIndex >= 0) { // Редактирование существующего sources[editingSourceIndex] = newSource } saveGiteaSources(sources) editingSourceIndex = null updateGiteaModal(null) logseq.UI.showMsg('Источник сохранён', 'success') } // Главная функция async function main() { logseq.useSettingsSchema(settingsSchema) // Добавляем стили для модального окна logseq.provideStyle(modalStyles) // Регистрируем команды 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.Editor.registerSlashCommand('manage-gitea', () => { showGiteaModal() }) // Кнопка синхронизации в тулбаре logseq.App.registerUIItem('toolbar', { key: 'todo-everywhere-sync', template: ` ` }) // Модель для обработки кликов logseq.provideModel({ async syncAll() { await syncAllTasks() }, closeModal() { hideGiteaModal() }, addSource() { editingSourceIndex = -1 updateGiteaModal(-1) }, editSource(e: any) { const index = parseInt(e.dataset.index) editingSourceIndex = index updateGiteaModal(index) }, toggleSource(e: any) { const index = parseInt(e.dataset.index) const sources = getGiteaSources() if (sources[index]) { sources[index].enabled = !sources[index].enabled saveGiteaSources(sources) updateGiteaModal(editingSourceIndex) } }, deleteSource(e: any) { const index = parseInt(e.dataset.index) const sources = getGiteaSources() sources.splice(index, 1) saveGiteaSources(sources) editingSourceIndex = null updateGiteaModal(null) }, cancelEdit() { editingSourceIndex = null updateGiteaModal(null) }, saveSource() { saveSourceFromForm() } }) // Открытие модального окна при выборе настройки "Управление источниками" logseq.onSettingsChanged((newSettings, oldSettings) => { if (newSettings.giteaManage === 'Открыть' && oldSettings.giteaManage !== 'Открыть') { // Сбрасываем значение и открываем окно setTimeout(() => { logseq.updateSettings({ giteaManage: '' }) showGiteaModal() }, 100) } }) // Синхронизация при запуске if (logseq.settings?.syncOnStartup) { setTimeout(() => syncAllTasks(), 3000) } // Периодическая синхронизация const interval = logseq.settings?.syncInterval as number if (interval > 0) { setInterval(() => syncAllTasks(), interval * 60 * 1000) } // Отслеживание изменений блоков для обратной синхронизации logseq.DB.onChanged(async ({ blocks, txData, txMeta }) => { console.log('[TE] DB.onChanged: блоков изменено:', blocks.length) if (!logseq.settings?.ppdbSyncBack) { console.log('[TE] ppdbSyncBack выключен, пропускаем') return } if (!logseq.settings?.ppdbEnabled) { console.log('[TE] ppdbEnabled выключен, пропускаем') return } for (const block of blocks) { console.log('[TE] Обрабатываю блок:', block.uuid, 'marker:', block.marker, 'props:', JSON.stringify(block.properties || {})) // Проверяем что блок имеет маркер (TODO/DOING/DONE) if (block.marker) { await handleBlockChange(block) } } }) console.log('TODO Everywhere plugin loaded') } // Синхронизация всех источников async function syncAllTasks() { logseq.UI.showMsg('Синхронизация задач...', 'info') console.log('[TE] Начало синхронизации') console.log('[TE] Настройки:', { ppdbEnabled: logseq.settings?.ppdbEnabled, ppdbUrl: logseq.settings?.ppdbUrl, ppdbApiKey: logseq.settings?.ppdbApiKey ? '***' : '(пусто)', giteaEnabled: logseq.settings?.giteaEnabled, giteaSources: logseq.settings?.giteaSources }) try { const results: string[] = [] if (logseq.settings?.ppdbEnabled) { console.log('[TE] PPDB включен, синхронизирую...') const ppdbResult = await syncPPDBTasks() results.push(ppdbResult) } else { console.log('[TE] PPDB выключен') } if (logseq.settings?.giteaEnabled) { console.log('[TE] Gitea включен, синхронизирую...') const giteaResult = await syncGiteaTasks() results.push(giteaResult) } else { console.log('[TE] Gitea выключен') } if (results.length === 0) { logseq.UI.showMsg('Нет включенных источников', 'warning') } else { logseq.UI.showMsg(results.join('\n'), 'success') } } catch (error) { console.error('[TE] 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 blocks = generatePPDBBlocks(tasks) // Записываем инкрементально const stats = await writeToPageIncremental(pageName, blocks) return `PPDB: ${tasks.length} (+${stats.added} ~${stats.updated} -${stats.deleted})` } // Синхронизация 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 blocks = generateGiteaBlocks(issues, source) const stats = await writeToPageIncremental(pageName, blocks) results.push(`${source.name}: ${issues.length} (+${stats.added} ~${stats.updated})`) } 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 generatePPDBBlocks(tasks: PPDBTask[]): TaskBlock[] { const blocks: TaskBlock[] = [] // Группируем по статусам 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) ) } // Генерируем блоки задач 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} ` const content = `${task.logseq_status} ${priorityStr}${typeTag}${categoryTag}${task.title}` const properties: Record = { 'source-id': `ppdb-${task.id}`, 'author': task.author_username, 'created': task.created_at.split('T')[0] } if (task.url) { properties['url'] = task.url } if (task.assigned_to_username) { properties['assignee'] = task.assigned_to_username } const children: string[] = [] if (task.description) { const descLines = task.description.split('\n').filter(l => l.trim()) children.push(...descLines) } if (task.admin_comment) { children.push(`**Комментарий:** ${task.admin_comment}`) } blocks.push({ sourceId: `ppdb-${task.id}`, content, properties, children: children.length > 0 ? children : undefined }) } return blocks } // Генерация структурированных блоков для Gitea function generateGiteaBlocks(issues: GiteaIssue[], source: GiteaSource): TaskBlock[] { const blocks: TaskBlock[] = [] // Определяем приоритет по меткам 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 content = `TODO [#${priority}] ${typeTag}#${issue.number} ${issue.title}` if (labelTags) { content += ` ${labelTags}` } const properties: Record = { 'source-id': `gitea-${source.name}-${issue.number}`, 'url': issue.html_url, 'created': issue.created_at.split('T')[0] } if (issue.assignee) { properties['assignee'] = issue.assignee.login } if (issue.milestone) { properties['milestone'] = issue.milestone.title } const children: string[] = [] if (issue.body) { const bodyLines = issue.body.split('\n').filter(l => l.trim()).slice(0, 5) children.push(...bodyLines.map(l => l.substring(0, 200))) if (issue.body.split('\n').length > 5) { children.push('...') } } blocks.push({ sourceId: `gitea-${source.name}-${issue.number}`, content, properties, children: children.length > 0 ? children : undefined }) } return blocks } // Проверка валидности UUID function isValidUUID(str: string): boolean { // UUID формат: 8-4-4-4-12 hex символов return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(str) } // Инкрементальная запись блоков в страницу async function writeToPageIncremental(pageName: string, newBlocks: TaskBlock[]) { console.log('[TE] writeToPageIncremental:', pageName, 'новых блоков:', newBlocks.length) // Получаем или создаём страницу let page = await logseq.Editor.getPage(pageName) if (!page) { console.log('[TE] Создаю страницу:', pageName) page = await logseq.Editor.createPage(pageName, {}, { redirect: false }) } if (!page) { throw new Error(`Не удалось создать страницу ${pageName}`) } // Получаем существующие блоки страницы const existingBlocks = await logseq.Editor.getPageBlocksTree(pageName) || [] // Создаём map существующих блоков по source-id const existingMap = new Map }>() let skippedInvalidBlocks = 0 for (const block of existingBlocks) { // Пропускаем блоки с невалидным UUID (старый кэш Logseq) if (!isValidUUID(block.uuid)) { skippedInvalidBlocks++ continue } try { // Используем свойства из блока напрямую (getPageBlocksTree уже их возвращает) // Logseq конвертирует source-id в sourceId (camelCase) const props = block.properties || {} const sourceId = props['source-id'] || props['sourceId'] || props['source_id'] if (sourceId) { existingMap.set(sourceId, { uuid: block.uuid, content: block.content || '', properties: props }) console.log('[TE] Найден блок с source-id:', sourceId, 'uuid:', block.uuid) } } catch (err) { // Ошибка при обработке блока - пропускаем console.warn('[TE] Ошибка обработки блока, пропускаем:', block.uuid, err) } } if (skippedInvalidBlocks > 0) { console.log('[TE] Пропущено блоков с невалидным UUID (кэш):', skippedInvalidBlocks) } console.log('[TE] Существующих блоков с source-id:', existingMap.size) // Создаём set новых source-id const newSourceIds = new Set(newBlocks.map(b => b.sourceId)) // Статистика let added = 0, updated = 0, deleted = 0, unchanged = 0 // 1. Удаляем блоки, которых нет в новых данных for (const [sourceId, existing] of existingMap) { if (!newSourceIds.has(sourceId)) { try { await logseq.Editor.removeBlock(existing.uuid) deleted++ console.log('[TE] Удалён блок:', sourceId) } catch (err) { console.warn('[TE] Не удалось удалить блок:', existing.uuid, err) } } } // 2. Добавляем или обновляем блоки let lastBlockUuid: string | null = null for (const newBlock of newBlocks) { const existing = existingMap.get(newBlock.sourceId) if (existing) { // Проверяем нужно ли обновлять const contentChanged = normalizeContent(existing.content) !== normalizeContent(newBlock.content) const propsChanged = !propsEqual(existing.properties, newBlock.properties) if (contentChanged || propsChanged) { // Обновляем контент блока if (contentChanged) { await logseq.Editor.updateBlock(existing.uuid, newBlock.content) } // Обновляем свойства for (const [key, value] of Object.entries(newBlock.properties)) { if (existing.properties[key] !== value) { await logseq.Editor.upsertBlockProperty(existing.uuid, key, value) } } updated++ console.log('[TE] Обновлён блок:', newBlock.sourceId) } else { unchanged++ } lastBlockUuid = existing.uuid } else { // Добавляем новый блок let insertedBlock if (lastBlockUuid) { insertedBlock = await logseq.Editor.insertBlock(lastBlockUuid, newBlock.content, { sibling: true }) } else { insertedBlock = await logseq.Editor.insertBlock(page.uuid, newBlock.content, { sibling: false }) } if (insertedBlock) { // Устанавливаем свойства for (const [key, value] of Object.entries(newBlock.properties)) { await logseq.Editor.upsertBlockProperty(insertedBlock.uuid, key, value) } // Добавляем дочерние блоки если есть if (newBlock.children && newBlock.children.length > 0) { for (const child of newBlock.children) { await logseq.Editor.insertBlock(insertedBlock.uuid, child, { sibling: false }) } } lastBlockUuid = insertedBlock.uuid added++ console.log('[TE] Добавлен блок:', newBlock.sourceId) } } } console.log(`[TE] writeToPageIncremental завершён: +${added} ~${updated} -${deleted} =${unchanged}`) return { added, updated, deleted, unchanged } } // Нормализация контента для сравнения (убираем свойства из текста) function normalizeContent(content: string): string { // Убираем свойства из начала контента return content.replace(/^\w+-?\w*::[^\n]*\n?/gm, '').trim() } // Сравнение свойств function propsEqual(props1: Record, props2: Record): boolean { // Проверяем только свойства из props2 (новые) for (const key of Object.keys(props2)) { if (String(props1[key] || '') !== String(props2[key])) { return false } } return true } // Запуск плагина logseq.ready(main).catch(console.error)