add notmuch query service with JSON parsing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseSearchResults, parseThreadMessages } from "./notmuch";
|
||||
|
||||
const searchFixture = await Bun.file("test/fixtures/notmuch-search-output.json").json();
|
||||
const showFixture = await Bun.file("test/fixtures/notmuch-show-output.json").json();
|
||||
|
||||
describe("parseSearchResults", () => {
|
||||
test("parses notmuch search JSON into thread summaries", () => {
|
||||
const threads = parseSearchResults(searchFixture);
|
||||
expect(threads).toHaveLength(2);
|
||||
expect(threads[0].threadId).toBe("0000000000000001");
|
||||
expect(threads[0].subject).toBe("Q1 Planning");
|
||||
expect(threads[0].authors).toBe("Alice, Bob");
|
||||
expect(threads[0].totalMessages).toBe(3);
|
||||
expect(threads[0].tags).toContain("unread");
|
||||
expect(threads[0].timestamp).toBe(1709884532);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseThreadMessages", () => {
|
||||
test("flattens notmuch show JSON into ordered messages", () => {
|
||||
const messages = parseThreadMessages(showFixture);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0].messageId).toBe("msg001@example.com");
|
||||
expect(messages[0].from).toBe("Alice <alice@example.com>");
|
||||
expect(messages[0].subject).toBe("Q1 Planning");
|
||||
expect(messages[0].body).toBe("Hey, let's plan Q1.\n");
|
||||
expect(messages[1].messageId).toBe("msg003@example.com");
|
||||
expect(messages[1].inReplyTo).toBe("msg001@example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { runCommand } from "./orchestrator";
|
||||
|
||||
export interface ThreadSummary {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
authors: string;
|
||||
totalMessages: number;
|
||||
tags: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
messageId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
date: string;
|
||||
inReplyTo: string;
|
||||
references: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function parseSearchResults(raw: unknown[]): ThreadSummary[] {
|
||||
return (raw as Array<Record<string, unknown>>).map((entry) => ({
|
||||
threadId: entry.thread as string,
|
||||
subject: entry.subject as string,
|
||||
authors: entry.authors as string,
|
||||
totalMessages: entry.total as number,
|
||||
tags: entry.tags as string[],
|
||||
timestamp: entry.timestamp as number,
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseThreadMessages(raw: unknown[]): Message[] {
|
||||
const messages: Message[] = [];
|
||||
flattenThread(raw, messages);
|
||||
return messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
function flattenThread(node: unknown, messages: Message[]): void {
|
||||
if (!Array.isArray(node)) return;
|
||||
|
||||
for (const item of node) {
|
||||
if (isMessageNode(item)) {
|
||||
const headers = item.headers as Record<string, string>;
|
||||
const bodyParts = item.body as Array<Record<string, unknown>>;
|
||||
const plainPart = bodyParts.find((p) => p["content-type"] === "text/plain");
|
||||
|
||||
messages.push({
|
||||
messageId: item.id as string,
|
||||
from: headers.From ?? "",
|
||||
to: headers.To ?? "",
|
||||
subject: headers.Subject ?? "",
|
||||
date: headers.Date ?? "",
|
||||
inReplyTo: headers["In-Reply-To"] ?? "",
|
||||
references: headers.References ?? "",
|
||||
body: (plainPart?.content as string) ?? "",
|
||||
tags: item.tags as string[],
|
||||
timestamp: item.timestamp as number,
|
||||
});
|
||||
} else if (Array.isArray(item)) {
|
||||
flattenThread(item, messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMessageNode(item: unknown): item is Record<string, unknown> {
|
||||
return typeof item === "object" && item !== null && "id" in item && "headers" in item;
|
||||
}
|
||||
|
||||
export async function searchThreads(query: string): Promise<ThreadSummary[]> {
|
||||
const result = await runCommand("notmuch", ["search", "--format=json", query]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`notmuch search failed: ${result.stderr}`);
|
||||
}
|
||||
return parseSearchResults(JSON.parse(result.stdout));
|
||||
}
|
||||
|
||||
export async function getThread(threadId: string): Promise<Message[]> {
|
||||
const result = await runCommand("notmuch", ["show", "--format=json", `thread:${threadId}`]);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`notmuch show failed: ${result.stderr}`);
|
||||
}
|
||||
return parseThreadMessages(JSON.parse(result.stdout));
|
||||
}
|
||||
Reference in New Issue
Block a user