Newer
Older
TelosDB / src / frontend / components / settings-panel.js
import {
  loadSettingsFromFile,
  saveSettingsToFile,
  escapeHtml,
  DEFAULTS,
  SETTINGS_KEY,
} from '../js/shared.js';

/** 標準フォルダのカテゴリ名と既定説明(バックエンドと一致) */
const STANDARD_FOLDER_CATEGORIES = [
  ['汎用ルール', 'エディタや AI エージェント用のルールを格納します。'],
  ['汎用スキル', 'AI エージェント用のスキル(SKILL.md など)を格納します。'],
  ['汎用ツール', 'MCP ツール定義やスクリプト(ソースコード)を格納します。監視対象の拡張子は設定(watch_extensions)で指定し、**MCP からも変更可能**にします。'],
  ['汎用ナレッジ', '調べものや参照用のナレッジを格納します。'],
];
const STANDARD_CATEGORY_NAMES = new Set(STANDARD_FOLDER_CATEGORIES.map(([name]) => name));

class SettingsPanel extends HTMLElement {
  constructor() {
    super();
    this._initialized = false;
    this._monitorPaths = [];
    this._removeFromIndexPaths = new Set();
    this._editingMonitorPathIndex = null;
  }

  connectedCallback() {
    if (this._initialized) return;
    this._initialized = true;
    this.className = 'panel panel-settings';
    this.id = 'panel-settings';
    this.innerHTML = `
      <h2 style="font-family:Outfit; margin-bottom:12px;">設定</h2>
      <div class="settings-form">
        <fieldset class="settings-fieldset">
          <legend>検索</legend>
          <div class="setting-row">
            <label for="setting-min-score">スコア足切り (0〜1)</label>
            <input type="number" id="setting-min-score" min="0" max="1" step="0.05" value="0.3" title="この値未満の類似度の結果は表示しません">
          </div>
          <div class="setting-row">
            <label for="setting-limit">取得件数</label>
            <input type="number" id="setting-limit" min="1" max="100" value="5" title="検索で返す最大件数">
          </div>
        </fieldset>
        <fieldset class="settings-fieldset">
          <legend>ログイン時自動起動</legend>
          <div class="setting-row setting-row-toggle">
            <label for="setting-run-on-login">有効にする</label>
            <label class="setting-toggle-wrap" aria-hidden="true">
              <input type="checkbox" id="setting-run-on-login" class="setting-toggle" role="switch" aria-describedby="setting-run-on-login-desc">
              <span class="setting-toggle-slider"></span>
            </label>
            <span id="setting-run-on-login-desc" class="setting-hint">OS にサインインしたときに TelosDB を自動で起動します</span>
          </div>
        </fieldset>
        <fieldset class="settings-fieldset" id="fieldset-standard-folders">
          <legend id="label-standard-folders">標準フォルダ</legend>
          <div class="setting-row setting-row-toggle">
            <label for="setting-standard-folders-enabled">標準フォルダを有効にする</label>
            <label class="setting-toggle-wrap" aria-hidden="true">
              <input type="checkbox" id="setting-standard-folders-enabled" class="setting-toggle" role="switch" aria-describedby="setting-standard-folders-desc">
              <span class="setting-toggle-slider"></span>
            </label>
            <span id="setting-standard-folders-desc" class="setting-hint">汎用ルール・汎用スキル・汎用ツール・汎用ナレッジの4フォルダを監視に追加します(パスは編集可能)</span>
          </div>
          <div class="setting-monitor-paths-wrap" id="settings-standard-folders-wrap" aria-labelledby="label-standard-folders">
            <table class="setting-monitor-paths-table" aria-label="標準フォルダ一覧">
              <thead>
                <tr>
                  <th>パス</th>
                  <th>カテゴリ</th>
                  <th>説明</th>
                  <th>操作</th>
                </tr>
              </thead>
              <tbody id="settings-standard-paths-tbody"></tbody>
            </table>
          </div>
        </fieldset>
        <fieldset class="settings-fieldset" id="fieldset-monitor-paths">
          <legend id="label-monitor-paths">監視フォルダ</legend>
          <div class="setting-monitor-paths-wrap" aria-labelledby="label-monitor-paths">
            <table class="setting-monitor-paths-table" aria-label="監視フォルダ一覧">
              <thead>
                <tr>
                  <th>パス</th>
                  <th>カテゴリ</th>
                  <th>説明</th>
                  <th>操作</th>
                </tr>
              </thead>
              <tbody id="settings-custom-paths-tbody"></tbody>
            </table>
            <button type="button" id="settings-monitor-path-add" class="secondary-btn">追加</button>
          </div>
          <div id="settings-monitor-path-modal" class="settings-modal-overlay" role="dialog" aria-modal="true" aria-labelledby="settings-monitor-path-modal-title" hidden>
            <div class="settings-modal-content">
              <h3 id="settings-monitor-path-modal-title" class="settings-modal-title">フォルダを編集</h3>
              <div class="setting-row">
                <label for="modal-monitor-path">パス</label>
                <input type="text" id="modal-monitor-path" class="setting-monitor-path-input" placeholder="例: C:\\Documents\\Notes" aria-label="モニター先フォルダパス">
              </div>
              <div class="setting-row">
                <label for="modal-monitor-category">カテゴリ</label>
                <input type="text" id="modal-monitor-category" class="setting-monitor-category-input" placeholder="カテゴリ名" aria-label="カテゴリ名">
              </div>
              <div class="setting-row">
                <label for="modal-monitor-description">説明 (Markdown)</label>
                <textarea id="modal-monitor-description" class="setting-monitor-description-input" placeholder="説明 (Markdown)" aria-label="説明" rows="4"></textarea>
              </div>
              <div class="settings-modal-actions">
                <button type="button" id="settings-monitor-path-modal-cancel" class="secondary-btn">キャンセル</button>
                <button type="button" id="settings-monitor-path-modal-save" class="secondary-btn">保存</button>
              </div>
            </div>
          </div>
          <div class="setting-row setting-row-watch-extensions">
            <label for="setting-watch-extensions">取込対象拡張子</label>
            <div class="setting-watch-extensions-cell">
              <input type="text" id="setting-watch-extensions" class="setting-watch-extensions" value="txt, md, json, html, css, js, mjs, ts, rs" placeholder="例: txt, md, json" aria-describedby="setting-watch-extensions-desc" title="カンマ区切り。監視フォルダ内のこの拡張子のファイルのみ取り込みます">
              <span id="setting-watch-extensions-desc" class="setting-hint setting-hint-block">カンマ区切り(例: txt, md, json)。空の場合は既定の拡張子を使用</span>
            </div>
          </div>
        </fieldset>
        <div class="setting-actions">
          <button type="button" id="settings-save-btn" class="secondary-btn">保存</button>
          <button type="button" id="settings-backup-btn" class="secondary-btn">設定をバックアップ</button>
          <button type="button" id="settings-restore-btn" class="secondary-btn">設定を復元</button>
          <button type="button" id="settings-open-data-folder-btn" class="secondary-btn">データフォルダを開く</button>
          <span id="settings-feedback" class="settings-feedback" aria-live="polite"></span>
        </div>
      </div>
    `;

    const renderOneRow = (p, i) => {
      const path = (p && p.path) || '';
      const pathDisplay = path || '(保存後に確定)';
      const category = (p && p.category) || '';
      const desc = (p && p.description) || '';
      const descPreview = desc.length > 40 ? desc.slice(0, 40) + '…' : (desc || '—');
      const tr = document.createElement('tr');
      tr.innerHTML = `
        <td class="setting-monitor-path-cell">${escapeHtml(pathDisplay)}</td>
        <td class="setting-monitor-category-cell">${escapeHtml(category)}</td>
        <td class="setting-monitor-desc-preview-cell">${escapeHtml(descPreview)}</td>
        <td class="setting-monitor-actions-cell">
          <button type="button" class="setting-monitor-edit secondary-btn" data-index="${i}" title="編集">編集</button>
          <button type="button" class="setting-monitor-remove secondary-btn" data-index="${i}" title="削除">削除</button>
        </td>
      `;
      return tr;
    };

    const renderMonitorPathsTable = () => {
      const standardTbody = this.querySelector('#settings-standard-paths-tbody');
      const customTbody = this.querySelector('#settings-custom-paths-tbody');
      const standardWrap = this.querySelector('#settings-standard-folders-wrap');
      const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled');
      const arr = Array.isArray(this._monitorPaths) ? this._monitorPaths : [];
      const enabled = standardFoldersEl ? standardFoldersEl.checked : false;
      if (standardWrap) standardWrap.style.display = enabled ? '' : 'none';
      if (standardTbody) {
        standardTbody.innerHTML = '';
        arr.forEach((p, i) => {
          if (STANDARD_CATEGORY_NAMES.has((p && p.category) || '')) standardTbody.appendChild(renderOneRow(p, i));
        });
      }
      if (customTbody) {
        customTbody.innerHTML = '';
        arr.forEach((p, i) => {
          if (!STANDARD_CATEGORY_NAMES.has((p && p.category) || '')) customTbody.appendChild(renderOneRow(p, i));
        });
      }
    };

    const renderMonitorPathsList = (paths) => {
      const arr = Array.isArray(paths) ? paths : [];
      this._monitorPaths = arr.map((p) => {
        if (typeof p === 'object' && p !== null) {
          return { path: String(p.path || ''), category: String(p.category || ''), description: String(p.description || '') };
        }
        return { path: String(p || ''), category: '', description: '' };
      });
      renderMonitorPathsTable();
    };

    const loadSettingsIntoForm = (settingsOverrides) => {
      try {
        const s = settingsOverrides != null
          ? { ...DEFAULTS, ...settingsOverrides }
          : (() => {
              const raw = localStorage.getItem(SETTINGS_KEY);
              return raw ? { ...DEFAULTS, ...JSON.parse(raw) } : DEFAULTS;
            })();
        const minScoreEl = this.querySelector('#setting-min-score');
        const limitEl = this.querySelector('#setting-limit');
        const runOnLoginEl = this.querySelector('#setting-run-on-login');
        if (minScoreEl) minScoreEl.value = String(Number(s.min_score));
        if (limitEl) limitEl.value = String(Number(s.limit));
        if (runOnLoginEl) runOnLoginEl.checked = Boolean(s.run_on_login);
        const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled');
        if (standardFoldersEl) standardFoldersEl.checked = Boolean(s.standard_folders_enabled);
        renderMonitorPathsList(s.monitor_paths);
        const extEl = this.querySelector('#setting-watch-extensions');
        if (extEl) extEl.value = Array.isArray(s.watch_extensions) ? s.watch_extensions.join(', ') : (s.watch_extensions || '');
      } catch (e) {
        const minScoreEl = this.querySelector('#setting-min-score');
        const limitEl = this.querySelector('#setting-limit');
        const runOnLoginEl = this.querySelector('#setting-run-on-login');
        if (minScoreEl) minScoreEl.value = String(DEFAULTS.min_score);
        if (limitEl) limitEl.value = String(DEFAULTS.limit);
        if (runOnLoginEl) runOnLoginEl.checked = DEFAULTS.run_on_login;
        const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled');
        if (standardFoldersEl) standardFoldersEl.checked = DEFAULTS.standard_folders_enabled;
        renderMonitorPathsList(DEFAULTS.monitor_paths);
        const extEl = this.querySelector('#setting-watch-extensions');
        if (extEl) extEl.value = DEFAULTS.watch_extensions.join(', ');
      }
    };

    this.loadForm = async (data) => {
      let fromFile = data;
      if (fromFile == null) {
        fromFile = await loadSettingsFromFile();
        if (fromFile && window.__TAURI__?.core?.invoke) {
          try {
            const invoke = window.__TAURI__.core.invoke;
            const osEnabled = await invoke('autostart_is_enabled');
            if (fromFile.run_on_login !== osEnabled) {
              fromFile.run_on_login = osEnabled;
            }
          } catch (_) {}
        }
      }
      if (fromFile) {
        loadSettingsIntoForm(fromFile);
        localStorage.setItem(SETTINGS_KEY, JSON.stringify(fromFile));
      } else {
        loadSettingsIntoForm();
      }
    };

    const modalEl = this.querySelector('#settings-monitor-path-modal');
    const modalPath = this.querySelector('#modal-monitor-path');
    const modalCategory = this.querySelector('#modal-monitor-category');
    const modalDescription = this.querySelector('#modal-monitor-description');

    const openMonitorPathModal = (index) => {
      this._editingMonitorPathIndex = index;
      const titleEl = this.querySelector('#settings-monitor-path-modal-title');
      if (titleEl) titleEl.textContent = index === null ? 'フォルダを追加' : 'フォルダを編集';
      if (modalPath) modalPath.value = index !== null && this._monitorPaths[index] ? this._monitorPaths[index].path : '';
      if (modalCategory) modalCategory.value = index !== null && this._monitorPaths[index] ? this._monitorPaths[index].category : '';
      if (modalDescription) modalDescription.value = index !== null && this._monitorPaths[index] ? this._monitorPaths[index].description : '';
      if (modalEl) {
        modalEl.hidden = false;
        modalPath?.focus();
      }
    };

    const closeMonitorPathModal = () => {
      if (modalEl) modalEl.hidden = true;
      this._editingMonitorPathIndex = null;
    };

    this.querySelector('#settings-monitor-path-add')?.addEventListener('click', (e) => {
      e.preventDefault();
      openMonitorPathModal(null);
    });

    this.addEventListener('click', (e) => {
      if (e.target.classList.contains('setting-monitor-edit')) {
        const i = parseInt(e.target.dataset.index, 10);
        if (!Number.isNaN(i) && i >= 0) openMonitorPathModal(i);
        return;
      }
      if (e.target.classList.contains('setting-monitor-remove')) {
        const i = parseInt(e.target.dataset.index, 10);
        if (Number.isNaN(i) || i < 0) return;
        const path = (this._monitorPaths[i] && this._monitorPaths[i].path) || '';
        if (path) this._removeFromIndexPaths.add(path);
        this._monitorPaths.splice(i, 1);
        renderMonitorPathsTable();
      }
    });

    this.querySelector('#settings-monitor-path-modal-cancel')?.addEventListener('click', () => closeMonitorPathModal());
    this.querySelector('#settings-monitor-path-modal-save')?.addEventListener('click', () => {
      const path = (modalPath?.value || '').trim();
      const category = (modalCategory?.value || '').trim();
      const description = (modalDescription?.value || '').trim();
      const idx = this._editingMonitorPathIndex;
      if (idx === null) {
        if (path) this._monitorPaths.push({ path, category, description });
      } else if (typeof idx === 'number' && this._monitorPaths[idx] !== undefined) {
        this._monitorPaths[idx] = { path, category, description };
      }
      renderMonitorPathsTable();
      closeMonitorPathModal();
    });
    modalEl?.addEventListener('click', (e) => {
      if (e.target === modalEl) closeMonitorPathModal();
    });

    this.querySelector('#setting-run-on-login')?.addEventListener('change', () => {
      const runOnLoginCheckbox = this.querySelector('#setting-run-on-login');
      if (runOnLoginCheckbox) {
        console.log('[TelosDB] 自動起動:', runOnLoginCheckbox.checked ? 'オン' : 'オフ');
      }
    });

    // 標準フォルダONで一覧に4行を即表示(path は空で保存時にバックエンドが実パスを付与)
    const standardFoldersToggle = this.querySelector('#setting-standard-folders-enabled');
    if (standardFoldersToggle) {
      standardFoldersToggle.addEventListener('change', () => {
        const enabled = standardFoldersToggle.checked;
        const arr = Array.isArray(this._monitorPaths) ? [...this._monitorPaths] : [];
        const standardNames = new Set(STANDARD_FOLDER_CATEGORIES.map(([name]) => name));
        if (enabled) {
          for (const [category, description] of STANDARD_FOLDER_CATEGORIES) {
            const has = arr.some((p) => (p && p.category) === category);
            if (!has) arr.push({ path: '', category, description });
          }
        } else {
          for (let i = arr.length - 1; i >= 0; i--) {
            if (standardNames.has((arr[i] && arr[i].category) || '')) arr.splice(i, 1);
          }
        }
        this._monitorPaths = arr;
        renderMonitorPathsTable();
      });
    }

    const saveBtn = this.querySelector('#settings-save-btn');
    const feedbackEl = this.querySelector('#settings-feedback');
    if (saveBtn && feedbackEl) {
      saveBtn.addEventListener('click', async () => {
        const minScoreEl = this.querySelector('#setting-min-score');
        const limitEl = this.querySelector('#setting-limit');
        const runOnLoginEl = this.querySelector('#setting-run-on-login');
        const min_score = Math.max(0, Math.min(1, parseFloat(minScoreEl?.value) || DEFAULTS.min_score));
        const limit = Math.max(1, Math.min(100, parseInt(limitEl?.value, 10) || DEFAULTS.limit));
        const run_on_login = runOnLoginEl ? runOnLoginEl.checked : DEFAULTS.run_on_login;
        const standardFoldersEl = this.querySelector('#setting-standard-folders-enabled');
        const standard_folders_enabled = standardFoldersEl ? standardFoldersEl.checked : DEFAULTS.standard_folders_enabled;
        const monitor_paths = (Array.isArray(this._monitorPaths) ? this._monitorPaths : [])
          .map((p) => {
            const path = (p && p.path || '').trim();
            const category = (p && p.category || '').trim();
            const description = (p && p.description || '').trim();
            return path ? { path, category, ...(description ? { description } : {}) } : null;
          })
          .filter(Boolean);
        const extInput = this.querySelector('#setting-watch-extensions');
        const watch_extensions = (extInput?.value || '')
          .split(/[\s,]+/)
          .map((s) => s.trim().toLowerCase())
          .filter(Boolean);
        try {
          let autostartError = null;
          try {
            const invoke = window.__TAURI__?.core?.invoke;
            if (!invoke) throw new Error('Tauri API が利用できません');
            if (run_on_login) {
              await invoke('autostart_enable');
              console.log('[TelosDB] autostart: 有効化成功');
            } else {
              await invoke('autostart_disable');
              console.log('[TelosDB] autostart: 無効化成功');
            }
          } catch (autoErr) {
            autostartError = String(autoErr?.message || autoErr);
            console.error('[TelosDB] autostart 設定失敗:', autoErr);
          }
          const remove_from_index_paths = Array.from(this._removeFromIndexPaths || []);
          const payload = {
            min_score,
            limit,
            run_on_login,
            standard_folders_enabled,
            monitor_paths,
            watch_extensions: watch_extensions.length ? watch_extensions : DEFAULTS.watch_extensions,
            remove_from_index_paths,
          };
          console.log('[TelosDB] 保存: run_on_login =', run_on_login, 'monitor_paths =', monitor_paths.length, 'watch_extensions =', payload.watch_extensions.length, 'remove_from_index_paths =', remove_from_index_paths.length);
          const saved = await saveSettingsToFile(payload);
          if (saved) {
            this._removeFromIndexPaths?.clear();
            let fromFile = await loadSettingsFromFile();
            if (!fromFile) {
              await new Promise((r) => setTimeout(r, 300));
              fromFile = await loadSettingsFromFile();
            }
            if (fromFile) {
              loadSettingsIntoForm(fromFile);
              localStorage.setItem(SETTINGS_KEY, JSON.stringify(fromFile));
            } else {
              const toStore = { min_score, limit, run_on_login, standard_folders_enabled, monitor_paths, watch_extensions: payload.watch_extensions };
              localStorage.setItem(SETTINGS_KEY, JSON.stringify(toStore));
              if (minScoreEl) minScoreEl.value = String(min_score);
              if (limitEl) limitEl.value = String(limit);
              if (runOnLoginEl) runOnLoginEl.checked = run_on_login;
              if (standardFoldersEl) standardFoldersEl.checked = standard_folders_enabled;
              renderMonitorPathsList(monitor_paths);
            }
            if (autostartError) {
              feedbackEl.textContent = '保存しました(自動起動の登録に失敗: ' + autostartError + ')';
              feedbackEl.classList.add('error');
            } else {
              feedbackEl.textContent = '保存しました';
              feedbackEl.classList.remove('error');
            }
            setTimeout(() => { feedbackEl.textContent = ''; }, autostartError ? 5000 : 2000);
          } else {
            feedbackEl.textContent = '保存に失敗しました(ファイルへの書き込みに失敗しました)';
            feedbackEl.classList.add('error');
          }
        } catch (e) {
          feedbackEl.textContent = '保存に失敗しました';
          feedbackEl.classList.add('error');
        }
      });
    }

    this.querySelector('#settings-backup-btn')?.addEventListener('click', async () => {
      const feedbackEl = this.querySelector('#settings-feedback');
      const invoke = window.__TAURI__?.core?.invoke;
      if (!invoke || !feedbackEl) return;
      try {
        const date = new Date();
        const yyyy = date.getFullYear();
        const mm = String(date.getMonth() + 1).padStart(2, '0');
        const dd = String(date.getDate()).padStart(2, '0');
        const path = await invoke('plugin:dialog|save', {
          options: {
            defaultPath: `telosdb-settings-${yyyy}-${mm}-${dd}.json`,
            filters: [{ name: 'JSON', extensions: ['json'] }],
            title: '設定のバックアップ先を選択',
          },
        });
        if (path == null) return;
        await invoke('export_settings_to_file', { path });
        feedbackEl.textContent = 'バックアップしました';
        feedbackEl.classList.remove('error');
        setTimeout(() => { feedbackEl.textContent = ''; }, 2000);
      } catch (e) {
        feedbackEl.textContent = 'バックアップに失敗しました: ' + (e?.message || e);
        feedbackEl.classList.add('error');
      }
    });

    this.querySelector('#settings-restore-btn')?.addEventListener('click', async () => {
      const feedbackEl = this.querySelector('#settings-feedback');
      const invoke = window.__TAURI__?.core?.invoke;
      if (!invoke || !feedbackEl) return;
      try {
        const path = await invoke('plugin:dialog|open', {
          options: {
            multiple: false,
            filters: [{ name: 'JSON', extensions: ['json'] }],
            title: '復元する設定ファイルを選択',
          },
        });
        if (path == null) return;
        const pathStr = Array.isArray(path) ? path[0] : path;
        await invoke('import_settings_from_file', { path: pathStr });
        const fromFile = await loadSettingsFromFile();
        if (fromFile) {
          loadSettingsIntoForm(fromFile);
          localStorage.setItem(SETTINGS_KEY, JSON.stringify(fromFile));
        }
        feedbackEl.textContent = '設定を復元しました。自動起動を変更する場合は保存を押してください。';
        feedbackEl.classList.remove('error');
        setTimeout(() => { feedbackEl.textContent = ''; }, 4000);
      } catch (e) {
        feedbackEl.textContent = '復元に失敗しました: ' + (e?.message || e);
        feedbackEl.classList.add('error');
      }
    });

    this.querySelector('#settings-open-data-folder-btn')?.addEventListener('click', async () => {
      const invoke = window.__TAURI__?.core?.invoke;
      if (!invoke) return;
      try {
        await invoke('open_app_data_folder');
      } catch (e) {
        const feedbackEl = this.querySelector('#settings-feedback');
        if (feedbackEl) {
          feedbackEl.textContent = 'フォルダを開けませんでした: ' + (e?.message || e);
          feedbackEl.classList.add('error');
          setTimeout(() => { feedbackEl.textContent = ''; feedbackEl.classList.remove('error'); }, 4000);
        }
      }
    });
  }
}

customElements.define('settings-panel', SettingsPanel);
export {};