Newer
Older
TelosDB / src / frontend / components / docs-panel.js
import { callMcp, parseResultText, escapeHtml } from '../js/shared.js';

class DocsPanel extends HTMLElement {
  constructor() {
    super();
    this._initialized = false;
    this._editingDocumentId = null;
    this._docsEditorInstance = null;
    this._docsCurrentPage = 1;
  }

  connectedCallback() {
    if (this._initialized) return;
    this._initialized = true;
    this.className = 'panel panel-docs';
    this.id = 'panel-docs';
    this.innerHTML = `
      <h2 style="font-family:Outfit; margin-bottom:12px;">文書管理</h2>
      <div class="docs-toolbar">
        <button type="button" id="docs-add-btn" class="secondary-btn">新規登録</button>
        <button type="button" id="docs-refresh-btn" class="secondary-btn">一覧更新</button>
      </div>
      <div id="docs-list" class="docs-list">
        <div class="empty-state">一覧を読み込み中...</div>
      </div>
      <div id="docs-edit-modal" class="docs-edit-modal hidden">
        <div class="docs-edit-modal-backdrop"></div>
        <div class="docs-edit-modal-box">
          <div id="docs-form-area" class="docs-form-area">
            <fieldset class="settings-fieldset">
              <legend id="docs-form-legend">新規登録</legend>
              <div class="setting-row">
                <label for="docs-path">パス (path)</label>
                <input type="text" id="docs-path" placeholder="例: my-note.md" class="docs-input">
              </div>
              <div class="setting-row docs-import-row">
                <label>ファイル</label>
                <div class="docs-import-controls">
                  <input type="file" id="docs-file-input" accept=".txt,.md,.json,.csv,.html,.xml,.log,text/plain,text/markdown,text/html,application/json" aria-label="ファイルを選択">
                  <button type="button" id="docs-import-file-btn" class="secondary-btn">ファイルを取り込む</button>
                  <span id="docs-import-feedback" class="docs-import-feedback"></span>
                </div>
              </div>
              <div class="setting-row">
                <label for="docs-editor-container">本文</label>
                <div id="docs-editor-container" class="docs-editor-container"></div>
              </div>
              <div class="setting-actions">
                <button type="button" id="docs-save-btn" class="secondary-btn">保存</button>
                <button type="button" id="docs-cancel-btn" class="secondary-btn">キャンセル</button>
                <span id="docs-form-feedback" class="settings-feedback"></span>
              </div>
            </fieldset>
          </div>
        </div>
      </div>
    `;

    const DOCS_PAGE_SIZE = 20;
    const docsListEl = this.querySelector('#docs-list');
    const docsEditModal = this.querySelector('#docs-edit-modal');
    const docsFormArea = this.querySelector('#docs-form-area');
    const docsPathEl = this.querySelector('#docs-path');
    const docsEditorContainer = this.querySelector('#docs-editor-container');
    const docsFormLegend = this.querySelector('#docs-form-legend');
    const docsFormFeedback = this.querySelector('#docs-form-feedback');
    const docsFileInput = this.querySelector('#docs-file-input');
    const docsImportFileBtn = this.querySelector('#docs-import-file-btn');
    const docsImportFeedback = this.querySelector('#docs-import-feedback');

    const destroyDocsEditor = () => {
      if (this._docsEditorInstance) {
        try {
          this._docsEditorInstance.destroy();
        } catch (_) {}
        this._docsEditorInstance = null;
      }
    };

    const createDocsEditor = (initialValue = '') => {
      if (!docsEditorContainer) return;
      destroyDocsEditor.call(this);
      if (typeof toastui === 'undefined' || !toastui?.Editor) {
        docsEditorContainer.innerHTML = '<p class="docs-editor-fallback">Toast UI Editor を読み込んでください。npm run build-editor を実行し、vendor/toast-ui/ にバンドルを生成してください。</p>';
        return;
      }
      this._docsEditorInstance = new toastui.Editor({
        el: docsEditorContainer,
        initialValue: initialValue || '',
        height: '400px',
        initialEditType: 'wysiwyg',
        previewStyle: 'tab',
        usageStatistics: false,
        theme: 'dark',
      });
    };

    const getDocsEditorMarkdown = () => (this._docsEditorInstance ? this._docsEditorInstance.getMarkdown() : '');
    const setDocsEditorMarkdown = (markdown) => {
      if (this._docsEditorInstance) this._docsEditorInstance.setMarkdown(markdown || '');
    };

    const showDocsForm = (legend, path = '', content = '', documentId = null) => {
      this._editingDocumentId = documentId;
      if (docsFormLegend) docsFormLegend.textContent = legend;
      if (docsPathEl) docsPathEl.value = path;
      if (docsFormFeedback) {
        docsFormFeedback.textContent = '';
        docsFormFeedback.classList.remove('error');
      }
      if (docsEditModal) docsEditModal.classList.remove('hidden');
      createDocsEditor.call(this, content);
    };

    const hideDocsForm = () => {
      destroyDocsEditor.call(this);
      this._editingDocumentId = null;
      if (docsEditModal) docsEditModal.classList.add('hidden');
      this.loadDocsList();
    };

    const loadDocsList = async () => {
      if (!docsListEl) return;
      docsListEl.innerHTML = '<div class="empty-state">一覧を読み込み中...</div>';
      const timeoutMs = 15000;
      try {
        const result = await Promise.race([
          callMcp('list_documents', { limit: DOCS_PAGE_SIZE, page: this._docsCurrentPage }),
          new Promise((_, reject) => setTimeout(() => reject(new Error('タイムアウト')), timeoutMs)),
        ]);
        const raw = parseResultText(result);
        const list = Array.isArray(raw) ? raw : (raw?.items ?? []);
        const totalPages = Math.max(0, raw?.total_pages ?? 1);
        const totalCount = raw?.total_count ?? list.length;
        const currentPage = raw?.page ?? this._docsCurrentPage;
        this._docsCurrentPage = currentPage;

        if (!Array.isArray(list)) {
          docsListEl.innerHTML = '<div class="empty-state">一覧の取得に失敗しました</div>';
          return;
        }
        if (list.length === 0) {
          docsListEl.innerHTML = totalCount > 0
            ? '<div class="empty-state">このページには文書がありません</div>'
            : '<div class="empty-state">登録された文書はありません</div>';
          return;
        }

        const prevDisabled = currentPage <= 1;
        const nextDisabled = currentPage >= totalPages;
        const pagerHtml = totalPages > 1
          ? `<div class="docs-pager">
              <button type="button" class="docs-pager-btn" data-action="prev" ${prevDisabled ? 'disabled' : ''}>前へ</button>
              <span class="docs-pager-info">${currentPage} / ${totalPages} ページ(全 ${totalCount} 件)</span>
              <button type="button" class="docs-pager-btn" data-action="next" ${nextDisabled ? 'disabled' : ''}>次へ</button>
            </div>`
          : (totalCount > 0 ? `<p class="docs-page-info">全 ${totalCount} 件</p>` : '');

        docsListEl.innerHTML = pagerHtml + `
          <table class="docs-table">
            <thead>
              <tr><th>パス</th><th>MIME</th><th>チャンク数</th><th>先頭(chunk0)</th><th>操作</th></tr>
            </thead>
            <tbody>
              ${list.map((d) => `
                <tr>
                  <td class="docs-cell-path">${escapeHtml(d.path || '')}</td>
                  <td>${escapeHtml(d.mime || '')}</td>
                  <td>${Number(d.chunk_count) || 0}</td>
                  <td class="docs-cell-preview" title="${escapeHtml((d.chunk0_preview || '') + (d.chunk_count > 1 ? '…' : ''))}">${escapeHtml(d.chunk0_preview || '')}${Number(d.chunk_count) > 1 ? '…' : ''}</td>
                  <td class="docs-actions">
                    <button type="button" class="docs-btn docs-btn-edit" data-id="${escapeHtml(String(d.id))}">編集</button>
                    <button type="button" class="docs-btn docs-btn-delete" data-id="${escapeHtml(String(d.id))}">削除</button>
                  </td>
                </tr>
              `).join('')}
            </tbody>
          </table>
        `;

        docsListEl.querySelectorAll('[data-action="prev"]').forEach((btn) => {
          btn.addEventListener('click', () => {
            if (this._docsCurrentPage > 1) {
              this._docsCurrentPage--;
              loadDocsList();
            }
          });
        });
        docsListEl.querySelectorAll('[data-action="next"]').forEach((btn) => {
          btn.addEventListener('click', () => {
            if (this._docsCurrentPage < totalPages) {
              this._docsCurrentPage++;
              loadDocsList();
            }
          });
        });
        docsListEl.querySelectorAll('.docs-btn-edit').forEach((btn) => {
          btn.addEventListener('click', () => openDocEdit(btn.dataset.id));
        });
        docsListEl.querySelectorAll('.docs-btn-delete').forEach((btn) => {
          btn.addEventListener('click', () => deleteDoc(btn.dataset.id));
        });
      } catch (e) {
        docsListEl.innerHTML = `<div class="empty-state error-state">エラー: ${escapeHtml(e.message)}</div>`;
      }
    };

    const openDocEdit = async (id) => {
      const docId = typeof id === 'string' ? parseInt(id, 10) : id;
      if (!Number.isFinite(docId)) {
        if (docsFormFeedback) docsFormFeedback.textContent = '無効な文書IDです';
        return;
      }
      showDocsForm('編集', '', '読み込み中...', docId);
      try {
        const result = await callMcp('get_document', { document_id: docId });
        const doc = parseResultText(result);
        if (doc && typeof doc === 'object') {
          if (docsPathEl) docsPathEl.value = doc.path || '';
          setDocsEditorMarkdown(doc.content != null ? String(doc.content) : '');
          if (docsFormFeedback) {
            docsFormFeedback.textContent = '';
            docsFormFeedback.classList.remove('error');
          }
        } else {
          if (docsFormFeedback) docsFormFeedback.textContent = '文書の取得に失敗しました';
        }
      } catch (e) {
        if (docsFormFeedback) {
          docsFormFeedback.textContent = 'エラー: ' + e.message;
          docsFormFeedback.classList.add('error');
        }
      }
    };

    const deleteDoc = async (id) => {
      const docId = typeof id === 'string' ? parseInt(id, 10) : id;
      if (!Number.isFinite(docId)) {
        alert('無効な文書IDです');
        return;
      }
      if (!confirm('この文書を削除しますか?')) return;
      try {
        await callMcp('delete_document', { document_id: docId });
        loadDocsList();
        if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount();
      } catch (e) {
        alert('削除に失敗しました: ' + e.message);
      }
    };

    this.loadDocsList = loadDocsList;

    this.querySelector('#docs-add-btn')?.addEventListener('click', () => showDocsForm('新規登録', '', ''));
    this.querySelector('#docs-refresh-btn')?.addEventListener('click', () => loadDocsList());
    this.querySelector('#docs-save-btn')?.addEventListener('click', async () => {
      const path = docsPathEl?.value?.trim();
      const content = getDocsEditorMarkdown();
      if (!path) {
        if (docsFormFeedback) {
          docsFormFeedback.textContent = 'パスを入力してください';
          docsFormFeedback.classList.add('error');
        }
        return;
      }
      if (docsFormFeedback) {
        docsFormFeedback.textContent = '保存中...';
        docsFormFeedback.classList.remove('error');
      }
      try {
        if (this._editingDocumentId) {
          await callMcp('delete_document', { document_id: this._editingDocumentId });
        }
        await callMcp('add_item_text', { path, content });
        if (docsFormFeedback) {
          docsFormFeedback.textContent = '保存しました';
          docsFormFeedback.classList.remove('error');
        }
        setTimeout(() => hideDocsForm(), 600);
        if (typeof window !== 'undefined' && window.updateDocCount) window.updateDocCount();
      } catch (e) {
        if (docsFormFeedback) {
          docsFormFeedback.textContent = 'エラー: ' + e.message;
          docsFormFeedback.classList.add('error');
        }
      }
    });
    this.querySelector('#docs-cancel-btn')?.addEventListener('click', hideDocsForm);
    docsFileInput?.addEventListener('change', () => {
      const file = docsFileInput.files?.[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = () => {
        const text = typeof reader.result === 'string' ? reader.result : '';
        if (!docsPathEl?.value?.trim()) docsPathEl.value = file.name;
        setDocsEditorMarkdown(text);
        if (docsImportFeedback) {
          docsImportFeedback.textContent = `「${escapeHtml(file.name)}」を読み込みました`;
          docsImportFeedback.classList.remove('error');
          setTimeout(() => { docsImportFeedback.textContent = ''; }, 3000);
        }
        docsFileInput.value = '';
      };
      reader.onerror = () => {
        if (docsImportFeedback) {
          docsImportFeedback.textContent = 'ファイルの読み込みに失敗しました';
          docsImportFeedback.classList.add('error');
        }
        docsFileInput.value = '';
      };
      reader.readAsText(file, 'UTF-8');
    });
    docsImportFileBtn?.addEventListener('click', () => docsFileInput?.click());

    const backdrop = this.querySelector('.docs-edit-modal-backdrop');
    backdrop?.addEventListener('click', hideDocsForm);
  }
}

customElements.define('docs-panel', DocsPanel);
export {};