thunderbird-patch-review
Easy-to-use patch review interface for Thunderbird
Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht
git clone git://mccd.space/thunderbird-patch-review
| Log | Files | Refs | README | LICENSE |
series.js (5863B)
1 // Collect a full patch series from one displayed message: find the sibling
2 // patches in the same folder, order them, and fetch their plain-text bodies.
3
4 import { parseSubject, sameSeries, bodyLooksLikeDiff, isReplySubject } from "./patch-detect.js";
5
6 /** Extract the text/plain body from a messages.getFull() part tree. */
7 export function plainBodyFromPart(part) {
8 if (!part) {
9 return "";
10 }
11 const contentType = (part.contentType || "").toLowerCase();
12 if (contentType.startsWith("text/plain") && typeof part.body === "string") {
13 return part.body;
14 }
15 for (const sub of part.parts || []) {
16 const body = plainBodyFromPart(sub);
17 if (body) {
18 return body;
19 }
20 }
21 // Fall back to any textual body at all (some senders use text/x-patch).
22 if (typeof part.body === "string" && contentType.startsWith("text/")) {
23 return part.body;
24 }
25 return "";
26 }
27
28 export async function getPlainBody(messageId) {
29 const full = await messenger.messages.getFull(messageId);
30 return plainBodyFromPart(full);
31 }
32
33 /** Query all messages in the anchor's folder within a date window, following pagination. */
34 async function listFolderMessages(anchor, windowDays) {
35 const from = new Date(anchor.date.getTime() - windowDays * 86400e3);
36 const to = new Date(anchor.date.getTime() + windowDays * 86400e3);
37 const base = { fromDate: from, toDate: to, subject: "PATCH" };
38
39 let page;
40 try {
41 // TB 121+ addressing; falls back to the MailFolder object on TB 115.
42 page = await messenger.messages.query({ ...base, folderId: anchor.folder.id });
43 } catch (e) {
44 page = await messenger.messages.query({ ...base, folder: anchor.folder });
45 }
46
47 const out = [...page.messages];
48 while (page.id) {
49 page = await messenger.messages.continueList(page.id);
50 out.push(...page.messages);
51 }
52 return out;
53 }
54
55 /**
56 * Collapse a list of same-series entries to one per series index.
57 *
58 * Each entry is { header, info, body, hasDiff } (only header.date,
59 * header.headerMessageId, info.n and hasDiff are read here).
60 *
61 * For a real patch slot (n > 0) a body that carries a diff always beats one
62 * that does not — a reply that shares the subject (some mailing lists strip
63 * the "Re:" prefix) would otherwise win by date and surface as
64 * "No diff found in this message". With no diff tiebreak, the more recent
65 * message wins (handles resends of the same index).
66 */
67 export function dedupByIndex(entries) {
68 const byIndex = new Map();
69 for (const entry of entries) {
70 const n = entry.info ? entry.info.n : null;
71 const key = n === null ? `id:${entry.header.headerMessageId}` : n;
72 const prev = byIndex.get(key);
73 if (!prev) {
74 byIndex.set(key, entry);
75 continue;
76 }
77 if (n !== null && n > 0 && entry.hasDiff !== prev.hasDiff) {
78 byIndex.set(key, entry.hasDiff ? entry : prev);
79 } else {
80 byIndex.set(key, entry.header.date > prev.header.date ? entry : prev);
81 }
82 }
83 return byIndex;
84 }
85
86 /**
87 * Build the ordered series for the given message header.
88 * @returns {Promise<Array<{
89 * id: number, headerMessageId: string, subject: string, author: string,
90 * date: number, n: number|null, m: number|null, isCover: boolean,
91 * title: string, body: string, hasDiff: boolean
92 * }>>} ordered patches (cover letter first when present)
93 */
94 export async function collectSeries(anchor) {
95 const anchorInfo = parseSubject(anchor.subject);
96 const members = new Map(); // headerMessageId -> {header, info}
97
98 // Replies (Re:/Aw:/Fwd:) to a patch are not part of the series: skip the
99 // anchor itself and every folder candidate that is a reply. The subject
100 // tag still matches through the prefix, so we filter on the prefix here.
101 if (!isReplySubject(anchor.subject)) {
102 members.set(anchor.headerMessageId, { header: anchor, info: anchorInfo });
103 }
104
105 if (anchorInfo && anchorInfo.m !== null) {
106 let candidates = [];
107 try {
108 candidates = await listFolderMessages(anchor, 30);
109 } catch (e) {
110 console.warn("patch-review: folder query failed, reviewing single message", e);
111 }
112 for (const header of candidates) {
113 if (isReplySubject(header.subject)) {
114 continue;
115 }
116 const info = parseSubject(header.subject);
117 if (!sameSeries(anchorInfo, info)) {
118 continue;
119 }
120 const existing = members.get(header.headerMessageId);
121 if (!existing) {
122 members.set(header.headerMessageId, { header, info });
123 }
124 }
125 }
126
127 // Fetch bodies up front so the per-index dedup can prefer the message
128 // that actually carries a diff. A reply that shares the subject (some
129 // mailing lists strip the "Re:" prefix, and isReplySubject only catches
130 // the ones that keep it) would otherwise win the slot by date and show up
131 // as "No diff found in this message".
132 const entries = [];
133 for (const { header, info } of members.values()) {
134 const body = await getPlainBody(header.id);
135 entries.push({ header, info, body, hasDiff: bodyLooksLikeDiff(body) });
136 }
137
138 // Keep one message per series index (a resend may duplicate an index).
139 const byIndex = dedupByIndex(entries);
140
141 const ordered = [...byIndex.values()].sort((a, b) => {
142 const an = a.info && a.info.n !== null ? a.info.n : 0;
143 const bn = b.info && b.info.n !== null ? b.info.n : 0;
144 return an - bn || a.header.date - b.header.date;
145 });
146
147 const series = [];
148 for (const { header, info, body, hasDiff } of ordered) {
149 series.push({
150 id: header.id,
151 headerMessageId: header.headerMessageId,
152 subject: header.subject,
153 author: header.author,
154 date: header.date.getTime ? header.date.getTime() : header.date,
155 n: info ? info.n : null,
156 m: info ? info.m : null,
157 isCover: Boolean(info && info.n === 0),
158 title: info ? info.title : header.subject,
159 body,
160 hasDiff,
161 });
162 }
163 return series;
164 }