@hasna/connectors 1.3.22 → 1.3.23
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/bin/index.js +109 -59
- package/bin/mcp.js +236 -182
- package/bin/serve.js +77 -33
- package/connectors/connect-dropbox/src/api/bulk.ts +285 -0
- package/connectors/connect-dropbox/src/api/index.ts +4 -0
- package/connectors/connect-dropbox/src/cli/index.ts +151 -0
- package/connectors/connect-github/src/api/bulk.ts +328 -0
- package/connectors/connect-github/src/api/index.ts +4 -0
- package/connectors/connect-github/src/cli/index.ts +145 -0
- package/connectors/connect-gmail/src/api/history.ts +82 -0
- package/connectors/connect-gmail/src/api/index.ts +12 -0
- package/connectors/connect-gmail/src/api/settings.ts +280 -0
- package/connectors/connect-gmail/src/api/watch.ts +40 -0
- package/connectors/connect-gmail/src/cli/index.ts +237 -0
- package/connectors/connect-googleads/src/api/bulk.ts +354 -0
- package/connectors/connect-googleads/src/api/index.ts +4 -0
- package/connectors/connect-googleads/src/cli/index.ts +192 -0
- package/connectors/connect-googlecalendar/src/api/acl.ts +66 -0
- package/connectors/connect-googlecalendar/src/api/bulk.ts +452 -0
- package/connectors/connect-googlecalendar/src/api/freebusy.ts +35 -0
- package/connectors/connect-googlecalendar/src/api/index.ts +9 -0
- package/connectors/connect-googlecalendar/src/cli/index.ts +522 -0
- package/connectors/connect-googlecalendar/src/types/index.ts +90 -0
- package/connectors/connect-googlecontacts/src/api/bulk.ts +365 -0
- package/connectors/connect-googlecontacts/src/api/groups.ts +164 -0
- package/connectors/connect-googlecontacts/src/api/index.ts +8 -0
- package/connectors/connect-googlecontacts/src/cli/index.ts +340 -0
- package/connectors/connect-googledocs/src/api/bulk.ts +301 -0
- package/connectors/connect-googledocs/src/api/index.ts +4 -0
- package/connectors/connect-googledocs/src/cli/index.ts +187 -0
- package/connectors/connect-googledrive/src/api/bulk.ts +505 -0
- package/connectors/connect-googledrive/src/api/changes.ts +139 -0
- package/connectors/connect-googledrive/src/api/client.ts +34 -0
- package/connectors/connect-googledrive/src/api/index.ts +12 -0
- package/connectors/connect-googledrive/src/api/revisions.ts +132 -0
- package/connectors/connect-googledrive/src/cli/index.ts +624 -0
- package/connectors/connect-googledrive/src/index.ts +2 -0
- package/connectors/connect-googlephotos/src/api/bulk.ts +455 -0
- package/connectors/connect-googlephotos/src/api/index.ts +8 -0
- package/connectors/connect-googlephotos/src/cli/index.ts +185 -0
- package/connectors/connect-googletasks/src/api/bulk.ts +359 -0
- package/connectors/connect-googletasks/src/api/index.ts +1 -0
- package/connectors/connect-googletasks/src/cli/index.ts +178 -0
- package/connectors/connect-linear/src/api/bulk.ts +219 -0
- package/connectors/connect-linear/src/api/index.ts +4 -0
- package/connectors/connect-linear/src/cli/index.ts +116 -0
- package/connectors/connect-shopify/src/api/bulk.ts +288 -0
- package/connectors/connect-shopify/src/api/index.ts +4 -0
- package/connectors/connect-shopify/src/cli/index.ts +108 -0
- package/connectors/connect-slack/src/api/bulk.ts +237 -0
- package/connectors/connect-slack/src/api/index.ts +4 -0
- package/connectors/connect-slack/src/cli/index.ts +157 -0
- package/connectors/connect-stripe/src/api/bulk.ts +488 -0
- package/connectors/connect-stripe/src/api/index.ts +4 -0
- package/connectors/connect-stripe/src/cli/index.ts +206 -0
- package/dist/index.js +75 -33
- package/dist/lib/runner.d.ts +15 -5
- package/package.json +2 -2
- package/connectors/connect-cloudflare/bun.lock +0 -32
- package/connectors/connect-gmail/bin/index.js +0 -7027
- package/connectors/connect-gmail/dist/cli/index.js +0 -7035
- package/connectors/connect-gmail/dist/index.js +0 -2174
- package/dist/server/server.test.d.ts +0 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import type { PhotosClient } from './client';
|
|
2
|
+
import type { MediaItem, MediaItemsListResponse, Album } from '../types';
|
|
3
|
+
|
|
4
|
+
// ============================================
|
|
5
|
+
// Bulk Operation Types
|
|
6
|
+
// ============================================
|
|
7
|
+
|
|
8
|
+
export interface BulkOperationOptions {
|
|
9
|
+
/** Filter by album ID */
|
|
10
|
+
albumId?: string;
|
|
11
|
+
/** Filter by media type (PHOTO, VIDEO) */
|
|
12
|
+
mediaType?: 'PHOTO' | 'VIDEO';
|
|
13
|
+
/** Include archived media */
|
|
14
|
+
includeArchived?: boolean;
|
|
15
|
+
/** Only favorites */
|
|
16
|
+
favoritesOnly?: boolean;
|
|
17
|
+
/** Maximum items to process (default: 100) */
|
|
18
|
+
maxResults?: number;
|
|
19
|
+
/** Maximum concurrent API calls (default: 10) */
|
|
20
|
+
concurrency?: number;
|
|
21
|
+
/** Dry run - don't actually modify */
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
/** Progress callback */
|
|
24
|
+
onProgress?: (current: number, total: number, item: MediaSummary) => void;
|
|
25
|
+
/** Error callback */
|
|
26
|
+
onError?: (error: Error, item: MediaSummary) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface BulkAddToAlbumOptions extends BulkOperationOptions {
|
|
30
|
+
/** Album ID to add items to */
|
|
31
|
+
targetAlbumId: string;
|
|
32
|
+
/** Media item IDs to add (or use filters above to discover) */
|
|
33
|
+
mediaItemIds?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface BulkRemoveFromAlbumOptions {
|
|
37
|
+
/** Album ID to remove items from */
|
|
38
|
+
albumId: string;
|
|
39
|
+
/** Media item IDs to remove (or use filters to discover) */
|
|
40
|
+
mediaItemIds?: string[];
|
|
41
|
+
/** Filter options if mediaItemIds not provided */
|
|
42
|
+
albumId?: string;
|
|
43
|
+
mediaType?: 'PHOTO' | 'VIDEO';
|
|
44
|
+
maxResults?: number;
|
|
45
|
+
concurrency?: number;
|
|
46
|
+
dryRun?: boolean;
|
|
47
|
+
onProgress?: (current: number, total: number, item: MediaSummary) => void;
|
|
48
|
+
onError?: (error: Error, item: MediaSummary) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BulkCreateAlbumsOptions {
|
|
52
|
+
/** Album titles to create */
|
|
53
|
+
titles: string[];
|
|
54
|
+
concurrency?: number;
|
|
55
|
+
dryRun?: boolean;
|
|
56
|
+
onProgress?: (current: number, total: number, title: string) => void;
|
|
57
|
+
onError?: (error: Error, title: string) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MediaSummary {
|
|
61
|
+
id: string;
|
|
62
|
+
filename: string;
|
|
63
|
+
mimeType: string;
|
|
64
|
+
created: string;
|
|
65
|
+
productUrl: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface BulkOperationResult {
|
|
69
|
+
total: number;
|
|
70
|
+
success: number;
|
|
71
|
+
failed: number;
|
|
72
|
+
skipped: number;
|
|
73
|
+
errors: Array<{ mediaId: string; error: string }>;
|
|
74
|
+
processedItems: MediaSummary[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PreviewResult {
|
|
78
|
+
items: MediaSummary[];
|
|
79
|
+
total: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface BulkCreateAlbumsResult {
|
|
83
|
+
total: number;
|
|
84
|
+
success: number;
|
|
85
|
+
failed: number;
|
|
86
|
+
errors: Array<{ title: string; error: string }>;
|
|
87
|
+
createdAlbums: Album[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================
|
|
91
|
+
// Bulk Operations API
|
|
92
|
+
// ============================================
|
|
93
|
+
|
|
94
|
+
export class BulkApi {
|
|
95
|
+
private readonly client: PhotosClient;
|
|
96
|
+
|
|
97
|
+
constructor(client: PhotosClient) {
|
|
98
|
+
this.client = client;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================
|
|
102
|
+
// Preview
|
|
103
|
+
// ============================================
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Preview media items matching filters
|
|
107
|
+
*/
|
|
108
|
+
async preview(options?: {
|
|
109
|
+
albumId?: string;
|
|
110
|
+
mediaType?: 'PHOTO' | 'VIDEO';
|
|
111
|
+
favoritesOnly?: boolean;
|
|
112
|
+
maxResults?: number;
|
|
113
|
+
}): Promise<PreviewResult> {
|
|
114
|
+
const items = await this.fetchMediaItems({
|
|
115
|
+
albumId: options?.albumId,
|
|
116
|
+
mediaType: options?.mediaType,
|
|
117
|
+
favoritesOnly: options?.favoritesOnly,
|
|
118
|
+
maxResults: options?.maxResults || 50,
|
|
119
|
+
});
|
|
120
|
+
return { items, total: items.length };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================
|
|
124
|
+
// Delete (Archive)
|
|
125
|
+
// ============================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Note: Google Photos API doesn't support direct delete.
|
|
129
|
+
* This archives media items by moving them out of albums.
|
|
130
|
+
* For true deletion, users must use the Google Photos UI.
|
|
131
|
+
*/
|
|
132
|
+
async removeFromAlbums(options: {
|
|
133
|
+
albumIds: string[];
|
|
134
|
+
mediaItemIds?: string[];
|
|
135
|
+
maxResults?: number;
|
|
136
|
+
concurrency?: number;
|
|
137
|
+
dryRun?: boolean;
|
|
138
|
+
onProgress?: (current: number, total: number, item: MediaSummary) => void;
|
|
139
|
+
onError?: (error: Error, item: MediaSummary) => void;
|
|
140
|
+
}): Promise<BulkOperationResult> {
|
|
141
|
+
const { albumIds, mediaItemIds, ...rest } = options;
|
|
142
|
+
|
|
143
|
+
let items: MediaSummary[];
|
|
144
|
+
if (mediaItemIds && mediaItemIds.length > 0) {
|
|
145
|
+
items = mediaItemIds.map(id => ({
|
|
146
|
+
id,
|
|
147
|
+
filename: id,
|
|
148
|
+
mimeType: 'unknown',
|
|
149
|
+
created: '',
|
|
150
|
+
productUrl: '',
|
|
151
|
+
}));
|
|
152
|
+
} else {
|
|
153
|
+
items = await this.fetchMediaItems({
|
|
154
|
+
albumId: albumIds[0],
|
|
155
|
+
maxResults: rest.maxResults || 100,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return this.executeBatch(items, {
|
|
160
|
+
dryRun: rest.dryRun || false,
|
|
161
|
+
concurrency: rest.concurrency || 10,
|
|
162
|
+
onProgress: rest.onProgress,
|
|
163
|
+
onError: rest.onError,
|
|
164
|
+
operation: async (item) => {
|
|
165
|
+
for (const albumId of albumIds) {
|
|
166
|
+
await this.client.post<void>(`/albums/${albumId}:batchRemoveMediaItems`, {
|
|
167
|
+
mediaItemIds: [item.id],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ============================================
|
|
175
|
+
// Add to Album
|
|
176
|
+
// ============================================
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Bulk add media items to an album
|
|
180
|
+
*/
|
|
181
|
+
async addToAlbum(options: BulkAddToAlbumOptions): Promise<BulkOperationResult> {
|
|
182
|
+
let itemIds = options.mediaItemIds;
|
|
183
|
+
|
|
184
|
+
if (!itemIds || itemIds.length === 0) {
|
|
185
|
+
const items = await this.fetchMediaItems({
|
|
186
|
+
albumId: options.albumId,
|
|
187
|
+
mediaType: options.mediaType,
|
|
188
|
+
favoritesOnly: options.favoritesOnly,
|
|
189
|
+
includeArchived: options.includeArchived,
|
|
190
|
+
maxResults: options.maxResults || 100,
|
|
191
|
+
});
|
|
192
|
+
itemIds = items.map(i => i.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const summaries = itemIds.map(id => ({
|
|
196
|
+
id,
|
|
197
|
+
filename: id,
|
|
198
|
+
mimeType: 'unknown',
|
|
199
|
+
created: '',
|
|
200
|
+
productUrl: '',
|
|
201
|
+
}));
|
|
202
|
+
|
|
203
|
+
return this.executeBatch(summaries, {
|
|
204
|
+
dryRun: options.dryRun || false,
|
|
205
|
+
concurrency: options.concurrency || 10,
|
|
206
|
+
onProgress: options.onProgress,
|
|
207
|
+
onError: options.onError,
|
|
208
|
+
operation: async (item) => {
|
|
209
|
+
await this.client.post<void>(`/albums/${options.targetAlbumId}:batchAddMediaItems`, {
|
|
210
|
+
mediaItemIds: [item.id],
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ============================================
|
|
217
|
+
// Remove from Album
|
|
218
|
+
// ============================================
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bulk remove media items from an album
|
|
222
|
+
*/
|
|
223
|
+
async removeFromAlbum(options: {
|
|
224
|
+
albumId: string;
|
|
225
|
+
mediaItemIds?: string[];
|
|
226
|
+
maxResults?: number;
|
|
227
|
+
concurrency?: number;
|
|
228
|
+
dryRun?: boolean;
|
|
229
|
+
onProgress?: (current: number, total: number, item: MediaSummary) => void;
|
|
230
|
+
onError?: (error: Error, item: MediaSummary) => void;
|
|
231
|
+
}): Promise<BulkOperationResult> {
|
|
232
|
+
let itemIds = options.mediaItemIds;
|
|
233
|
+
|
|
234
|
+
if (!itemIds || itemIds.length === 0) {
|
|
235
|
+
const items = await this.fetchMediaItems({
|
|
236
|
+
albumId: options.albumId,
|
|
237
|
+
maxResults: options.maxResults || 100,
|
|
238
|
+
});
|
|
239
|
+
itemIds = items.map(i => i.id);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const summaries = itemIds.map(id => ({
|
|
243
|
+
id,
|
|
244
|
+
filename: id,
|
|
245
|
+
mimeType: 'unknown',
|
|
246
|
+
created: '',
|
|
247
|
+
productUrl: '',
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
return this.executeBatch(summaries, {
|
|
251
|
+
dryRun: options.dryRun || false,
|
|
252
|
+
concurrency: options.concurrency || 10,
|
|
253
|
+
onProgress: options.onProgress,
|
|
254
|
+
onError: options.onError,
|
|
255
|
+
operation: async (item) => {
|
|
256
|
+
await this.client.post<void>(`/albums/${options.albumId}:batchRemoveMediaItems`, {
|
|
257
|
+
mediaItemIds: [item.id],
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ============================================
|
|
264
|
+
// Create Albums
|
|
265
|
+
// ============================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Bulk create albums from a list of titles
|
|
269
|
+
*/
|
|
270
|
+
async createAlbums(options: BulkCreateAlbumsOptions): Promise<BulkCreateAlbumsResult> {
|
|
271
|
+
const { titles, concurrency = 10, dryRun = false, onProgress, onError } = options;
|
|
272
|
+
|
|
273
|
+
const result: BulkCreateAlbumsResult = {
|
|
274
|
+
total: titles.length,
|
|
275
|
+
success: 0,
|
|
276
|
+
failed: 0,
|
|
277
|
+
errors: [],
|
|
278
|
+
createdAlbums: [],
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (titles.length === 0) return result;
|
|
282
|
+
|
|
283
|
+
const chunks = this.chunkArray(titles, concurrency);
|
|
284
|
+
|
|
285
|
+
for (const chunk of chunks) {
|
|
286
|
+
await Promise.all(
|
|
287
|
+
chunk.map(async (title) => {
|
|
288
|
+
try {
|
|
289
|
+
if (dryRun) {
|
|
290
|
+
result.success++;
|
|
291
|
+
} else {
|
|
292
|
+
const album = await this.client.post<Album>('/albums', { album: { title } });
|
|
293
|
+
result.success++;
|
|
294
|
+
result.createdAlbums.push(album);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (onProgress) onProgress(result.success + result.failed, result.total, title);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
result.failed++;
|
|
300
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
301
|
+
result.errors.push({ title, error: errorMessage });
|
|
302
|
+
if (onError) onError(err instanceof Error ? err : new Error(errorMessage), title);
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ============================================
|
|
312
|
+
// Favorite / Unfavorite
|
|
313
|
+
// ============================================
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Note: Google Photos API doesn't support setting favorite status via API.
|
|
317
|
+
* This is read-only through the featureFilter. Listed for completeness.
|
|
318
|
+
*/
|
|
319
|
+
async listFavorites(options?: { maxResults?: number }): Promise<PreviewResult> {
|
|
320
|
+
const items = await this.fetchMediaItems({
|
|
321
|
+
favoritesOnly: true,
|
|
322
|
+
maxResults: options?.maxResults || 100,
|
|
323
|
+
});
|
|
324
|
+
return { items, total: items.length };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ============================================
|
|
328
|
+
// Helper Methods
|
|
329
|
+
// ============================================
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Fetch media items matching filters
|
|
333
|
+
*/
|
|
334
|
+
async fetchMediaItems(params: {
|
|
335
|
+
albumId?: string;
|
|
336
|
+
mediaType?: 'PHOTO' | 'VIDEO';
|
|
337
|
+
favoritesOnly?: boolean;
|
|
338
|
+
includeArchived?: boolean;
|
|
339
|
+
maxResults?: number;
|
|
340
|
+
}): Promise<MediaSummary[]> {
|
|
341
|
+
const items: MediaSummary[] = [];
|
|
342
|
+
let pageToken: string | undefined;
|
|
343
|
+
const max = params.maxResults || 100;
|
|
344
|
+
|
|
345
|
+
while (items.length < max) {
|
|
346
|
+
const body: Record<string, unknown> = {
|
|
347
|
+
pageSize: Math.min(100, max - items.length),
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
if (params.albumId) {
|
|
351
|
+
body.albumId = params.albumId;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const filters: Record<string, unknown> = {};
|
|
355
|
+
if (params.mediaType) {
|
|
356
|
+
filters.mediaTypeFilter = { mediaTypes: [params.mediaType] };
|
|
357
|
+
}
|
|
358
|
+
if (params.favoritesOnly) {
|
|
359
|
+
filters.featureFilter = { includedFeatures: ['FAVORITES'] };
|
|
360
|
+
}
|
|
361
|
+
if (params.includeArchived) {
|
|
362
|
+
filters.includeArchivedMedia = true;
|
|
363
|
+
}
|
|
364
|
+
if (Object.keys(filters).length > 0) {
|
|
365
|
+
body.filters = filters;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (pageToken) {
|
|
369
|
+
body.pageToken = pageToken;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const response = await this.client.post<MediaItemsListResponse>('/mediaItems:search', body);
|
|
373
|
+
|
|
374
|
+
if (!response.mediaItems || response.mediaItems.length === 0) break;
|
|
375
|
+
|
|
376
|
+
for (const item of response.mediaItems) {
|
|
377
|
+
items.push({
|
|
378
|
+
id: item.id,
|
|
379
|
+
filename: item.filename,
|
|
380
|
+
mimeType: item.mimeType,
|
|
381
|
+
created: item.mediaMetadata.creationTime,
|
|
382
|
+
productUrl: item.productUrl,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
pageToken = response.nextPageToken;
|
|
387
|
+
if (!pageToken) break;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return items;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Execute operations in batches with concurrency control
|
|
395
|
+
*/
|
|
396
|
+
private async executeBatch(
|
|
397
|
+
items: MediaSummary[],
|
|
398
|
+
options: {
|
|
399
|
+
dryRun: boolean;
|
|
400
|
+
concurrency: number;
|
|
401
|
+
onProgress?: (current: number, total: number, item: MediaSummary) => void;
|
|
402
|
+
onError?: (error: Error, item: MediaSummary) => void;
|
|
403
|
+
operation: (item: MediaSummary) => Promise<void>;
|
|
404
|
+
}
|
|
405
|
+
): Promise<BulkOperationResult> {
|
|
406
|
+
const { dryRun, concurrency, onProgress, onError, operation } = options;
|
|
407
|
+
|
|
408
|
+
const result: BulkOperationResult = {
|
|
409
|
+
total: items.length,
|
|
410
|
+
success: 0,
|
|
411
|
+
failed: 0,
|
|
412
|
+
skipped: 0,
|
|
413
|
+
errors: [],
|
|
414
|
+
processedItems: [],
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
if (items.length === 0) return result;
|
|
418
|
+
|
|
419
|
+
const chunks = this.chunkArray(items, concurrency);
|
|
420
|
+
|
|
421
|
+
for (const chunk of chunks) {
|
|
422
|
+
await Promise.all(
|
|
423
|
+
chunk.map(async (item) => {
|
|
424
|
+
try {
|
|
425
|
+
if (dryRun) {
|
|
426
|
+
result.success++;
|
|
427
|
+
result.processedItems.push(item);
|
|
428
|
+
} else {
|
|
429
|
+
await operation(item);
|
|
430
|
+
result.success++;
|
|
431
|
+
result.processedItems.push(item);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (onProgress) onProgress(result.success + result.failed, result.total, item);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
result.failed++;
|
|
437
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
438
|
+
result.errors.push({ mediaId: item.id, error: errorMessage });
|
|
439
|
+
if (onError) onError(err instanceof Error ? err : new Error(errorMessage), item);
|
|
440
|
+
}
|
|
441
|
+
})
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private chunkArray<T>(array: T[], size: number): T[][] {
|
|
449
|
+
const chunks: T[][] = [];
|
|
450
|
+
for (let i = 0; i < array.length; i += size) {
|
|
451
|
+
chunks.push(array.slice(i, i + size));
|
|
452
|
+
}
|
|
453
|
+
return chunks;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
@@ -2,11 +2,13 @@ import { PhotosClient } from './client';
|
|
|
2
2
|
import { AlbumsApi } from './albums';
|
|
3
3
|
import { MediaApi } from './media';
|
|
4
4
|
import { UploadApi } from './upload';
|
|
5
|
+
import { BulkApi } from './bulk';
|
|
5
6
|
|
|
6
7
|
export { PhotosClient } from './client';
|
|
7
8
|
export { AlbumsApi } from './albums';
|
|
8
9
|
export { MediaApi } from './media';
|
|
9
10
|
export { UploadApi } from './upload';
|
|
11
|
+
export { BulkApi } from './bulk';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Main Google Photos API client
|
|
@@ -30,11 +32,17 @@ export class GooglePhotos {
|
|
|
30
32
|
*/
|
|
31
33
|
public readonly upload: UploadApi;
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Bulk API - batch operations on media items and albums
|
|
37
|
+
*/
|
|
38
|
+
public readonly bulk: BulkApi;
|
|
39
|
+
|
|
33
40
|
constructor() {
|
|
34
41
|
this.client = new PhotosClient();
|
|
35
42
|
this.albums = new AlbumsApi(this.client);
|
|
36
43
|
this.media = new MediaApi(this.client);
|
|
37
44
|
this.upload = new UploadApi(this.client);
|
|
45
|
+
this.bulk = new BulkApi(this.client);
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/**
|
|
@@ -5,6 +5,7 @@ import open from 'open';
|
|
|
5
5
|
import { writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
6
6
|
import { join, basename } from 'path';
|
|
7
7
|
import { GooglePhotos } from '../api';
|
|
8
|
+
import { BulkApi } from '../api/bulk';
|
|
8
9
|
import {
|
|
9
10
|
getClientId,
|
|
10
11
|
getClientSecret,
|
|
@@ -750,5 +751,189 @@ uploadCmd
|
|
|
750
751
|
info(extensions.join(', '));
|
|
751
752
|
});
|
|
752
753
|
|
|
754
|
+
// ============================================
|
|
755
|
+
// Bulk Commands
|
|
756
|
+
// ============================================
|
|
757
|
+
const bulkCmd = program
|
|
758
|
+
.command('bulk')
|
|
759
|
+
.description('Bulk operations on media items and albums');
|
|
760
|
+
|
|
761
|
+
function makeProgress(total: number) {
|
|
762
|
+
let last = 0;
|
|
763
|
+
return (current: number) => {
|
|
764
|
+
if (current !== last) {
|
|
765
|
+
process.stdout.write(`\r Progress: ${current}/${total}`);
|
|
766
|
+
last = current;
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
bulkCmd
|
|
772
|
+
.command('preview')
|
|
773
|
+
.description('Preview media items matching filters')
|
|
774
|
+
.option('--album-id <albumId>', 'Filter by album')
|
|
775
|
+
.option('--type <type>', 'Filter by media type (PHOTO, VIDEO)')
|
|
776
|
+
.option('--favorites', 'Only show favorites')
|
|
777
|
+
.option('-n, --max <number>', 'Maximum results', '50')
|
|
778
|
+
.action(async (opts) => {
|
|
779
|
+
try {
|
|
780
|
+
const photos = requireAuth();
|
|
781
|
+
const bulk = new BulkApi(photos.getClient());
|
|
782
|
+
const result = await bulk.preview({
|
|
783
|
+
albumId: opts.albumId,
|
|
784
|
+
mediaType: opts.type,
|
|
785
|
+
favoritesOnly: opts.favorites,
|
|
786
|
+
maxResults: parseInt(opts.max),
|
|
787
|
+
});
|
|
788
|
+
success(`Found ${result.total} item(s):`);
|
|
789
|
+
print(result.items, getFormat(bulkCmd));
|
|
790
|
+
} catch (err) {
|
|
791
|
+
error(String(err));
|
|
792
|
+
process.exit(1);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
bulkCmd
|
|
797
|
+
.command('add-to-album <albumId>')
|
|
798
|
+
.description('Bulk add media items to an album')
|
|
799
|
+
.option('--query-album-id <albumId>', 'Filter by album to discover items')
|
|
800
|
+
.option('--type <type>', 'Filter by media type')
|
|
801
|
+
.option('--favorites', 'Only favorites')
|
|
802
|
+
.option('-n, --max <number>', 'Maximum results', '100')
|
|
803
|
+
.option('--concurrency <number>', 'Max concurrent operations', '10')
|
|
804
|
+
.option('--dry-run', 'Preview without adding')
|
|
805
|
+
.action(async (albumId: string, opts) => {
|
|
806
|
+
try {
|
|
807
|
+
const photos = requireAuth();
|
|
808
|
+
const bulk = new BulkApi(photos.getClient());
|
|
809
|
+
const result = await bulk.addToAlbum({
|
|
810
|
+
targetAlbumId: albumId,
|
|
811
|
+
albumId: opts.queryAlbumId,
|
|
812
|
+
mediaType: opts.type,
|
|
813
|
+
favoritesOnly: opts.favorites,
|
|
814
|
+
maxResults: parseInt(opts.max),
|
|
815
|
+
concurrency: parseInt(opts.concurrency),
|
|
816
|
+
dryRun: opts.dryRun || false,
|
|
817
|
+
onProgress: (cur, total) => { process.stdout.write(`\r Progress: ${cur}/${total}`); },
|
|
818
|
+
onError: (err, item) => { warn(`Failed: ${item.filename} - ${err.message}`); },
|
|
819
|
+
});
|
|
820
|
+
process.stdout.write('\n');
|
|
821
|
+
if (opts.dryRun) {
|
|
822
|
+
info(`Dry run: would add ${result.success} item(s) to album`);
|
|
823
|
+
} else {
|
|
824
|
+
success(`Added ${result.success} item(s) to album, ${result.failed} failed`);
|
|
825
|
+
}
|
|
826
|
+
if (result.errors.length > 0) {
|
|
827
|
+
print(result.errors, 'pretty');
|
|
828
|
+
}
|
|
829
|
+
} catch (err) {
|
|
830
|
+
error(String(err));
|
|
831
|
+
process.exit(1);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
bulkCmd
|
|
836
|
+
.command('remove-from-album <albumId>')
|
|
837
|
+
.description('Bulk remove media items from an album')
|
|
838
|
+
.option('--media-ids <ids>', 'Comma-separated media item IDs')
|
|
839
|
+
.option('-n, --max <number>', 'Maximum results', '100')
|
|
840
|
+
.option('--concurrency <number>', 'Max concurrent operations', '10')
|
|
841
|
+
.option('--dry-run', 'Preview without removing')
|
|
842
|
+
.action(async (albumId: string, opts) => {
|
|
843
|
+
try {
|
|
844
|
+
const photos = requireAuth();
|
|
845
|
+
const bulk = new BulkApi(photos.getClient());
|
|
846
|
+
const mediaItemIds = opts.mediaIds ? opts.mediaIds.split(',') : undefined;
|
|
847
|
+
const result = await bulk.removeFromAlbum({
|
|
848
|
+
albumId,
|
|
849
|
+
mediaItemIds,
|
|
850
|
+
maxResults: parseInt(opts.max),
|
|
851
|
+
concurrency: parseInt(opts.concurrency),
|
|
852
|
+
dryRun: opts.dryRun || false,
|
|
853
|
+
onProgress: (cur, total) => { process.stdout.write(`\r Progress: ${cur}/${total}`); },
|
|
854
|
+
onError: (err, item) => { warn(`Failed: ${item.filename} - ${err.message}`); },
|
|
855
|
+
});
|
|
856
|
+
process.stdout.write('\n');
|
|
857
|
+
if (opts.dryRun) {
|
|
858
|
+
info(`Dry run: would remove ${result.success} item(s) from album`);
|
|
859
|
+
} else {
|
|
860
|
+
success(`Removed ${result.success} item(s) from album, ${result.failed} failed`);
|
|
861
|
+
}
|
|
862
|
+
if (result.errors.length > 0) {
|
|
863
|
+
print(result.errors, 'pretty');
|
|
864
|
+
}
|
|
865
|
+
} catch (err) {
|
|
866
|
+
error(String(err));
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
bulkCmd
|
|
872
|
+
.command('create-albums')
|
|
873
|
+
.description('Bulk create albums from titles')
|
|
874
|
+
.option('--titles <titles>', 'Comma-separated album titles')
|
|
875
|
+
.option('--titles-file <path>', 'File with one title per line')
|
|
876
|
+
.option('--concurrency <number>', 'Max concurrent operations', '10')
|
|
877
|
+
.option('--dry-run', 'Preview without creating')
|
|
878
|
+
.action(async (opts) => {
|
|
879
|
+
let titles: string[] = [];
|
|
880
|
+
if (opts.titles) {
|
|
881
|
+
titles = opts.titles.split(',').map((t: string) => t.trim()).filter(Boolean);
|
|
882
|
+
} else if (opts.titlesFile) {
|
|
883
|
+
if (!existsSync(opts.titlesFile)) {
|
|
884
|
+
error(`File not found: ${opts.titlesFile}`);
|
|
885
|
+
process.exit(1);
|
|
886
|
+
}
|
|
887
|
+
titles = require('fs').readFileSync(opts.titlesFile, 'utf-8')
|
|
888
|
+
.split('\n')
|
|
889
|
+
.map((t: string) => t.trim())
|
|
890
|
+
.filter(Boolean);
|
|
891
|
+
}
|
|
892
|
+
if (titles.length === 0) {
|
|
893
|
+
error('Provide --titles or --titles-file');
|
|
894
|
+
process.exit(1);
|
|
895
|
+
}
|
|
896
|
+
try {
|
|
897
|
+
const photos = requireAuth();
|
|
898
|
+
const bulk = new BulkApi(photos.getClient());
|
|
899
|
+
const result = await bulk.createAlbums({
|
|
900
|
+
titles,
|
|
901
|
+
concurrency: parseInt(opts.concurrency),
|
|
902
|
+
dryRun: opts.dryRun || false,
|
|
903
|
+
onProgress: (cur, total) => { process.stdout.write(`\r Progress: ${cur}/${total}`); },
|
|
904
|
+
onError: (err, title) => { warn(`Failed: ${title} - ${err.message}`); },
|
|
905
|
+
});
|
|
906
|
+
process.stdout.write('\n');
|
|
907
|
+
if (opts.dryRun) {
|
|
908
|
+
info(`Dry run: would create ${result.success} album(s)`);
|
|
909
|
+
} else {
|
|
910
|
+
success(`Created ${result.success} album(s), ${result.failed} failed`);
|
|
911
|
+
}
|
|
912
|
+
if (result.errors.length > 0) {
|
|
913
|
+
print(result.errors, 'pretty');
|
|
914
|
+
}
|
|
915
|
+
} catch (err) {
|
|
916
|
+
error(String(err));
|
|
917
|
+
process.exit(1);
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
bulkCmd
|
|
922
|
+
.command('favorites')
|
|
923
|
+
.description('List favorite media items')
|
|
924
|
+
.option('-n, --max <number>', 'Maximum results', '100')
|
|
925
|
+
.action(async (opts) => {
|
|
926
|
+
try {
|
|
927
|
+
const photos = requireAuth();
|
|
928
|
+
const bulk = new BulkApi(photos.getClient());
|
|
929
|
+
const result = await bulk.listFavorites({ maxResults: parseInt(opts.max) });
|
|
930
|
+
success(`Found ${result.total} favorite(s):`);
|
|
931
|
+
print(result.items, getFormat(bulkCmd));
|
|
932
|
+
} catch (err) {
|
|
933
|
+
error(String(err));
|
|
934
|
+
process.exit(1);
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
|
|
753
938
|
// Parse and run
|
|
754
939
|
program.parse();
|