@divizend/scratch-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/basic/demo.ts +11 -0
- package/basic/index.ts +490 -0
- package/core/Auth.ts +63 -0
- package/core/Currency.ts +16 -0
- package/core/Env.ts +186 -0
- package/core/Fragment.ts +43 -0
- package/core/FragmentServingMode.ts +37 -0
- package/core/JsonSchemaValidator.ts +173 -0
- package/core/ProjectRoot.ts +76 -0
- package/core/Scratch.ts +44 -0
- package/core/URI.ts +203 -0
- package/core/Universe.ts +406 -0
- package/core/index.ts +27 -0
- package/gsuite/core/GSuite.ts +237 -0
- package/gsuite/core/GSuiteAdmin.ts +81 -0
- package/gsuite/core/GSuiteOrgConfig.ts +47 -0
- package/gsuite/core/GSuiteUser.ts +115 -0
- package/gsuite/core/index.ts +21 -0
- package/gsuite/documents/Document.ts +173 -0
- package/gsuite/documents/Documents.ts +52 -0
- package/gsuite/documents/index.ts +19 -0
- package/gsuite/drive/Drive.ts +118 -0
- package/gsuite/drive/DriveFile.ts +147 -0
- package/gsuite/drive/index.ts +19 -0
- package/gsuite/gmail/Gmail.ts +430 -0
- package/gsuite/gmail/GmailLabel.ts +55 -0
- package/gsuite/gmail/GmailMessage.ts +428 -0
- package/gsuite/gmail/GmailMessagePart.ts +298 -0
- package/gsuite/gmail/GmailThread.ts +97 -0
- package/gsuite/gmail/index.ts +5 -0
- package/gsuite/gmail/utils.ts +184 -0
- package/gsuite/index.ts +28 -0
- package/gsuite/spreadsheets/CellValue.ts +71 -0
- package/gsuite/spreadsheets/Sheet.ts +128 -0
- package/gsuite/spreadsheets/SheetValues.ts +12 -0
- package/gsuite/spreadsheets/Spreadsheet.ts +76 -0
- package/gsuite/spreadsheets/Spreadsheets.ts +52 -0
- package/gsuite/spreadsheets/index.ts +25 -0
- package/gsuite/spreadsheets/utils.ts +52 -0
- package/gsuite/utils.ts +104 -0
- package/http-server/HttpServer.ts +110 -0
- package/http-server/NativeHttpServer.ts +1084 -0
- package/http-server/index.ts +3 -0
- package/http-server/middlewares/01-cors.ts +33 -0
- package/http-server/middlewares/02-static.ts +67 -0
- package/http-server/middlewares/03-request-logger.ts +159 -0
- package/http-server/middlewares/04-body-parser.ts +54 -0
- package/http-server/middlewares/05-no-cache.ts +23 -0
- package/http-server/middlewares/06-response-handler.ts +39 -0
- package/http-server/middlewares/handler-wrapper.ts +250 -0
- package/http-server/middlewares/index.ts +37 -0
- package/http-server/middlewares/types.ts +27 -0
- package/index.ts +24 -0
- package/package.json +37 -0
- package/queue/EmailQueue.ts +228 -0
- package/queue/RateLimiter.ts +54 -0
- package/queue/index.ts +2 -0
- package/resend/Resend.ts +190 -0
- package/resend/index.ts +11 -0
- package/s2/S2.ts +335 -0
- package/s2/index.ts +11 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GmailMessage - Email Message Processing and Serving
|
|
3
|
+
*
|
|
4
|
+
* The GmailMessage class represents an individual email message and implements
|
|
5
|
+
* the Fragment interface to provide multiple serving modes. It handles email
|
|
6
|
+
* content processing, MIME parsing, and format conversion for different use cases.
|
|
7
|
+
*
|
|
8
|
+
* Key Features:
|
|
9
|
+
* - Multiple serving modes (original, markdown, JSON)
|
|
10
|
+
* - Intelligent content type selection for rendering
|
|
11
|
+
* - CID URL rewriting for embedded content
|
|
12
|
+
* - Attachment and part management
|
|
13
|
+
* - Efficient content fetching with caching
|
|
14
|
+
*
|
|
15
|
+
* The class implements RFC 2046 compliant MIME parsing and provides
|
|
16
|
+
* intelligent content selection for optimal rendering across different formats.
|
|
17
|
+
*
|
|
18
|
+
* @class GmailMessage
|
|
19
|
+
* @implements Fragment
|
|
20
|
+
* @version 1.0.0
|
|
21
|
+
* @author Divizend GmbH
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { gmail_v1 } from "googleapis";
|
|
25
|
+
import TurndownService from "turndown";
|
|
26
|
+
import {
|
|
27
|
+
Gmail,
|
|
28
|
+
GmailMessagePart,
|
|
29
|
+
GmailMessagePartData,
|
|
30
|
+
Fragment,
|
|
31
|
+
FragmentServingMode,
|
|
32
|
+
Universe,
|
|
33
|
+
URI,
|
|
34
|
+
GmailMessageURI,
|
|
35
|
+
} from "../..";
|
|
36
|
+
import { rewriteCidUrls } from "./utils";
|
|
37
|
+
|
|
38
|
+
export class GmailMessage implements Fragment {
|
|
39
|
+
/**
|
|
40
|
+
* Set of MIME types that can be rendered directly
|
|
41
|
+
*
|
|
42
|
+
* These types represent the primary content formats that
|
|
43
|
+
* can be displayed to users in a readable form.
|
|
44
|
+
*/
|
|
45
|
+
private static readonly RENDERABLE_TYPES = new Set([
|
|
46
|
+
"text/html",
|
|
47
|
+
"text/x-amp-html",
|
|
48
|
+
"text/plain",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a new GmailMessage instance
|
|
53
|
+
*
|
|
54
|
+
* @param gmail - Reference to the Gmail service instance
|
|
55
|
+
* @param message - Raw Gmail API message data
|
|
56
|
+
* @param isFull - Whether the message contains full content
|
|
57
|
+
*/
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly gmail: Gmail,
|
|
60
|
+
public readonly message: gmail_v1.Schema$Message,
|
|
61
|
+
public readonly isFull: boolean
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a GmailMessage from a URI
|
|
66
|
+
*
|
|
67
|
+
* This factory method parses the URI to extract the email address
|
|
68
|
+
* and message ID, then creates the appropriate Gmail instance
|
|
69
|
+
* to fetch the message.
|
|
70
|
+
*
|
|
71
|
+
* @param universe - Reference to the central Universe instance
|
|
72
|
+
* @param uri - URI identifying the message to retrieve
|
|
73
|
+
* @returns Promise<GmailMessage> - The requested message
|
|
74
|
+
*/
|
|
75
|
+
static async fromURI(universe: Universe, uri: URI): Promise<GmailMessage> {
|
|
76
|
+
const gmailMessageUri = GmailMessageURI.fromURI(uri);
|
|
77
|
+
const gmail = universe.gsuite.user(gmailMessageUri.email).gmail();
|
|
78
|
+
return GmailMessage.fromMessageId(gmail, gmailMessageUri.messageId);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates a GmailMessage from a message ID
|
|
83
|
+
*
|
|
84
|
+
* This factory method creates a prototype message and then
|
|
85
|
+
* fetches the full content from the Gmail API.
|
|
86
|
+
*
|
|
87
|
+
* @param gmail - Gmail service instance for the user
|
|
88
|
+
* @param messageId - Gmail message identifier
|
|
89
|
+
* @returns Promise<GmailMessage> - The full message
|
|
90
|
+
*/
|
|
91
|
+
static async fromMessageId(
|
|
92
|
+
gmail: Gmail,
|
|
93
|
+
messageId: string
|
|
94
|
+
): Promise<GmailMessage> {
|
|
95
|
+
const proto = new GmailMessage(gmail, { id: messageId }, false);
|
|
96
|
+
return proto.fetch();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Fetches the full message content from Gmail
|
|
101
|
+
*
|
|
102
|
+
* This method retrieves the complete message data including
|
|
103
|
+
* headers, body parts, and attachments. It's an expensive
|
|
104
|
+
* operation that should be used judiciously.
|
|
105
|
+
*
|
|
106
|
+
* @returns Promise<GmailMessage> - Message with full content
|
|
107
|
+
*/
|
|
108
|
+
async fetch(): Promise<GmailMessage> {
|
|
109
|
+
if (this.isFull) {
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new GmailMessage(
|
|
114
|
+
this.gmail,
|
|
115
|
+
(
|
|
116
|
+
await this.gmail.gmail.users.messages.get({
|
|
117
|
+
userId: "me",
|
|
118
|
+
id: this.message.id!,
|
|
119
|
+
format: "full",
|
|
120
|
+
})
|
|
121
|
+
).data,
|
|
122
|
+
true
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Gets the URI identifier for this message
|
|
128
|
+
*
|
|
129
|
+
* The URI follows the format: gmail://email/message/messageId
|
|
130
|
+
* and can be used to reference this message in other parts
|
|
131
|
+
* of the system.
|
|
132
|
+
*/
|
|
133
|
+
get uri() {
|
|
134
|
+
return `gmail://${this.gmail.email}/message/${this.message.id}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Gets the raw message data from the Gmail API
|
|
139
|
+
*
|
|
140
|
+
* This provides access to all the original message properties
|
|
141
|
+
* including headers, payload structure, and metadata.
|
|
142
|
+
*/
|
|
143
|
+
get data() {
|
|
144
|
+
return this.message;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Gets the subject line of the email
|
|
149
|
+
*
|
|
150
|
+
* Extracts the subject from the message headers, providing
|
|
151
|
+
* a convenient way to access this commonly used field.
|
|
152
|
+
*/
|
|
153
|
+
get subject() {
|
|
154
|
+
return this.message.payload?.headers?.find(
|
|
155
|
+
(h) => h.name!.toLowerCase() === "subject"
|
|
156
|
+
)?.value!;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Serves the message content in the specified format
|
|
161
|
+
*
|
|
162
|
+
* This method implements the Fragment interface and provides
|
|
163
|
+
* multiple serving modes optimized for different use cases:
|
|
164
|
+
* - ORIGINAL: Native format with CID URL rewriting
|
|
165
|
+
* - MARKDOWN: Converted to Markdown for easy reading
|
|
166
|
+
* - JSON: Structured metadata and content representation
|
|
167
|
+
*
|
|
168
|
+
* @param format - The desired serving mode
|
|
169
|
+
* @returns Promise containing headers and data for the served content
|
|
170
|
+
* @throws Error if the serving mode is not supported or content processing fails
|
|
171
|
+
*/
|
|
172
|
+
async serve(format: FragmentServingMode): Promise<{
|
|
173
|
+
headers: { name: string; value: string }[];
|
|
174
|
+
data: Buffer;
|
|
175
|
+
}> {
|
|
176
|
+
if (!this.isFull) {
|
|
177
|
+
throw new Error("Message is not full");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (format === FragmentServingMode.ORIGINAL) {
|
|
181
|
+
const renderable = this.chooseRenderable(this.message.payload!);
|
|
182
|
+
if (!renderable) {
|
|
183
|
+
throw new Error("No renderable part found");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const renderablePart = await GmailMessagePart.fromMessageAndPart(
|
|
187
|
+
this.gmail,
|
|
188
|
+
this,
|
|
189
|
+
renderable
|
|
190
|
+
);
|
|
191
|
+
const ret = await renderablePart.serve(format);
|
|
192
|
+
|
|
193
|
+
const chosenType = (renderablePart.part.mimeType || "").toLowerCase();
|
|
194
|
+
if (chosenType === "text/html" || chosenType === "text/x-amp-html") {
|
|
195
|
+
const text = await renderablePart.asText();
|
|
196
|
+
const cidMap = this.buildCidMap(this.message.payload!);
|
|
197
|
+
let html = rewriteCidUrls(text, this.uri, cidMap);
|
|
198
|
+
ret.data = Buffer.from(html);
|
|
199
|
+
ret.headers = ret.headers.filter(
|
|
200
|
+
(h) => h.name.toLowerCase() !== "content-type"
|
|
201
|
+
);
|
|
202
|
+
ret.headers.push({
|
|
203
|
+
name: "Content-Type",
|
|
204
|
+
value: "text/html; charset=utf-8",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return ret;
|
|
209
|
+
} else if (format === FragmentServingMode.MARKDOWN) {
|
|
210
|
+
const original = await this.serve(FragmentServingMode.ORIGINAL);
|
|
211
|
+
const contentType = original.headers.find(
|
|
212
|
+
(h) => h.name.toLowerCase() === "content-type"
|
|
213
|
+
)?.value;
|
|
214
|
+
|
|
215
|
+
let markdown = original.data.toString();
|
|
216
|
+
if (contentType?.includes("text/html")) {
|
|
217
|
+
const turndownService = new TurndownService();
|
|
218
|
+
turndownService.remove(["script", "style"]);
|
|
219
|
+
markdown = turndownService.turndown(markdown).trim();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
headers: [
|
|
224
|
+
{ name: "Content-Type", value: "text/markdown; charset=utf-8" },
|
|
225
|
+
],
|
|
226
|
+
data: Buffer.from(markdown),
|
|
227
|
+
};
|
|
228
|
+
} else if (format === FragmentServingMode.JSON) {
|
|
229
|
+
return {
|
|
230
|
+
headers: [{ name: "Content-Type", value: "application/json" }],
|
|
231
|
+
data: Buffer.from(
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
message: this.message,
|
|
234
|
+
cidMap: this.partIdsToURIs(this.buildCidMap(this.message.payload!)),
|
|
235
|
+
attachments: this.partIdsToURIs(
|
|
236
|
+
this.buildAttachmentsMap(this.message.payload!)
|
|
237
|
+
),
|
|
238
|
+
})
|
|
239
|
+
),
|
|
240
|
+
};
|
|
241
|
+
} else {
|
|
242
|
+
throw new Error(`Unknown serving mode: ${format}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Choose the best renderable body part per RFC 2046.
|
|
248
|
+
* - For multipart/alternative -> scan from LAST to FIRST and pick first we can render.
|
|
249
|
+
* - For multipart/related -> the FIRST part is the root; recurse into it.
|
|
250
|
+
* - For multipart/mixed -> collect renderable candidates and pick "best" (html > plain).
|
|
251
|
+
* - For multipart/signed -> the FIRST part is the content.
|
|
252
|
+
* - For message/rfc822 -> recurse into its enclosed structure if present (Gmail exposes as parts).
|
|
253
|
+
* Returns the chosen message part (not yet decoded).
|
|
254
|
+
*/
|
|
255
|
+
private chooseRenderable(
|
|
256
|
+
part: gmail_v1.Schema$MessagePart
|
|
257
|
+
): gmail_v1.Schema$MessagePart | null {
|
|
258
|
+
const type = (part.mimeType || "").toLowerCase();
|
|
259
|
+
|
|
260
|
+
if (GmailMessage.RENDERABLE_TYPES.has(type)) return part;
|
|
261
|
+
|
|
262
|
+
if (type.startsWith("multipart/alternative")) {
|
|
263
|
+
const ps = part.parts || [];
|
|
264
|
+
for (let i = ps.length - 1; i >= 0; i--) {
|
|
265
|
+
const cand = this.chooseRenderable(ps[i]!);
|
|
266
|
+
if (cand) return cand;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (type.startsWith("multipart/related")) {
|
|
272
|
+
const ps = part.parts || [];
|
|
273
|
+
if (ps.length === 0) return null;
|
|
274
|
+
// RFC: first part is the root (typically html or alternative)
|
|
275
|
+
return this.chooseRenderable(ps[0]!) || null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (type.startsWith("multipart/signed")) {
|
|
279
|
+
const ps = part.parts || [];
|
|
280
|
+
if (ps.length === 0) return null;
|
|
281
|
+
// first part is the signed content
|
|
282
|
+
return this.chooseRenderable(ps[0]!) || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (type.startsWith("multipart/mixed")) {
|
|
286
|
+
const ps = part.parts || [];
|
|
287
|
+
const cands: gmail_v1.Schema$MessagePart[] = [];
|
|
288
|
+
for (const p of ps) {
|
|
289
|
+
const c = this.chooseRenderable(p);
|
|
290
|
+
if (c) cands.push(c);
|
|
291
|
+
}
|
|
292
|
+
// prefer html > amp-html > plain
|
|
293
|
+
const score = (t: string) =>
|
|
294
|
+
t === "text/html"
|
|
295
|
+
? 3
|
|
296
|
+
: t === "text/x-amp-html"
|
|
297
|
+
? 2
|
|
298
|
+
: t === "text/plain"
|
|
299
|
+
? 1
|
|
300
|
+
: 0;
|
|
301
|
+
let best: gmail_v1.Schema$MessagePart | null = null;
|
|
302
|
+
let bestScore = 0;
|
|
303
|
+
for (const c of cands) {
|
|
304
|
+
const s = score((c.mimeType || "").toLowerCase());
|
|
305
|
+
if (s > bestScore) {
|
|
306
|
+
best = c;
|
|
307
|
+
bestScore = s;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return best;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (type === "message/rfc822") {
|
|
314
|
+
// Gmail often exposes enclosed message under .parts as a mini-tree
|
|
315
|
+
const ps = part.parts || [];
|
|
316
|
+
for (const p of ps) {
|
|
317
|
+
const cand = this.chooseRenderable(p);
|
|
318
|
+
if (cand) return cand;
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Other types are not directly renderable here
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Generic method to traverse all parts of a message recursively.
|
|
329
|
+
* Calls the provided callback for each part encountered.
|
|
330
|
+
*
|
|
331
|
+
* @param root - The root message part to start traversal from
|
|
332
|
+
* @param callback - Function called for each part with the part and a helper GmailMessagePart instance
|
|
333
|
+
*/
|
|
334
|
+
private traverseParts(
|
|
335
|
+
root: gmail_v1.Schema$MessagePart,
|
|
336
|
+
callback: (
|
|
337
|
+
part: gmail_v1.Schema$MessagePart,
|
|
338
|
+
partHelper: GmailMessagePart
|
|
339
|
+
) => void
|
|
340
|
+
) {
|
|
341
|
+
const stack: gmail_v1.Schema$MessagePart[] = [root];
|
|
342
|
+
|
|
343
|
+
while (stack.length) {
|
|
344
|
+
const part = stack.pop()!;
|
|
345
|
+
const partHelper = new GmailMessagePart(
|
|
346
|
+
this.gmail,
|
|
347
|
+
part as GmailMessagePartData,
|
|
348
|
+
this.message.id!,
|
|
349
|
+
false
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
callback(part, partHelper);
|
|
353
|
+
|
|
354
|
+
if (part.parts?.length) {
|
|
355
|
+
stack.push(...part.parts);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private buildCidMap(
|
|
361
|
+
root: gmail_v1.Schema$MessagePart,
|
|
362
|
+
map: { [key: string]: string } = {}
|
|
363
|
+
) {
|
|
364
|
+
this.traverseParts(root, (part, partHelper) => {
|
|
365
|
+
const cidRaw = partHelper.getHeader("content-id");
|
|
366
|
+
if (cidRaw && part.partId) {
|
|
367
|
+
const cid = cidRaw.trim().replace(/^<|>$/g, "").toLowerCase();
|
|
368
|
+
if (!map[cid]) map[cid] = part.partId;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
return map;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private partIdsToURIs(partsMap: { [key: string]: string }) {
|
|
375
|
+
return Object.fromEntries(
|
|
376
|
+
Object.entries(partsMap).map(([cid, partId]) => [
|
|
377
|
+
cid,
|
|
378
|
+
`gmail://${this.gmail.email}/message/${this.message.id}/part/${partId}`,
|
|
379
|
+
])
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Build a map of "regular" attachments (as shown in Gmail's UI).
|
|
385
|
+
* We consider a part a regular attachment if:
|
|
386
|
+
* - it is NOT a multipart container,
|
|
387
|
+
* - it has a non-empty filename,
|
|
388
|
+
* - it has a body.attachmentId (i.e., is a downloadable blob),
|
|
389
|
+
* - and its Content-Disposition is not "inline".
|
|
390
|
+
*
|
|
391
|
+
* Returns a Map where:
|
|
392
|
+
* key = attachment filename
|
|
393
|
+
* value = partId (use with users.messages.attachments.get)
|
|
394
|
+
*/
|
|
395
|
+
private buildAttachmentsMap(
|
|
396
|
+
root: gmail_v1.Schema$MessagePart,
|
|
397
|
+
map: { [key: string]: string } = {}
|
|
398
|
+
) {
|
|
399
|
+
this.traverseParts(root, (part, partHelper) => {
|
|
400
|
+
const type = (part.mimeType || "").toLowerCase();
|
|
401
|
+
const isMultipart = type.startsWith("multipart/");
|
|
402
|
+
|
|
403
|
+
const disposition = (
|
|
404
|
+
partHelper.getHeader("content-disposition") || ""
|
|
405
|
+
).toLowerCase();
|
|
406
|
+
const isInline = disposition.startsWith("inline");
|
|
407
|
+
|
|
408
|
+
const filename = (part.filename || "").trim();
|
|
409
|
+
const hasAttachmentId = !!part.body?.attachmentId;
|
|
410
|
+
const isSignature = type.startsWith("application/pkcs7-signature");
|
|
411
|
+
|
|
412
|
+
if (
|
|
413
|
+
!isMultipart &&
|
|
414
|
+
filename &&
|
|
415
|
+
hasAttachmentId &&
|
|
416
|
+
!isInline &&
|
|
417
|
+
part.partId &&
|
|
418
|
+
!isSignature
|
|
419
|
+
) {
|
|
420
|
+
if (!map[filename]) {
|
|
421
|
+
map[filename] = part.partId;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
return map;
|
|
427
|
+
}
|
|
428
|
+
}
|