Newer
Older
TelosDB / src / frontend / js / shared.js
/**
 * フロントエンド共通: API・設定読み書き・ユーティリティ
 */

const DEFAULT_EXTENSIONS = ['txt', 'md', 'json', 'html', 'css', 'js', 'mjs', 'ts', 'rs'];

export const SETTINGS_KEY = 'telosdb_settings';

export const DEFAULTS = {
  min_score: 0.3,
  limit: 5,
  run_on_login: false,
  standard_folders_enabled: false,
  monitor_paths: [],
  watch_extensions: DEFAULT_EXTENSIONS,
};

export function getApiBase() {
  return (typeof window !== 'undefined' && window.API_BASE) ? window.API_BASE : 'http://127.0.0.1:3001';
}

export function escapeHtml(s) {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}

/**
 * Markdown を安全な HTML に変換する(見出し・太字・イタリック・コード・リンク・リスト・ブロック引用など)。
 * 出力は許可タグのみで XSS 対策済み。
 */
export function markdownToHtml(md) {
  if (md == null || typeof md !== 'string') return '';
  const esc = (t) => {
    const d = document.createElement('div');
    d.textContent = t;
    return d.innerHTML;
  };
  const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g;
  const boldRe = /\*\*([^*]+)\*\*|__([^_]+)__/g;
  const italicRe = /\*([^*]+)\*|_([^_]+)_/g;
  const codeRe = new RegExp('`([^`]+)`', 'g');
  const processInline = (line) => {
    const placeholders = [];
    const ph = (html) => {
      const id = placeholders.length;
      placeholders.push(html);
      return '\uFFFC' + id + '\uFFFC';
    };
    let s = line
      .replace(linkRe, (_, text, href) => ph(`<a href="${esc(href)}" rel="noopener">${esc(text)}</a>`))
      .replace(boldRe, (_, a, b) => ph('<strong>' + esc(a != null ? a : b) + '</strong>'))
      .replace(italicRe, (_, a, b) => ph('<em>' + esc(a != null ? a : b) + '</em>'))
      .replace(codeRe, (_, c) => ph('<code>' + esc(c) + '</code>'));
    s = esc(s);
    placeholders.forEach((html, i) => {
      s = s.replace('\uFFFC' + i + '\uFFFC', html);
    });
    return s;
  };
  const lines = md.split(/\r?\n/);
  const out = [];
  let i = 0;
  let inFence = false;
  let fenceChar = '';
  let codeBlock = [];
  const fenceRe = new RegExp('^(`{3,}|~{3,})(.*)$');

  const parseTableRow = (str) => {
    return str.split('|').map((c) => c.trim()).filter((c) => c !== '');
  };
  const isTableSeparator = (str) => /^\|[\s\-:]+\|/.test(str) && /^[\s|\-:]+$/.test(str.trim());

  while (i < lines.length) {
    const line = lines[i];
    const fence = line.match(fenceRe);
    if (fence) {
      if (!inFence) {
        inFence = true;
        fenceChar = fence[1];
        codeBlock = [fence[2] ? esc(fence[2].trim()) : ''];
      } else if (line.startsWith(fenceChar)) {
        inFence = false;
        out.push('<pre><code>' + (codeBlock.length > 1 ? codeBlock.slice(1).join('\n') : codeBlock[0]).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</code></pre>');
        codeBlock = [];
      } else {
        codeBlock.push(line);
      }
      i++;
      continue;
    }
    if (inFence) {
      codeBlock.push(line);
      i++;
      continue;
    }
    const head = line.match(/^(#{1,6})\s+(.*)$/);
    if (head) {
      const level = head[1].length;
      out.push(`<h${level}>${processInline(head[2])}</h${level}>`);
      i++;
      continue;
    }
    if (line.includes('|') && line.trim().length > 0) {
      const tableRows = [];
      let j = i;
      while (j < lines.length && lines[j].includes('|')) {
        tableRows.push(lines[j]);
        j++;
      }
      if (tableRows.length >= 2) {
        const headerCells = parseTableRow(tableRows[0]);
        const isSep = isTableSeparator(tableRows[1]);
        const bodyStart = isSep ? 2 : 1;
        if (headerCells.length > 0) {
          let tableHtml = '<table><thead><tr>';
          headerCells.forEach((c) => { tableHtml += '<th>' + processInline(c) + '</th>'; });
          tableHtml += '</tr></thead><tbody>';
          for (let r = bodyStart; r < tableRows.length; r++) {
            const cells = parseTableRow(tableRows[r]);
            if (cells.length > 0) {
              tableHtml += '<tr>';
              cells.forEach((c) => { tableHtml += '<td>' + processInline(c) + '</td>'; });
              tableHtml += '</tr>';
            }
          }
          tableHtml += '</tbody></table>';
          out.push(tableHtml);
        }
        i = j;
        continue;
      }
    }
    if (line.startsWith('> ')) {
      const quote = [];
      while (i < lines.length && lines[i].startsWith('> ')) {
        quote.push(processInline(lines[i].slice(2)));
        i++;
      }
      out.push('<blockquote>' + quote.join('<br>') + '</blockquote>');
      continue;
    }
    if (line.match(/^[-*]\s/)) {
      const items = [];
      while (i < lines.length && lines[i].match(/^[-*]\s/)) {
        items.push('<li>' + processInline(lines[i].replace(/^[-*]\s+/, '')) + '</li>');
        i++;
      }
      out.push('<ul>' + items.join('') + '</ul>');
      continue;
    }
    if (line.match(/^\d+\.\s/)) {
      const items = [];
      while (i < lines.length && lines[i].match(/^\d+\.\s/)) {
        items.push('<li>' + processInline(lines[i].replace(/^\d+\.\s+/, '')) + '</li>');
        i++;
      }
      out.push('<ol>' + items.join('') + '</ol>');
      continue;
    }
    if (line.trim() === '') {
      out.push('<br>');
      i++;
      continue;
    }
    out.push('<p>' + processInline(line) + '</p>');
    i++;
  }
  if (inFence && codeBlock.length) {
    out.push('<pre><code>' + codeBlock.join('\n').replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</code></pre>');
  }
  return out.join('');
}

export async function callMcp(method, params = {}) {
  const res = await fetch(`${getApiBase()}/messages`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',
      method,
      params,
      id: Date.now(),
    }),
  });
  const data = await res.json();
  if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
  return data.result;
}

export function parseResultText(result) {
  const content = result?.content;
  if (!content || !Array.isArray(content) || content.length === 0) return null;
  if (content[0].type === 'text' && content[0].text) {
    try {
      return JSON.parse(content[0].text);
    } catch (_) {
      return content[0].text;
    }
  }
  return null;
}

export async function loadSettingsFromFile() {
  try {
    const { invoke } = await import('@tauri-apps/api/core');
    const fileSettings = await invoke('get_app_settings');
    if (fileSettings && typeof fileSettings === 'object') {
      return {
        min_score: fileSettings.min_score ?? DEFAULTS.min_score,
        limit: fileSettings.limit ?? DEFAULTS.limit,
        run_on_login: Boolean(fileSettings.run_on_login),
        standard_folders_enabled: Boolean(fileSettings.standard_folders_enabled),
        monitor_paths: Array.isArray(fileSettings.monitor_paths) ? fileSettings.monitor_paths : DEFAULTS.monitor_paths,
        watch_extensions: Array.isArray(fileSettings.watch_extensions) ? fileSettings.watch_extensions : DEFAULTS.watch_extensions,
      };
    }
  } catch (_) {
    try {
      const res = await fetch(`${getApiBase()}/settings`);
      if (!res.ok) return null;
      const fileSettings = await res.json();
      if (fileSettings && typeof fileSettings === 'object') {
        return {
          min_score: fileSettings.min_score ?? DEFAULTS.min_score,
          limit: fileSettings.limit ?? DEFAULTS.limit,
          run_on_login: Boolean(fileSettings.run_on_login),
          standard_folders_enabled: Boolean(fileSettings.standard_folders_enabled),
          monitor_paths: Array.isArray(fileSettings.monitor_paths) ? fileSettings.monitor_paths : DEFAULTS.monitor_paths,
          watch_extensions: Array.isArray(fileSettings.watch_extensions) ? fileSettings.watch_extensions : DEFAULTS.watch_extensions,
        };
      }
    } catch (_) {}
  }
  return null;
}

export async function saveSettingsToFile(payload) {
  const toPersist = {
    min_score: payload.min_score,
    limit: payload.limit,
    run_on_login: payload.run_on_login,
    standard_folders_enabled: payload.standard_folders_enabled,
    monitor_paths: payload.monitor_paths,
    watch_extensions: payload.watch_extensions,
  };
  const hasRemovals = Array.isArray(payload.remove_from_index_paths) && payload.remove_from_index_paths.length > 0;
  if (hasRemovals) {
    try {
      const res = await fetch(`${getApiBase()}/settings`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...toPersist, remove_from_index_paths: payload.remove_from_index_paths }),
      });
      return res.ok;
    } catch (_) {
      return false;
    }
  }
  try {
    const { invoke } = await import('@tauri-apps/api/core');
    await invoke('set_app_settings', { settings: toPersist });
    return true;
  } catch (_) {
    try {
      const res = await fetch(`${getApiBase()}/settings`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(toPersist),
      });
      return res.ok;
    } catch (_) {
      return false;
    }
  }
}