formatter.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. /**
  2. * formatter.ts - Output formatting utilities for QMD
  3. *
  4. * Provides methods to format search results and documents into various output formats:
  5. * JSON, CSV, XML, Markdown, files list, and CLI (colored terminal output).
  6. */
  7. import { extractSnippet } from "../store.js";
  8. // =============================================================================
  9. // Helper Functions
  10. // =============================================================================
  11. /**
  12. * Add line numbers to text content.
  13. * Each line becomes: "{lineNum}: {content}"
  14. * @param text The text to add line numbers to
  15. * @param startLine Optional starting line number (default: 1)
  16. */
  17. export function addLineNumbers(text, startLine = 1) {
  18. const lines = text.split('\n');
  19. return lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
  20. }
  21. /**
  22. * Extract short docid from a full hash (first 6 characters).
  23. */
  24. export function getDocid(hash) {
  25. return hash.slice(0, 6);
  26. }
  27. // =============================================================================
  28. // Escape Helpers
  29. // =============================================================================
  30. export function escapeCSV(value) {
  31. if (value === null || value === undefined)
  32. return "";
  33. const str = String(value);
  34. if (str.includes(",") || str.includes('"') || str.includes("\n")) {
  35. return `"${str.replace(/"/g, '""')}"`;
  36. }
  37. return str;
  38. }
  39. export function escapeXml(str) {
  40. return str
  41. .replace(/&/g, "&")
  42. .replace(/</g, "&lt;")
  43. .replace(/>/g, "&gt;")
  44. .replace(/"/g, "&quot;")
  45. .replace(/'/g, "&apos;");
  46. }
  47. // =============================================================================
  48. // Search Results Formatters
  49. // =============================================================================
  50. /**
  51. * Format search results as JSON
  52. */
  53. export function searchResultsToJson(results, opts = {}) {
  54. const query = opts.query || "";
  55. const output = results.map(row => {
  56. const bodyStr = row.body || "";
  57. const snippetInfo = bodyStr
  58. ? extractSnippet(bodyStr, query, 300, row.chunkPos, undefined, opts.intent)
  59. : undefined;
  60. let body = opts.full ? bodyStr : undefined;
  61. let snippet = !opts.full ? snippetInfo?.snippet : undefined;
  62. if (opts.lineNumbers) {
  63. if (body)
  64. body = addLineNumbers(body);
  65. if (snippet)
  66. snippet = addLineNumbers(snippet);
  67. }
  68. return {
  69. docid: `#${row.docid}`,
  70. score: Math.round(row.score * 100) / 100,
  71. file: row.displayPath,
  72. ...(snippetInfo && { line: snippetInfo.line }),
  73. title: row.title,
  74. ...(row.context && { context: row.context }),
  75. ...(body && { body }),
  76. ...(snippet && { snippet }),
  77. };
  78. });
  79. return JSON.stringify(output, null, 2);
  80. }
  81. /**
  82. * Format search results as CSV
  83. */
  84. export function searchResultsToCsv(results, opts = {}) {
  85. const query = opts.query || "";
  86. const header = "docid,score,file,title,context,line,snippet";
  87. const rows = results.map(row => {
  88. const bodyStr = row.body || "";
  89. const { line, snippet } = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent);
  90. let content = opts.full ? bodyStr : snippet;
  91. if (opts.lineNumbers && content) {
  92. content = addLineNumbers(content);
  93. }
  94. return [
  95. `#${row.docid}`,
  96. row.score.toFixed(4),
  97. escapeCSV(row.displayPath),
  98. escapeCSV(row.title),
  99. escapeCSV(row.context || ""),
  100. line,
  101. escapeCSV(content),
  102. ].join(",");
  103. });
  104. return [header, ...rows].join("\n");
  105. }
  106. /**
  107. * Format search results as simple files list (docid,score,filepath,context)
  108. */
  109. export function searchResultsToFiles(results) {
  110. return results.map(row => {
  111. const ctx = row.context ? `,"${row.context.replace(/"/g, '""')}"` : "";
  112. return `#${row.docid},${row.score.toFixed(2)},${row.displayPath}${ctx}`;
  113. }).join("\n");
  114. }
  115. /**
  116. * Format search results as Markdown
  117. */
  118. export function searchResultsToMarkdown(results, opts = {}) {
  119. const query = opts.query || "";
  120. return results.map(row => {
  121. const heading = row.title || row.displayPath;
  122. const bodyStr = row.body || "";
  123. let content;
  124. if (opts.full) {
  125. content = bodyStr;
  126. }
  127. else {
  128. content = extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet;
  129. }
  130. if (opts.lineNumbers) {
  131. content = addLineNumbers(content);
  132. }
  133. const contextLine = row.context ? `**context:** ${row.context}\n` : "";
  134. return `---\n# ${heading}\n\n**docid:** \`#${row.docid}\`\n${contextLine}\n${content}\n`;
  135. }).join("\n");
  136. }
  137. /**
  138. * Format search results as XML
  139. */
  140. export function searchResultsToXml(results, opts = {}) {
  141. const query = opts.query || "";
  142. const items = results.map(row => {
  143. const titleAttr = row.title ? ` title="${escapeXml(row.title)}"` : "";
  144. const bodyStr = row.body || "";
  145. let content = opts.full ? bodyStr : extractSnippet(bodyStr, query, 500, row.chunkPos, undefined, opts.intent).snippet;
  146. if (opts.lineNumbers) {
  147. content = addLineNumbers(content);
  148. }
  149. const contextAttr = row.context ? ` context="${escapeXml(row.context)}"` : "";
  150. return `<file docid="#${row.docid}" name="${escapeXml(row.displayPath)}"${titleAttr}${contextAttr}>\n${escapeXml(content)}\n</file>`;
  151. });
  152. return items.join("\n\n");
  153. }
  154. /**
  155. * Format search results for MCP (simpler CSV format with pre-extracted snippets)
  156. */
  157. export function searchResultsToMcpCsv(results) {
  158. const header = "docid,file,title,score,context,snippet";
  159. const rows = results.map(r => [`#${r.docid}`, r.file, r.title, r.score, r.context || "", r.snippet].map(escapeCSV).join(","));
  160. return [header, ...rows].join("\n");
  161. }
  162. // =============================================================================
  163. // Document Formatters (for multi-get using MultiGetFile from store)
  164. // =============================================================================
  165. /**
  166. * Format documents as JSON
  167. */
  168. export function documentsToJson(results) {
  169. const output = results.map(r => ({
  170. file: r.displayPath,
  171. title: r.title,
  172. ...(r.context && { context: r.context }),
  173. ...(r.skipped ? { skipped: true, reason: r.skipReason } : { body: r.body }),
  174. }));
  175. return JSON.stringify(output, null, 2);
  176. }
  177. /**
  178. * Format documents as CSV
  179. */
  180. export function documentsToCsv(results) {
  181. const header = "file,title,context,skipped,body";
  182. const rows = results.map(r => [
  183. r.displayPath,
  184. r.title,
  185. r.context || "",
  186. r.skipped ? "true" : "false",
  187. r.skipped ? (r.skipReason || "") : r.body
  188. ].map(escapeCSV).join(","));
  189. return [header, ...rows].join("\n");
  190. }
  191. /**
  192. * Format documents as files list
  193. */
  194. export function documentsToFiles(results) {
  195. return results.map(r => {
  196. const ctx = r.context ? `,"${r.context.replace(/"/g, '""')}"` : "";
  197. const status = r.skipped ? ",[SKIPPED]" : "";
  198. return `${r.displayPath}${ctx}${status}`;
  199. }).join("\n");
  200. }
  201. /**
  202. * Format documents as Markdown
  203. */
  204. export function documentsToMarkdown(results) {
  205. return results.map(r => {
  206. let md = `## ${r.displayPath}\n\n`;
  207. if (r.title && r.title !== r.displayPath)
  208. md += `**Title:** ${r.title}\n\n`;
  209. if (r.context)
  210. md += `**Context:** ${r.context}\n\n`;
  211. if (r.skipped) {
  212. md += `> ${r.skipReason}\n`;
  213. }
  214. else {
  215. md += "```\n" + r.body + "\n```\n";
  216. }
  217. return md;
  218. }).join("\n");
  219. }
  220. /**
  221. * Format documents as XML
  222. */
  223. export function documentsToXml(results) {
  224. const items = results.map(r => {
  225. let xml = " <document>\n";
  226. xml += ` <file>${escapeXml(r.displayPath)}</file>\n`;
  227. xml += ` <title>${escapeXml(r.title)}</title>\n`;
  228. if (r.context)
  229. xml += ` <context>${escapeXml(r.context)}</context>\n`;
  230. if (r.skipped) {
  231. xml += ` <skipped>true</skipped>\n`;
  232. xml += ` <reason>${escapeXml(r.skipReason || "")}</reason>\n`;
  233. }
  234. else {
  235. xml += ` <body>${escapeXml(r.body)}</body>\n`;
  236. }
  237. xml += " </document>";
  238. return xml;
  239. });
  240. return `<?xml version="1.0" encoding="UTF-8"?>\n<documents>\n${items.join("\n")}\n</documents>`;
  241. }
  242. // =============================================================================
  243. // Single Document Formatters
  244. // =============================================================================
  245. /**
  246. * Format a single DocumentResult as JSON
  247. */
  248. export function documentToJson(doc) {
  249. return JSON.stringify({
  250. file: doc.displayPath,
  251. title: doc.title,
  252. ...(doc.context && { context: doc.context }),
  253. hash: doc.hash,
  254. modifiedAt: doc.modifiedAt,
  255. bodyLength: doc.bodyLength,
  256. ...(doc.body !== undefined && { body: doc.body }),
  257. }, null, 2);
  258. }
  259. /**
  260. * Format a single DocumentResult as Markdown
  261. */
  262. export function documentToMarkdown(doc) {
  263. let md = `# ${doc.title || doc.displayPath}\n\n`;
  264. if (doc.context)
  265. md += `**Context:** ${doc.context}\n\n`;
  266. md += `**File:** ${doc.displayPath}\n`;
  267. md += `**Modified:** ${doc.modifiedAt}\n\n`;
  268. if (doc.body !== undefined) {
  269. md += "---\n\n" + doc.body + "\n";
  270. }
  271. return md;
  272. }
  273. /**
  274. * Format a single DocumentResult as XML
  275. */
  276. export function documentToXml(doc) {
  277. let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<document>\n`;
  278. xml += ` <file>${escapeXml(doc.displayPath)}</file>\n`;
  279. xml += ` <title>${escapeXml(doc.title)}</title>\n`;
  280. if (doc.context)
  281. xml += ` <context>${escapeXml(doc.context)}</context>\n`;
  282. xml += ` <hash>${escapeXml(doc.hash)}</hash>\n`;
  283. xml += ` <modifiedAt>${escapeXml(doc.modifiedAt)}</modifiedAt>\n`;
  284. xml += ` <bodyLength>${doc.bodyLength}</bodyLength>\n`;
  285. if (doc.body !== undefined) {
  286. xml += ` <body>${escapeXml(doc.body)}</body>\n`;
  287. }
  288. xml += `</document>`;
  289. return xml;
  290. }
  291. /**
  292. * Format a single document to the specified format
  293. */
  294. export function formatDocument(doc, format) {
  295. switch (format) {
  296. case "json":
  297. return documentToJson(doc);
  298. case "md":
  299. return documentToMarkdown(doc);
  300. case "xml":
  301. return documentToXml(doc);
  302. default:
  303. // Default to markdown for CLI and other formats
  304. return documentToMarkdown(doc);
  305. }
  306. }
  307. // =============================================================================
  308. // Universal Format Function
  309. // =============================================================================
  310. /**
  311. * Format search results to the specified output format
  312. */
  313. export function formatSearchResults(results, format, opts = {}) {
  314. switch (format) {
  315. case "json":
  316. return searchResultsToJson(results, opts);
  317. case "csv":
  318. return searchResultsToCsv(results, opts);
  319. case "files":
  320. return searchResultsToFiles(results);
  321. case "md":
  322. return searchResultsToMarkdown(results, opts);
  323. case "xml":
  324. return searchResultsToXml(results, opts);
  325. case "cli":
  326. // CLI format should be handled separately with colors
  327. // Return a simple text version as fallback
  328. return searchResultsToMarkdown(results, opts);
  329. default:
  330. return searchResultsToJson(results, opts);
  331. }
  332. }
  333. /**
  334. * Format documents to the specified output format
  335. */
  336. export function formatDocuments(results, format) {
  337. switch (format) {
  338. case "json":
  339. return documentsToJson(results);
  340. case "csv":
  341. return documentsToCsv(results);
  342. case "files":
  343. return documentsToFiles(results);
  344. case "md":
  345. return documentsToMarkdown(results);
  346. case "xml":
  347. return documentsToXml(results);
  348. case "cli":
  349. // CLI format should be handled separately with colors
  350. return documentsToMarkdown(results);
  351. default:
  352. return documentsToJson(results);
  353. }
  354. }