@yuiseki/gyazocli 0.0.1
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/README.md +42 -0
- package/dist/api.js +67 -0
- package/dist/config.js +25 -0
- package/dist/credentials.js +60 -0
- package/dist/index.js +1903 -0
- package/dist/storage.js +107 -0
- package/docs/ADR/001-credentials.md +38 -0
- package/docs/ADR/002-caching-strategy.md +77 -0
- package/docs/ADR/003-cli-structure.md +112 -0
- package/docs/API/gyazo_api.md +66 -0
- package/package.json +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1903 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const api_1 = require("./api");
|
|
11
|
+
const storage_1 = require("./storage");
|
|
12
|
+
const credentials_1 = require("./credentials");
|
|
13
|
+
const program = new commander_1.Command();
|
|
14
|
+
const UPLOAD_DESC_TAG = '#gyazocli_uploads';
|
|
15
|
+
const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
16
|
+
program
|
|
17
|
+
.name('gyazo')
|
|
18
|
+
.description('Gyazo Memory CLI for AI Secretary')
|
|
19
|
+
.version('0.0.1');
|
|
20
|
+
// Config Command
|
|
21
|
+
const configCmd = program.command('config').description('Manage configuration');
|
|
22
|
+
configCmd
|
|
23
|
+
.command('set <key> <value>')
|
|
24
|
+
.description('Set a configuration value')
|
|
25
|
+
.action((key, value) => {
|
|
26
|
+
(0, credentials_1.setStoredConfig)(key, value);
|
|
27
|
+
});
|
|
28
|
+
configCmd
|
|
29
|
+
.command('get <key>')
|
|
30
|
+
.description('Get a configuration value')
|
|
31
|
+
.option('-j, --json', 'output as JSON')
|
|
32
|
+
.action(async (key, options) => {
|
|
33
|
+
if (key === 'me') {
|
|
34
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
35
|
+
try {
|
|
36
|
+
const me = await (0, api_1.getCurrentUser)();
|
|
37
|
+
if (options.json) {
|
|
38
|
+
console.log(JSON.stringify(me, null, 2));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const user = me?.user || {};
|
|
42
|
+
if (user.uid)
|
|
43
|
+
console.log(`UID: ${user.uid}`);
|
|
44
|
+
if (user.name)
|
|
45
|
+
console.log(`Name: ${user.name}`);
|
|
46
|
+
if (user.email)
|
|
47
|
+
console.log(`Email: ${user.email}`);
|
|
48
|
+
if (typeof user.is_pro === 'boolean')
|
|
49
|
+
console.log(`Plan: ${user.is_pro ? 'Pro' : 'Free'}`);
|
|
50
|
+
if (typeof user.is_team === 'boolean')
|
|
51
|
+
console.log(`Team: ${user.is_team ? 'Yes' : 'No'}`);
|
|
52
|
+
if (user.profile_image)
|
|
53
|
+
console.log(`Profile image: ${user.profile_image}`);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
console.error('Error getting current user:', error.message);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const value = (0, credentials_1.getStoredConfig)(key);
|
|
62
|
+
if (value) {
|
|
63
|
+
if (key === 'token') {
|
|
64
|
+
const masked = value.length > 8
|
|
65
|
+
? `${value.substring(0, 4)}...${value.substring(value.length - 4)}`
|
|
66
|
+
: '********';
|
|
67
|
+
console.log(masked);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.log(value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
console.error(`Config key '${key}' not found.`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
function isToday(date) {
|
|
79
|
+
const today = new Date();
|
|
80
|
+
return date.getDate() === today.getDate() &&
|
|
81
|
+
date.getMonth() === today.getMonth() &&
|
|
82
|
+
date.getFullYear() === today.getFullYear();
|
|
83
|
+
}
|
|
84
|
+
function normalizeText(value) {
|
|
85
|
+
if (!value)
|
|
86
|
+
return undefined;
|
|
87
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
88
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
89
|
+
}
|
|
90
|
+
function extractDomain(value) {
|
|
91
|
+
if (!value)
|
|
92
|
+
return undefined;
|
|
93
|
+
try {
|
|
94
|
+
const url = new URL(value);
|
|
95
|
+
return url.hostname.replace(/^www\./, '');
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(`https://${value}`);
|
|
100
|
+
return url.hostname.replace(/^www\./, '');
|
|
101
|
+
}
|
|
102
|
+
catch (_e) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function isXDomain(domain) {
|
|
108
|
+
if (!domain)
|
|
109
|
+
return false;
|
|
110
|
+
return domain === 'x.com' ||
|
|
111
|
+
domain.endsWith('.x.com') ||
|
|
112
|
+
domain === 'twitter.com' ||
|
|
113
|
+
domain.endsWith('.twitter.com');
|
|
114
|
+
}
|
|
115
|
+
function cleanTextForDomain(value, domain) {
|
|
116
|
+
if (!isXDomain(domain))
|
|
117
|
+
return value;
|
|
118
|
+
return value
|
|
119
|
+
.replace(/^Xユーザーの/, '')
|
|
120
|
+
.replace(/\s*\/\s*X$/, '')
|
|
121
|
+
.trim();
|
|
122
|
+
}
|
|
123
|
+
function stripInlineUrls(value) {
|
|
124
|
+
return value
|
|
125
|
+
.replace(/https?:\/\/\S+/g, '')
|
|
126
|
+
.replace(/\bwww\.\S+/g, '')
|
|
127
|
+
.replace(/\s+/g, ' ')
|
|
128
|
+
.trim();
|
|
129
|
+
}
|
|
130
|
+
function sanitizeSummaryText(value, domain) {
|
|
131
|
+
if (!value)
|
|
132
|
+
return undefined;
|
|
133
|
+
return normalizeText(stripInlineUrls(cleanTextForDomain(value, domain)));
|
|
134
|
+
}
|
|
135
|
+
function getAddressEntry(exifAddress, locale) {
|
|
136
|
+
if (!exifAddress || typeof exifAddress !== 'object')
|
|
137
|
+
return undefined;
|
|
138
|
+
if (typeof exifAddress.address === 'string')
|
|
139
|
+
return exifAddress;
|
|
140
|
+
const entry = exifAddress[locale];
|
|
141
|
+
if (!entry || typeof entry !== 'object')
|
|
142
|
+
return undefined;
|
|
143
|
+
return entry;
|
|
144
|
+
}
|
|
145
|
+
function getAddressComponent(addressEntry, type) {
|
|
146
|
+
if (!addressEntry || typeof addressEntry !== 'object')
|
|
147
|
+
return undefined;
|
|
148
|
+
const components = Array.isArray(addressEntry.address_components)
|
|
149
|
+
? addressEntry.address_components
|
|
150
|
+
: [];
|
|
151
|
+
for (const component of components) {
|
|
152
|
+
if (!component || typeof component !== 'object')
|
|
153
|
+
continue;
|
|
154
|
+
const types = Array.isArray(component.types) ? component.types : [];
|
|
155
|
+
if (!types.includes(type))
|
|
156
|
+
continue;
|
|
157
|
+
const value = normalizeText(component.long_name || component.short_name);
|
|
158
|
+
if (value)
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
function buildJaLocationLabel(exifAddress) {
|
|
164
|
+
const ja = getAddressEntry(exifAddress, 'ja');
|
|
165
|
+
if (!ja)
|
|
166
|
+
return undefined;
|
|
167
|
+
const pref = getAddressComponent(ja, 'administrative_area_level_1');
|
|
168
|
+
const locality = getAddressComponent(ja, 'locality') || getAddressComponent(ja, 'administrative_area_level_2');
|
|
169
|
+
const sublocality = getAddressComponent(ja, 'sublocality_level_2') ||
|
|
170
|
+
getAddressComponent(ja, 'sublocality_level_1') ||
|
|
171
|
+
getAddressComponent(ja, 'sublocality_level_3');
|
|
172
|
+
const fromComponents = normalizeText([pref, locality, sublocality].filter(Boolean).join(''));
|
|
173
|
+
if (fromComponents)
|
|
174
|
+
return fromComponents;
|
|
175
|
+
const raw = normalizeText(ja.address);
|
|
176
|
+
if (!raw)
|
|
177
|
+
return undefined;
|
|
178
|
+
const compact = raw
|
|
179
|
+
.replace(/^日本、?/, '')
|
|
180
|
+
.replace(/〒\d{3}-\d{4}\s*/g, '')
|
|
181
|
+
.replace(/[0-90-9].*$/, '')
|
|
182
|
+
.trim();
|
|
183
|
+
return normalizeText(compact);
|
|
184
|
+
}
|
|
185
|
+
function buildEnLocationLabel(exifAddress) {
|
|
186
|
+
const en = getAddressEntry(exifAddress, 'en');
|
|
187
|
+
if (!en)
|
|
188
|
+
return undefined;
|
|
189
|
+
const pref = getAddressComponent(en, 'administrative_area_level_1');
|
|
190
|
+
const locality = getAddressComponent(en, 'locality') || getAddressComponent(en, 'administrative_area_level_2');
|
|
191
|
+
const sublocality = getAddressComponent(en, 'sublocality_level_2') ||
|
|
192
|
+
getAddressComponent(en, 'sublocality_level_1') ||
|
|
193
|
+
getAddressComponent(en, 'sublocality_level_3');
|
|
194
|
+
const fromComponents = normalizeText([sublocality, locality, pref].filter(Boolean).join(', '));
|
|
195
|
+
if (fromComponents)
|
|
196
|
+
return fromComponents;
|
|
197
|
+
return normalizeText(en.address);
|
|
198
|
+
}
|
|
199
|
+
function extractImageAddressText(img) {
|
|
200
|
+
const exifAddress = img.metadata?.exif_address ?? img.exif_address;
|
|
201
|
+
if (!exifAddress)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (typeof exifAddress === 'string')
|
|
204
|
+
return normalizeText(exifAddress);
|
|
205
|
+
if (typeof exifAddress !== 'object')
|
|
206
|
+
return undefined;
|
|
207
|
+
const ja = getAddressEntry(exifAddress, 'ja');
|
|
208
|
+
const jaAddress = normalizeText(ja?.address);
|
|
209
|
+
if (jaAddress)
|
|
210
|
+
return jaAddress;
|
|
211
|
+
const en = getAddressEntry(exifAddress, 'en');
|
|
212
|
+
const enAddress = normalizeText(en?.address);
|
|
213
|
+
if (enAddress)
|
|
214
|
+
return enAddress;
|
|
215
|
+
for (const value of Object.values(exifAddress)) {
|
|
216
|
+
if (!value || typeof value !== 'object')
|
|
217
|
+
continue;
|
|
218
|
+
const raw = normalizeText(value.address);
|
|
219
|
+
if (raw)
|
|
220
|
+
return raw;
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
function extractImageLocationLabel(img) {
|
|
225
|
+
const exifAddress = img.metadata?.exif_address ?? img.exif_address;
|
|
226
|
+
if (!exifAddress)
|
|
227
|
+
return undefined;
|
|
228
|
+
if (typeof exifAddress === 'string')
|
|
229
|
+
return normalizeText(exifAddress);
|
|
230
|
+
if (typeof exifAddress !== 'object')
|
|
231
|
+
return undefined;
|
|
232
|
+
const jaLabel = buildJaLocationLabel(exifAddress);
|
|
233
|
+
if (jaLabel)
|
|
234
|
+
return jaLabel;
|
|
235
|
+
const enLabel = buildEnLocationLabel(exifAddress);
|
|
236
|
+
if (enLabel)
|
|
237
|
+
return enLabel;
|
|
238
|
+
for (const value of Object.values(exifAddress)) {
|
|
239
|
+
if (!value || typeof value !== 'object')
|
|
240
|
+
continue;
|
|
241
|
+
const raw = normalizeText(value.address);
|
|
242
|
+
if (raw)
|
|
243
|
+
return raw;
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
function truncateText(value, maxLength) {
|
|
248
|
+
if (value.length <= maxLength)
|
|
249
|
+
return value;
|
|
250
|
+
if (maxLength <= 3)
|
|
251
|
+
return value.slice(0, maxLength);
|
|
252
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
253
|
+
}
|
|
254
|
+
function formatCreatedAt(value) {
|
|
255
|
+
const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})/);
|
|
256
|
+
if (match) {
|
|
257
|
+
return `${match[1]} ${match[2]}:${match[3]}`;
|
|
258
|
+
}
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
function shortenImageId(imageId) {
|
|
262
|
+
if (!imageId)
|
|
263
|
+
return '';
|
|
264
|
+
if (imageId.length <= 4)
|
|
265
|
+
return imageId;
|
|
266
|
+
return `${imageId.slice(0, 4)}...`;
|
|
267
|
+
}
|
|
268
|
+
function formatTerminalLink(label, url) {
|
|
269
|
+
if (!url || !process.stdout.isTTY)
|
|
270
|
+
return label;
|
|
271
|
+
return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
|
|
272
|
+
}
|
|
273
|
+
function normalizeOcrText(value) {
|
|
274
|
+
if (!value)
|
|
275
|
+
return undefined;
|
|
276
|
+
const normalized = value
|
|
277
|
+
.replace(/\r\n/g, '\n')
|
|
278
|
+
.replace(/\r/g, '\n')
|
|
279
|
+
.split('\n')
|
|
280
|
+
.map(line => line.trimEnd())
|
|
281
|
+
.join('\n')
|
|
282
|
+
.trim();
|
|
283
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
284
|
+
}
|
|
285
|
+
function extractOcrDescription(image) {
|
|
286
|
+
const direct = normalizeOcrText(image?.ocr?.description);
|
|
287
|
+
if (direct)
|
|
288
|
+
return direct;
|
|
289
|
+
return normalizeOcrText(image?.metadata?.ocr?.description);
|
|
290
|
+
}
|
|
291
|
+
function buildOcrPreview(ocrText, maxLines) {
|
|
292
|
+
const lines = ocrText.split('\n');
|
|
293
|
+
if (lines.length <= maxLines) {
|
|
294
|
+
return { text: ocrText, truncated: false };
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
text: lines.slice(0, maxLines).join('\n'),
|
|
298
|
+
truncated: true,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
function extractObjectAnnotations(image) {
|
|
302
|
+
const rawAnnotations = image?.localizedObjectAnnotations ||
|
|
303
|
+
image?.localized_object_annotations ||
|
|
304
|
+
image?.metadata?.localizedObjectAnnotations ||
|
|
305
|
+
image?.metadata?.localized_object_annotations ||
|
|
306
|
+
[];
|
|
307
|
+
if (!Array.isArray(rawAnnotations))
|
|
308
|
+
return [];
|
|
309
|
+
const bestByName = new Map();
|
|
310
|
+
for (const annotation of rawAnnotations) {
|
|
311
|
+
if (!annotation || typeof annotation !== 'object')
|
|
312
|
+
continue;
|
|
313
|
+
const name = normalizeText(annotation.name_ja || annotation.nameJa || annotation.name);
|
|
314
|
+
if (!name)
|
|
315
|
+
continue;
|
|
316
|
+
const score = typeof annotation.score === 'number' ? annotation.score : undefined;
|
|
317
|
+
const existing = bestByName.get(name);
|
|
318
|
+
if (!existing) {
|
|
319
|
+
bestByName.set(name, { name, score });
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const existingScore = existing.score ?? -1;
|
|
323
|
+
const nextScore = score ?? -1;
|
|
324
|
+
if (nextScore > existingScore) {
|
|
325
|
+
bestByName.set(name, { name, score });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return Array.from(bestByName.values()).sort((a, b) => {
|
|
329
|
+
const sa = a.score ?? -1;
|
|
330
|
+
const sb = b.score ?? -1;
|
|
331
|
+
return sb - sa;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function formatObjectAnnotationLine(annotation) {
|
|
335
|
+
if (typeof annotation.score === 'number') {
|
|
336
|
+
return `${annotation.name} (${(annotation.score * 100).toFixed(1)}%)`;
|
|
337
|
+
}
|
|
338
|
+
return annotation.name;
|
|
339
|
+
}
|
|
340
|
+
function ensureUploadDescTag(desc) {
|
|
341
|
+
const normalized = normalizeText(desc);
|
|
342
|
+
if (!normalized)
|
|
343
|
+
return UPLOAD_DESC_TAG;
|
|
344
|
+
const words = normalized
|
|
345
|
+
.split(' ')
|
|
346
|
+
.filter(word => word.toLowerCase() !== UPLOAD_DESC_TAG.toLowerCase());
|
|
347
|
+
words.push(UPLOAD_DESC_TAG);
|
|
348
|
+
return words.join(' ').trim();
|
|
349
|
+
}
|
|
350
|
+
function parseUploadTimestamp(value) {
|
|
351
|
+
if (!value)
|
|
352
|
+
return undefined;
|
|
353
|
+
if (!/^\d+$/.test(value)) {
|
|
354
|
+
console.error('Error: --timestamp must be a unix timestamp in seconds.');
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
const parsed = Number(value);
|
|
358
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
359
|
+
console.error('Error: --timestamp is out of range.');
|
|
360
|
+
process.exit(1);
|
|
361
|
+
}
|
|
362
|
+
const now = Math.floor(Date.now() / 1000);
|
|
363
|
+
if (parsed > now) {
|
|
364
|
+
console.error('Error: --timestamp must be current time or in the past.');
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
return parsed;
|
|
368
|
+
}
|
|
369
|
+
function parsePositiveIntegerOption(value, optionName) {
|
|
370
|
+
if (!/^\d+$/.test(value)) {
|
|
371
|
+
console.error(`Error: ${optionName} must be a positive integer.`);
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
const parsed = Number(value);
|
|
375
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
376
|
+
console.error(`Error: ${optionName} must be a positive integer.`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
return parsed;
|
|
380
|
+
}
|
|
381
|
+
function parseDateOption(value) {
|
|
382
|
+
if (!value) {
|
|
383
|
+
const today = new Date();
|
|
384
|
+
const year = String(today.getFullYear());
|
|
385
|
+
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
386
|
+
const day = String(today.getDate()).padStart(2, '0');
|
|
387
|
+
return {
|
|
388
|
+
granularity: 'day',
|
|
389
|
+
dateKey: `${year}-${month}-${day}`,
|
|
390
|
+
start: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0),
|
|
391
|
+
end: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
if (/^\d{4}$/.test(value)) {
|
|
395
|
+
const year = Number(value);
|
|
396
|
+
return {
|
|
397
|
+
granularity: 'year',
|
|
398
|
+
dateKey: value,
|
|
399
|
+
start: new Date(year, 0, 1, 0, 0, 0, 0),
|
|
400
|
+
end: new Date(year, 11, 31, 23, 59, 59, 999),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
if (/^\d{4}-\d{2}$/.test(value)) {
|
|
404
|
+
const [yearText, monthText] = value.split('-');
|
|
405
|
+
const year = Number(yearText);
|
|
406
|
+
const month = Number(monthText);
|
|
407
|
+
const probe = new Date(year, month - 1, 1);
|
|
408
|
+
if (probe.getFullYear() !== year || probe.getMonth() !== month - 1) {
|
|
409
|
+
console.error('Error: --date month is invalid.');
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
granularity: 'month',
|
|
414
|
+
dateKey: value,
|
|
415
|
+
start: new Date(year, month - 1, 1, 0, 0, 0, 0),
|
|
416
|
+
end: new Date(year, month, 0, 23, 59, 59, 999),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
420
|
+
const [yearText, monthText, dayText] = value.split('-');
|
|
421
|
+
const year = Number(yearText);
|
|
422
|
+
const month = Number(monthText);
|
|
423
|
+
const day = Number(dayText);
|
|
424
|
+
const probe = new Date(year, month - 1, day);
|
|
425
|
+
if (probe.getFullYear() !== year ||
|
|
426
|
+
probe.getMonth() !== month - 1 ||
|
|
427
|
+
probe.getDate() !== day) {
|
|
428
|
+
console.error('Error: --date day is invalid.');
|
|
429
|
+
process.exit(1);
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
granularity: 'day',
|
|
433
|
+
dateKey: value,
|
|
434
|
+
start: new Date(year, month - 1, day, 0, 0, 0, 0),
|
|
435
|
+
end: new Date(year, month - 1, day, 23, 59, 59, 999),
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
console.error('Error: --date format must be yyyy or yyyy-mm or yyyy-mm-dd.');
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
function formatDateYmd(date) {
|
|
442
|
+
const year = String(date.getFullYear());
|
|
443
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
444
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
445
|
+
return `${year}-${month}-${day}`;
|
|
446
|
+
}
|
|
447
|
+
function buildRecentWeekRangeUntilYesterday() {
|
|
448
|
+
const today = new Date();
|
|
449
|
+
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1, 23, 59, 59, 999);
|
|
450
|
+
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 8, 0, 0, 0, 0);
|
|
451
|
+
return {
|
|
452
|
+
granularity: 'day',
|
|
453
|
+
dateKey: `${formatDateYmd(start)}..${formatDateYmd(end)}`,
|
|
454
|
+
start,
|
|
455
|
+
end,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function resolveRankingRangeOption(options) {
|
|
459
|
+
if (options.today && options.date) {
|
|
460
|
+
console.error('Error: --today and --date cannot be used together.');
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
if (options.today) {
|
|
464
|
+
return parseDateOption();
|
|
465
|
+
}
|
|
466
|
+
if (options.date) {
|
|
467
|
+
return parseDateOption(options.date);
|
|
468
|
+
}
|
|
469
|
+
return buildRecentWeekRangeUntilYesterday();
|
|
470
|
+
}
|
|
471
|
+
function buildStatsDateRange(dateOption, daysOption) {
|
|
472
|
+
if (!dateOption && daysOption === '7') {
|
|
473
|
+
const weekly = buildRecentWeekRangeUntilYesterday();
|
|
474
|
+
return {
|
|
475
|
+
range: weekly,
|
|
476
|
+
days: 7,
|
|
477
|
+
startLabel: formatDateYmd(weekly.start),
|
|
478
|
+
endLabel: formatDateYmd(weekly.end),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const days = parsePositiveIntegerOption(daysOption, '--days');
|
|
482
|
+
let endDate;
|
|
483
|
+
if (dateOption) {
|
|
484
|
+
const parsed = parseDateOption(dateOption);
|
|
485
|
+
endDate = new Date(parsed.end);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
const now = new Date();
|
|
489
|
+
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999);
|
|
490
|
+
}
|
|
491
|
+
const startDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate(), 0, 0, 0, 0);
|
|
492
|
+
startDate.setDate(startDate.getDate() - (days - 1));
|
|
493
|
+
const startLabel = formatDateYmd(startDate);
|
|
494
|
+
const endLabel = formatDateYmd(endDate);
|
|
495
|
+
return {
|
|
496
|
+
range: {
|
|
497
|
+
granularity: 'day',
|
|
498
|
+
dateKey: `${startLabel}..${endLabel}`,
|
|
499
|
+
start: startDate,
|
|
500
|
+
end: endDate,
|
|
501
|
+
},
|
|
502
|
+
days,
|
|
503
|
+
startLabel,
|
|
504
|
+
endLabel,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
function getDateHourStrings() {
|
|
508
|
+
const hours = [];
|
|
509
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
510
|
+
hours.push(String(hour).padStart(2, '0'));
|
|
511
|
+
}
|
|
512
|
+
return hours;
|
|
513
|
+
}
|
|
514
|
+
function buildHourlyBucketKey(year, month, day, hour) {
|
|
515
|
+
return `${year}-${month}-${day}-${hour}`;
|
|
516
|
+
}
|
|
517
|
+
function splitHourlyBucketKey(key) {
|
|
518
|
+
const [year, month, day, hour] = key.split('-');
|
|
519
|
+
return { year, month, day, hour };
|
|
520
|
+
}
|
|
521
|
+
function toDateParts(date) {
|
|
522
|
+
return {
|
|
523
|
+
year: String(date.getFullYear()),
|
|
524
|
+
month: String(date.getMonth() + 1).padStart(2, '0'),
|
|
525
|
+
day: String(date.getDate()).padStart(2, '0'),
|
|
526
|
+
hour: String(date.getHours()).padStart(2, '0'),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
function getDatePartsInRange(start, end) {
|
|
530
|
+
const dates = [];
|
|
531
|
+
const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0);
|
|
532
|
+
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 0, 0, 0, 0);
|
|
533
|
+
while (cursor.getTime() <= last.getTime()) {
|
|
534
|
+
dates.push({
|
|
535
|
+
year: String(cursor.getFullYear()),
|
|
536
|
+
month: String(cursor.getMonth() + 1).padStart(2, '0'),
|
|
537
|
+
day: String(cursor.getDate()).padStart(2, '0'),
|
|
538
|
+
});
|
|
539
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
540
|
+
}
|
|
541
|
+
return dates;
|
|
542
|
+
}
|
|
543
|
+
function normalizeRankingValues(values) {
|
|
544
|
+
const uniqueByLower = new Map();
|
|
545
|
+
for (const raw of values) {
|
|
546
|
+
const value = normalizeText(raw);
|
|
547
|
+
if (!value)
|
|
548
|
+
continue;
|
|
549
|
+
const key = value.toLocaleLowerCase();
|
|
550
|
+
if (!uniqueByLower.has(key)) {
|
|
551
|
+
uniqueByLower.set(key, value);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return Array.from(uniqueByLower.values());
|
|
555
|
+
}
|
|
556
|
+
function normalizeHourlyMetadataEntries(valuesByImageId) {
|
|
557
|
+
if (!valuesByImageId || typeof valuesByImageId !== 'object')
|
|
558
|
+
return {};
|
|
559
|
+
const normalized = {};
|
|
560
|
+
for (const [imageId, rawValues] of Object.entries(valuesByImageId)) {
|
|
561
|
+
const values = Array.isArray(rawValues)
|
|
562
|
+
? rawValues.map(value => String(value))
|
|
563
|
+
: [];
|
|
564
|
+
normalized[imageId] = normalizeRankingValues(values);
|
|
565
|
+
}
|
|
566
|
+
return normalized;
|
|
567
|
+
}
|
|
568
|
+
function extractImageApps(image) {
|
|
569
|
+
const app = normalizeText(image?.metadata?.app);
|
|
570
|
+
return app ? [app] : [];
|
|
571
|
+
}
|
|
572
|
+
function extractImageDomains(image) {
|
|
573
|
+
const domain = extractDomain(normalizeText(image?.metadata?.url));
|
|
574
|
+
return domain ? [domain] : [];
|
|
575
|
+
}
|
|
576
|
+
function extractImageLocations(image) {
|
|
577
|
+
const location = normalizeText(extractImageLocationLabel(image));
|
|
578
|
+
return location ? [location] : [];
|
|
579
|
+
}
|
|
580
|
+
function normalizeTagText(value) {
|
|
581
|
+
if (!value)
|
|
582
|
+
return undefined;
|
|
583
|
+
const normalized = normalizeText(value);
|
|
584
|
+
if (!normalized)
|
|
585
|
+
return undefined;
|
|
586
|
+
const stripped = normalized.replace(/^[##]+/, '').trim();
|
|
587
|
+
return stripped.length > 0 ? stripped : undefined;
|
|
588
|
+
}
|
|
589
|
+
function extractTagFromLinkValue(value) {
|
|
590
|
+
if (typeof value === 'string') {
|
|
591
|
+
return normalizeTagText(value);
|
|
592
|
+
}
|
|
593
|
+
if (!value || typeof value !== 'object')
|
|
594
|
+
return undefined;
|
|
595
|
+
const candidates = [
|
|
596
|
+
value.tag,
|
|
597
|
+
value.name,
|
|
598
|
+
value.title,
|
|
599
|
+
value.text,
|
|
600
|
+
value.keyword,
|
|
601
|
+
];
|
|
602
|
+
for (const candidate of candidates) {
|
|
603
|
+
const tag = normalizeTagText(candidate);
|
|
604
|
+
if (tag)
|
|
605
|
+
return tag;
|
|
606
|
+
}
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
function extractImageTags(image) {
|
|
610
|
+
const rawLinks = image?.metadata?.links ?? image?.links;
|
|
611
|
+
if (!Array.isArray(rawLinks))
|
|
612
|
+
return [];
|
|
613
|
+
const tags = [];
|
|
614
|
+
for (const rawLink of rawLinks) {
|
|
615
|
+
const tag = extractTagFromLinkValue(rawLink);
|
|
616
|
+
if (tag)
|
|
617
|
+
tags.push(tag);
|
|
618
|
+
}
|
|
619
|
+
return normalizeRankingValues(tags);
|
|
620
|
+
}
|
|
621
|
+
async function warmDateCacheForApps(targetDate, maxPages, useCache) {
|
|
622
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'apps', extractImageApps);
|
|
623
|
+
}
|
|
624
|
+
async function warmDateCacheForDomains(targetDate, maxPages, useCache) {
|
|
625
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'domains', extractImageDomains);
|
|
626
|
+
}
|
|
627
|
+
async function warmDateCacheForTags(targetDate, maxPages, useCache) {
|
|
628
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'tags', extractImageTags);
|
|
629
|
+
}
|
|
630
|
+
async function warmDateCacheForLocations(targetDate, maxPages, useCache) {
|
|
631
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'locations', extractImageLocations);
|
|
632
|
+
}
|
|
633
|
+
async function warmDateCacheForRanking(targetDate, maxPages, useCache, metadataKind, extractValues) {
|
|
634
|
+
const hourlyIndices = new Map();
|
|
635
|
+
const hourlyMetadataEntries = new Map();
|
|
636
|
+
const existingHourlyMetadataEntries = new Map();
|
|
637
|
+
const imageIds = new Set();
|
|
638
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
639
|
+
const images = await (0, api_1.listImages)(page, 100);
|
|
640
|
+
if (images.length === 0)
|
|
641
|
+
break;
|
|
642
|
+
let reachedLimit = false;
|
|
643
|
+
for (const img of images) {
|
|
644
|
+
const createdAt = new Date(img.created_at);
|
|
645
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
646
|
+
continue;
|
|
647
|
+
if (createdAt > targetDate.end)
|
|
648
|
+
continue;
|
|
649
|
+
if (createdAt < targetDate.start) {
|
|
650
|
+
reachedLimit = true;
|
|
651
|
+
break;
|
|
652
|
+
}
|
|
653
|
+
const dateParts = toDateParts(createdAt);
|
|
654
|
+
const bucketKey = buildHourlyBucketKey(dateParts.year, dateParts.month, dateParts.day, dateParts.hour);
|
|
655
|
+
if (!hourlyIndices.has(bucketKey)) {
|
|
656
|
+
hourlyIndices.set(bucketKey, new Set());
|
|
657
|
+
}
|
|
658
|
+
if (!hourlyMetadataEntries.has(bucketKey)) {
|
|
659
|
+
hourlyMetadataEntries.set(bucketKey, new Map());
|
|
660
|
+
}
|
|
661
|
+
hourlyIndices.get(bucketKey)?.add(img.image_id);
|
|
662
|
+
imageIds.add(img.image_id);
|
|
663
|
+
let merged = img;
|
|
664
|
+
const cached = useCache ? (0, storage_1.loadImageCache)(img.image_id) : null;
|
|
665
|
+
if (cached) {
|
|
666
|
+
merged = mergeImageForDisplay(img, cached);
|
|
667
|
+
}
|
|
668
|
+
let values;
|
|
669
|
+
let hasExistingMetadataEntry = false;
|
|
670
|
+
if (useCache) {
|
|
671
|
+
let existingForBucket = existingHourlyMetadataEntries.get(bucketKey);
|
|
672
|
+
if (!existingForBucket) {
|
|
673
|
+
existingForBucket = normalizeHourlyMetadataEntries((0, storage_1.loadHourlyMetadataCache)(metadataKind, dateParts.year, dateParts.month, dateParts.day, dateParts.hour));
|
|
674
|
+
existingHourlyMetadataEntries.set(bucketKey, existingForBucket);
|
|
675
|
+
}
|
|
676
|
+
if (Object.prototype.hasOwnProperty.call(existingForBucket, img.image_id)) {
|
|
677
|
+
values = existingForBucket[img.image_id];
|
|
678
|
+
hasExistingMetadataEntry = true;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (!values) {
|
|
682
|
+
values = normalizeRankingValues(extractValues(merged));
|
|
683
|
+
}
|
|
684
|
+
if (values.length === 0 && !hasExistingMetadataEntry) {
|
|
685
|
+
try {
|
|
686
|
+
const detail = await (0, api_1.getImageDetail)(img.image_id);
|
|
687
|
+
merged = mergeImageForDisplay(merged, detail);
|
|
688
|
+
values = normalizeRankingValues(extractValues(merged));
|
|
689
|
+
}
|
|
690
|
+
catch (_error) {
|
|
691
|
+
// Keep best effort result when detail fetch fails.
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
(0, storage_1.saveImageCache)(img.image_id, merged);
|
|
695
|
+
hourlyMetadataEntries.get(bucketKey)?.set(img.image_id, values);
|
|
696
|
+
}
|
|
697
|
+
if (reachedLimit)
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
for (const [bucketKey, current] of hourlyIndices.entries()) {
|
|
701
|
+
const { year, month, day, hour } = splitHourlyBucketKey(bucketKey);
|
|
702
|
+
if (useCache) {
|
|
703
|
+
const existing = (0, storage_1.loadHourlyCache)(year, month, day, hour) || [];
|
|
704
|
+
for (const id of existing)
|
|
705
|
+
current.add(id);
|
|
706
|
+
}
|
|
707
|
+
(0, storage_1.saveHourlyCache)(year, month, day, hour, Array.from(current));
|
|
708
|
+
for (const id of current)
|
|
709
|
+
imageIds.add(id);
|
|
710
|
+
const mergedMetadataEntries = useCache
|
|
711
|
+
? normalizeHourlyMetadataEntries((0, storage_1.loadHourlyMetadataCache)(metadataKind, year, month, day, hour))
|
|
712
|
+
: {};
|
|
713
|
+
const currentMetadataEntries = hourlyMetadataEntries.get(bucketKey) || new Map();
|
|
714
|
+
for (const [imageId, values] of currentMetadataEntries.entries()) {
|
|
715
|
+
mergedMetadataEntries[imageId] = values;
|
|
716
|
+
}
|
|
717
|
+
(0, storage_1.saveHourlyMetadataCache)(metadataKind, year, month, day, hour, mergedMetadataEntries);
|
|
718
|
+
}
|
|
719
|
+
if (useCache) {
|
|
720
|
+
const dates = getDatePartsInRange(targetDate.start, targetDate.end);
|
|
721
|
+
const hours = getDateHourStrings();
|
|
722
|
+
for (const date of dates) {
|
|
723
|
+
for (const hour of hours) {
|
|
724
|
+
const existing = (0, storage_1.loadHourlyCache)(date.year, date.month, date.day, hour) || [];
|
|
725
|
+
for (const id of existing)
|
|
726
|
+
imageIds.add(id);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return Array.from(imageIds);
|
|
731
|
+
}
|
|
732
|
+
function buildHourlyMetadataEntriesFromImageCache(year, month, day, hour, extractValues) {
|
|
733
|
+
const imageIds = (0, storage_1.loadHourlyCache)(year, month, day, hour) || [];
|
|
734
|
+
const valuesByImageId = {};
|
|
735
|
+
for (const imageId of imageIds) {
|
|
736
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
737
|
+
if (!image)
|
|
738
|
+
continue;
|
|
739
|
+
valuesByImageId[imageId] = normalizeRankingValues(extractValues(image));
|
|
740
|
+
}
|
|
741
|
+
return valuesByImageId;
|
|
742
|
+
}
|
|
743
|
+
function loadOrBuildHourlyMetadataEntries(metadataKind, year, month, day, hour, extractValues) {
|
|
744
|
+
const rawCached = (0, storage_1.loadHourlyMetadataCache)(metadataKind, year, month, day, hour);
|
|
745
|
+
if (rawCached !== null) {
|
|
746
|
+
return normalizeHourlyMetadataEntries(rawCached);
|
|
747
|
+
}
|
|
748
|
+
const built = buildHourlyMetadataEntriesFromImageCache(year, month, day, hour, extractValues);
|
|
749
|
+
const hasHourlyIndex = Boolean((0, storage_1.loadHourlyCache)(year, month, day, hour));
|
|
750
|
+
if (hasHourlyIndex || Object.keys(built).length > 0) {
|
|
751
|
+
(0, storage_1.saveHourlyMetadataCache)(metadataKind, year, month, day, hour, built);
|
|
752
|
+
}
|
|
753
|
+
return built;
|
|
754
|
+
}
|
|
755
|
+
function aggregateRankingFromHourlyMetadataCache(targetDate, metadataKind, extractValues) {
|
|
756
|
+
const counts = new Map();
|
|
757
|
+
const seenImageIds = new Set();
|
|
758
|
+
let totalImages = 0;
|
|
759
|
+
let imageCountWithValues = 0;
|
|
760
|
+
let totalAssignments = 0;
|
|
761
|
+
const dates = getDatePartsInRange(targetDate.start, targetDate.end);
|
|
762
|
+
const hours = getDateHourStrings();
|
|
763
|
+
for (const date of dates) {
|
|
764
|
+
for (const hour of hours) {
|
|
765
|
+
const entries = loadOrBuildHourlyMetadataEntries(metadataKind, date.year, date.month, date.day, hour, extractValues);
|
|
766
|
+
for (const [imageId, values] of Object.entries(entries)) {
|
|
767
|
+
if (seenImageIds.has(imageId))
|
|
768
|
+
continue;
|
|
769
|
+
seenImageIds.add(imageId);
|
|
770
|
+
totalImages++;
|
|
771
|
+
if (values.length === 0)
|
|
772
|
+
continue;
|
|
773
|
+
imageCountWithValues++;
|
|
774
|
+
totalAssignments += values.length;
|
|
775
|
+
for (const value of values) {
|
|
776
|
+
counts.set(value, (counts.get(value) || 0) + 1);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const ranking = Array.from(counts.entries())
|
|
782
|
+
.map(([key, count]) => ({ key, count }))
|
|
783
|
+
.sort((a, b) => {
|
|
784
|
+
if (b.count !== a.count)
|
|
785
|
+
return b.count - a.count;
|
|
786
|
+
return a.key.localeCompare(b.key);
|
|
787
|
+
});
|
|
788
|
+
return {
|
|
789
|
+
ranking,
|
|
790
|
+
totalImages,
|
|
791
|
+
imageCountWithValues,
|
|
792
|
+
totalAssignments,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function buildAppsRankingFromCache(imageIds) {
|
|
796
|
+
const counts = new Map();
|
|
797
|
+
for (const imageId of imageIds) {
|
|
798
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
799
|
+
const apps = extractImageApps(image);
|
|
800
|
+
if (apps.length === 0)
|
|
801
|
+
continue;
|
|
802
|
+
const app = apps[0];
|
|
803
|
+
counts.set(app, (counts.get(app) || 0) + 1);
|
|
804
|
+
}
|
|
805
|
+
return Array.from(counts.entries())
|
|
806
|
+
.map(([app, count]) => ({ app, count }))
|
|
807
|
+
.sort((a, b) => {
|
|
808
|
+
if (b.count !== a.count)
|
|
809
|
+
return b.count - a.count;
|
|
810
|
+
return a.app.localeCompare(b.app);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function buildAppsRankingFromHourlyCache(targetDate) {
|
|
814
|
+
const summary = aggregateRankingFromHourlyMetadataCache(targetDate, 'apps', extractImageApps);
|
|
815
|
+
return {
|
|
816
|
+
ranking: summary.ranking.map(item => ({ app: item.key, count: item.count })),
|
|
817
|
+
totalImages: summary.totalImages,
|
|
818
|
+
imageCountWithApps: summary.imageCountWithValues,
|
|
819
|
+
};
|
|
820
|
+
}
|
|
821
|
+
function buildDomainsRankingFromCache(imageIds) {
|
|
822
|
+
const counts = new Map();
|
|
823
|
+
for (const imageId of imageIds) {
|
|
824
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
825
|
+
const domains = extractImageDomains(image);
|
|
826
|
+
if (domains.length === 0)
|
|
827
|
+
continue;
|
|
828
|
+
const domain = domains[0];
|
|
829
|
+
counts.set(domain, (counts.get(domain) || 0) + 1);
|
|
830
|
+
}
|
|
831
|
+
return Array.from(counts.entries())
|
|
832
|
+
.map(([domain, count]) => ({ domain, count }))
|
|
833
|
+
.sort((a, b) => {
|
|
834
|
+
if (b.count !== a.count)
|
|
835
|
+
return b.count - a.count;
|
|
836
|
+
return a.domain.localeCompare(b.domain);
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
function buildDomainsRankingFromHourlyCache(targetDate) {
|
|
840
|
+
const summary = aggregateRankingFromHourlyMetadataCache(targetDate, 'domains', extractImageDomains);
|
|
841
|
+
return {
|
|
842
|
+
ranking: summary.ranking.map(item => ({ domain: item.key, count: item.count })),
|
|
843
|
+
totalImages: summary.totalImages,
|
|
844
|
+
imageCountWithDomains: summary.imageCountWithValues,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
function buildLocationsRankingFromCache(imageIds) {
|
|
848
|
+
const counts = new Map();
|
|
849
|
+
for (const imageId of imageIds) {
|
|
850
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
851
|
+
const locations = extractImageLocations(image);
|
|
852
|
+
if (locations.length === 0)
|
|
853
|
+
continue;
|
|
854
|
+
const location = locations[0];
|
|
855
|
+
counts.set(location, (counts.get(location) || 0) + 1);
|
|
856
|
+
}
|
|
857
|
+
return Array.from(counts.entries())
|
|
858
|
+
.map(([location, count]) => ({ location, count }))
|
|
859
|
+
.sort((a, b) => {
|
|
860
|
+
if (b.count !== a.count)
|
|
861
|
+
return b.count - a.count;
|
|
862
|
+
return a.location.localeCompare(b.location);
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
function buildLocationsRankingFromHourlyCache(targetDate) {
|
|
866
|
+
const summary = aggregateRankingFromHourlyMetadataCache(targetDate, 'locations', extractImageLocations);
|
|
867
|
+
return {
|
|
868
|
+
ranking: summary.ranking.map(item => ({ location: item.key, count: item.count })),
|
|
869
|
+
totalImages: summary.totalImages,
|
|
870
|
+
imageCountWithLocations: summary.imageCountWithValues,
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
function buildTagsRankingFromCache(imageIds) {
|
|
874
|
+
const counts = new Map();
|
|
875
|
+
let imageCountWithTags = 0;
|
|
876
|
+
let totalTagAssignments = 0;
|
|
877
|
+
for (const imageId of imageIds) {
|
|
878
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
879
|
+
const tags = extractImageTags(image);
|
|
880
|
+
if (tags.length === 0)
|
|
881
|
+
continue;
|
|
882
|
+
imageCountWithTags++;
|
|
883
|
+
for (const tag of tags) {
|
|
884
|
+
counts.set(tag, (counts.get(tag) || 0) + 1);
|
|
885
|
+
totalTagAssignments++;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const ranking = Array.from(counts.entries())
|
|
889
|
+
.map(([tag, count]) => ({ tag, count }))
|
|
890
|
+
.sort((a, b) => {
|
|
891
|
+
if (b.count !== a.count)
|
|
892
|
+
return b.count - a.count;
|
|
893
|
+
return a.tag.localeCompare(b.tag);
|
|
894
|
+
});
|
|
895
|
+
return {
|
|
896
|
+
ranking,
|
|
897
|
+
imageCountWithTags,
|
|
898
|
+
totalTagAssignments,
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
function buildTagsRankingFromHourlyCache(targetDate) {
|
|
902
|
+
const summary = aggregateRankingFromHourlyMetadataCache(targetDate, 'tags', extractImageTags);
|
|
903
|
+
return {
|
|
904
|
+
ranking: summary.ranking.map(item => ({ tag: item.key, count: item.count })),
|
|
905
|
+
totalImages: summary.totalImages,
|
|
906
|
+
imageCountWithTags: summary.imageCountWithValues,
|
|
907
|
+
totalTagAssignments: summary.totalAssignments,
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
function buildUploadTimeSummaryFromHourlyCache(targetDate) {
|
|
911
|
+
const seen = new Set();
|
|
912
|
+
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({ hour, count: 0 }));
|
|
913
|
+
const weekdayCounts = Array.from({ length: 7 }, (_, weekday) => ({ weekday, count: 0 }));
|
|
914
|
+
const dates = getDatePartsInRange(targetDate.start, targetDate.end);
|
|
915
|
+
const hours = getDateHourStrings();
|
|
916
|
+
for (const date of dates) {
|
|
917
|
+
for (const hourText of hours) {
|
|
918
|
+
const hour = Number(hourText);
|
|
919
|
+
const imageIds = (0, storage_1.loadHourlyCache)(date.year, date.month, date.day, hourText) || [];
|
|
920
|
+
const weekday = new Date(Number(date.year), Number(date.month) - 1, Number(date.day), hour, 0, 0, 0).getDay();
|
|
921
|
+
for (const imageId of imageIds) {
|
|
922
|
+
if (seen.has(imageId))
|
|
923
|
+
continue;
|
|
924
|
+
seen.add(imageId);
|
|
925
|
+
hourCounts[hour].count++;
|
|
926
|
+
weekdayCounts[weekday].count++;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return {
|
|
931
|
+
totalImages: seen.size,
|
|
932
|
+
byHour: hourCounts.sort((a, b) => {
|
|
933
|
+
if (b.count !== a.count)
|
|
934
|
+
return b.count - a.count;
|
|
935
|
+
return a.hour - b.hour;
|
|
936
|
+
}),
|
|
937
|
+
byWeekday: weekdayCounts.sort((a, b) => {
|
|
938
|
+
if (b.count !== a.count)
|
|
939
|
+
return b.count - a.count;
|
|
940
|
+
return a.weekday - b.weekday;
|
|
941
|
+
}),
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
function buildUploadTimeSummaryFromImageCache(imageIds, targetDate) {
|
|
945
|
+
const seen = new Set();
|
|
946
|
+
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({ hour, count: 0 }));
|
|
947
|
+
const weekdayCounts = Array.from({ length: 7 }, (_, weekday) => ({ weekday, count: 0 }));
|
|
948
|
+
for (const imageId of imageIds) {
|
|
949
|
+
if (seen.has(imageId))
|
|
950
|
+
continue;
|
|
951
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
952
|
+
const createdAtText = normalizeText(image?.created_at);
|
|
953
|
+
if (!createdAtText)
|
|
954
|
+
continue;
|
|
955
|
+
const createdAt = new Date(createdAtText);
|
|
956
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
957
|
+
continue;
|
|
958
|
+
if (createdAt < targetDate.start || createdAt > targetDate.end)
|
|
959
|
+
continue;
|
|
960
|
+
seen.add(imageId);
|
|
961
|
+
hourCounts[createdAt.getHours()].count++;
|
|
962
|
+
weekdayCounts[createdAt.getDay()].count++;
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
totalImages: seen.size,
|
|
966
|
+
byHour: hourCounts.sort((a, b) => {
|
|
967
|
+
if (b.count !== a.count)
|
|
968
|
+
return b.count - a.count;
|
|
969
|
+
return a.hour - b.hour;
|
|
970
|
+
}),
|
|
971
|
+
byWeekday: weekdayCounts.sort((a, b) => {
|
|
972
|
+
if (b.count !== a.count)
|
|
973
|
+
return b.count - a.count;
|
|
974
|
+
return a.weekday - b.weekday;
|
|
975
|
+
}),
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
function appendStatsRankSection(lines, title, rows, top) {
|
|
979
|
+
lines.push(`### ${title}`);
|
|
980
|
+
const filtered = rows.filter(row => row.count > 0).slice(0, top);
|
|
981
|
+
if (filtered.length === 0) {
|
|
982
|
+
lines.push('- No data');
|
|
983
|
+
lines.push('');
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
for (const row of filtered) {
|
|
987
|
+
lines.push(`- ${row.label}: ${row.count}`);
|
|
988
|
+
}
|
|
989
|
+
lines.push('');
|
|
990
|
+
}
|
|
991
|
+
function renderStatsMarkdown(params) {
|
|
992
|
+
const lines = [];
|
|
993
|
+
lines.push('## Gyazo Stats');
|
|
994
|
+
lines.push('');
|
|
995
|
+
lines.push(`- Window: ${params.startLabel} to ${params.endLabel} (${params.days} days)`);
|
|
996
|
+
lines.push(`- Total uploads: ${params.totalUploads}`);
|
|
997
|
+
lines.push('');
|
|
998
|
+
appendStatsRankSection(lines, 'Upload Time (Hour)', params.uploadTime.byHour.map(item => ({
|
|
999
|
+
label: `${String(item.hour).padStart(2, '0')}:00`,
|
|
1000
|
+
count: item.count,
|
|
1001
|
+
})), params.top);
|
|
1002
|
+
appendStatsRankSection(lines, 'Upload Weekday', params.uploadTime.byWeekday.map(item => ({
|
|
1003
|
+
label: WEEKDAY_LABELS[item.weekday] || String(item.weekday),
|
|
1004
|
+
count: item.count,
|
|
1005
|
+
})), Math.min(params.top, 7));
|
|
1006
|
+
appendStatsRankSection(lines, 'Apps', params.apps.map(item => ({ label: item.app, count: item.count })), params.top);
|
|
1007
|
+
appendStatsRankSection(lines, 'Domains', params.domains.map(item => ({ label: item.domain, count: item.count })), params.top);
|
|
1008
|
+
appendStatsRankSection(lines, 'Tags', params.tags.map(item => ({ label: `#${item.tag}`, count: item.count })), params.top);
|
|
1009
|
+
return lines.join('\n').trimEnd();
|
|
1010
|
+
}
|
|
1011
|
+
async function readStdinBuffer() {
|
|
1012
|
+
return new Promise((resolve, reject) => {
|
|
1013
|
+
const chunks = [];
|
|
1014
|
+
process.stdin.on('data', (chunk) => {
|
|
1015
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1016
|
+
});
|
|
1017
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
|
|
1018
|
+
process.stdin.on('error', reject);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
function printGetMarkdown(image, ocrDescription, objects = []) {
|
|
1022
|
+
const lines = [];
|
|
1023
|
+
lines.push('## Gyazo Image');
|
|
1024
|
+
lines.push('');
|
|
1025
|
+
lines.push(`- URL: <${image.permalink_url}>`);
|
|
1026
|
+
lines.push(`- Created at: ${formatCreatedAt(image.created_at)}`);
|
|
1027
|
+
const title = normalizeText(image.metadata?.title);
|
|
1028
|
+
if (title)
|
|
1029
|
+
lines.push(`- Title: ${title}`);
|
|
1030
|
+
const address = extractImageAddressText(image);
|
|
1031
|
+
if (address)
|
|
1032
|
+
lines.push(`- Address: ${address}`);
|
|
1033
|
+
const altText = normalizeText(image.alt_text);
|
|
1034
|
+
if (altText)
|
|
1035
|
+
lines.push(`- Alt text: ${altText}`);
|
|
1036
|
+
if (objects.length > 0) {
|
|
1037
|
+
lines.push('');
|
|
1038
|
+
lines.push('### Objects');
|
|
1039
|
+
for (const object of objects) {
|
|
1040
|
+
lines.push(`- ${formatObjectAnnotationLine(object)}`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (ocrDescription) {
|
|
1044
|
+
const preview = buildOcrPreview(ocrDescription, 5);
|
|
1045
|
+
lines.push('');
|
|
1046
|
+
lines.push('### OCR');
|
|
1047
|
+
lines.push('```text');
|
|
1048
|
+
lines.push(preview.text);
|
|
1049
|
+
lines.push('```');
|
|
1050
|
+
if (preview.truncated) {
|
|
1051
|
+
lines.push('');
|
|
1052
|
+
lines.push(`> Truncated to first 5 lines. Use \`gyazo get --ocr ${image.image_id}\` for full text.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
console.log(lines.join('\n'));
|
|
1056
|
+
}
|
|
1057
|
+
function summarizeImageForList(img) {
|
|
1058
|
+
const domain = extractDomain(normalizeText(img.metadata?.url));
|
|
1059
|
+
const cleanedTitle = sanitizeSummaryText(img.metadata?.title, domain);
|
|
1060
|
+
const cleanedDesc = sanitizeSummaryText(img.metadata?.desc, domain);
|
|
1061
|
+
const locationLabel = sanitizeSummaryText(extractImageLocationLabel(img));
|
|
1062
|
+
const cleanedAltText = sanitizeSummaryText(img.alt_text);
|
|
1063
|
+
let main = '(no title/description)';
|
|
1064
|
+
if (cleanedTitle && cleanedDesc) {
|
|
1065
|
+
main = `${cleanedTitle} | ${cleanedDesc}`;
|
|
1066
|
+
}
|
|
1067
|
+
else if (cleanedTitle) {
|
|
1068
|
+
main = cleanedTitle;
|
|
1069
|
+
}
|
|
1070
|
+
else if (cleanedDesc) {
|
|
1071
|
+
main = cleanedDesc;
|
|
1072
|
+
}
|
|
1073
|
+
if (cleanedAltText) {
|
|
1074
|
+
if (main === '(no title/description)') {
|
|
1075
|
+
main = cleanedAltText;
|
|
1076
|
+
}
|
|
1077
|
+
else if (cleanedAltText !== main) {
|
|
1078
|
+
main = `${main} | alt: ${cleanedAltText}`;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const prefixes = [];
|
|
1082
|
+
if (domain)
|
|
1083
|
+
prefixes.push(`[${domain}]`);
|
|
1084
|
+
if (locationLabel)
|
|
1085
|
+
prefixes.push(`[${locationLabel}]`);
|
|
1086
|
+
if (main === '(no title/description)') {
|
|
1087
|
+
if (prefixes.length > 0)
|
|
1088
|
+
return prefixes.join(' ');
|
|
1089
|
+
return main;
|
|
1090
|
+
}
|
|
1091
|
+
if (prefixes.length > 0) {
|
|
1092
|
+
return `${prefixes.join(' ')} ${main}`;
|
|
1093
|
+
}
|
|
1094
|
+
return main;
|
|
1095
|
+
}
|
|
1096
|
+
function shouldEnrichForLocationDisplay(img) {
|
|
1097
|
+
const locationLabel = sanitizeSummaryText(extractImageLocationLabel(img));
|
|
1098
|
+
return !locationLabel;
|
|
1099
|
+
}
|
|
1100
|
+
function mergeImageForDisplay(base, detail) {
|
|
1101
|
+
return {
|
|
1102
|
+
...base,
|
|
1103
|
+
...detail,
|
|
1104
|
+
metadata: {
|
|
1105
|
+
...(base?.metadata || {}),
|
|
1106
|
+
...(detail?.metadata || {}),
|
|
1107
|
+
},
|
|
1108
|
+
ocr: detail?.ocr ?? base?.ocr,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
function cacheSearchResultImages(images) {
|
|
1112
|
+
for (const img of images) {
|
|
1113
|
+
if (!img?.image_id)
|
|
1114
|
+
continue;
|
|
1115
|
+
(0, storage_1.saveSearchImageCache)(img.image_id, img);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function supplementAltTextFromSearchCache(image, useCache = true) {
|
|
1119
|
+
const hasAltText = Boolean(normalizeText(image.alt_text));
|
|
1120
|
+
if (hasAltText)
|
|
1121
|
+
return { image, supplemented: false };
|
|
1122
|
+
if (!useCache)
|
|
1123
|
+
return { image, supplemented: false };
|
|
1124
|
+
const cached = (0, storage_1.loadSearchImageCache)(image.image_id);
|
|
1125
|
+
const cachedAltText = normalizeText(cached?.alt_text);
|
|
1126
|
+
const cachedHasAltText = Boolean(cachedAltText);
|
|
1127
|
+
if (!cachedHasAltText)
|
|
1128
|
+
return { image, supplemented: false };
|
|
1129
|
+
return {
|
|
1130
|
+
image: {
|
|
1131
|
+
...image,
|
|
1132
|
+
alt_text: cachedAltText,
|
|
1133
|
+
},
|
|
1134
|
+
supplemented: true,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
function supplementAltTextForDisplay(images, useCache = true) {
|
|
1138
|
+
return images.map(img => supplementAltTextFromSearchCache(img, useCache).image);
|
|
1139
|
+
}
|
|
1140
|
+
async function prepareImagesForDisplay(images, options = {}) {
|
|
1141
|
+
const useCache = options.useCache !== false;
|
|
1142
|
+
if (options.cacheSearchResults) {
|
|
1143
|
+
cacheSearchResultImages(images);
|
|
1144
|
+
}
|
|
1145
|
+
let prepared = images;
|
|
1146
|
+
if (options.enrichLocation) {
|
|
1147
|
+
prepared = await enrichImagesForLocationDisplay(prepared, useCache);
|
|
1148
|
+
}
|
|
1149
|
+
prepared = supplementAltTextForDisplay(prepared, useCache);
|
|
1150
|
+
return prepared;
|
|
1151
|
+
}
|
|
1152
|
+
async function enrichImagesForLocationDisplay(images, useCache = true) {
|
|
1153
|
+
const enriched = [];
|
|
1154
|
+
for (const img of images) {
|
|
1155
|
+
let current = img;
|
|
1156
|
+
if (!shouldEnrichForLocationDisplay(current)) {
|
|
1157
|
+
enriched.push(current);
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1160
|
+
if (useCache) {
|
|
1161
|
+
const cached = (0, storage_1.loadImageCache)(img.image_id);
|
|
1162
|
+
if (cached) {
|
|
1163
|
+
current = mergeImageForDisplay(current, cached);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (!shouldEnrichForLocationDisplay(current)) {
|
|
1167
|
+
enriched.push(current);
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
try {
|
|
1171
|
+
const detail = await (0, api_1.getImageDetail)(img.image_id);
|
|
1172
|
+
(0, storage_1.saveImageCache)(img.image_id, detail);
|
|
1173
|
+
current = mergeImageForDisplay(current, detail);
|
|
1174
|
+
}
|
|
1175
|
+
catch (_error) {
|
|
1176
|
+
// Keep current data when detail fetch fails.
|
|
1177
|
+
}
|
|
1178
|
+
enriched.push(current);
|
|
1179
|
+
}
|
|
1180
|
+
return enriched;
|
|
1181
|
+
}
|
|
1182
|
+
function printListImages(images) {
|
|
1183
|
+
images.forEach(img => {
|
|
1184
|
+
const summary = truncateText(summarizeImageForList(img), 120);
|
|
1185
|
+
const created = formatCreatedAt(img.created_at);
|
|
1186
|
+
const shortId = shortenImageId(img.image_id);
|
|
1187
|
+
const imageUrl = img.permalink_url || `https://gyazo.com/${img.image_id}`;
|
|
1188
|
+
const linkedId = formatTerminalLink(shortId, imageUrl);
|
|
1189
|
+
console.log(`- [${created}] ${summary} (id: ${linkedId})`);
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
program
|
|
1193
|
+
.command('list')
|
|
1194
|
+
.alias('ls')
|
|
1195
|
+
.description('List recent images')
|
|
1196
|
+
.option('-p, --page <number>', 'page number', '1')
|
|
1197
|
+
.option('-l, --limit <number>', 'items per page', '20')
|
|
1198
|
+
.option('-j, --json', 'output as JSON')
|
|
1199
|
+
.option('-H, --hour <yyyy-mm-dd-hh>', 'target hour')
|
|
1200
|
+
.option('--photos', 'alias of search "has:location"')
|
|
1201
|
+
.option('--uploaded', 'alias of search "gyazocli_uploads"')
|
|
1202
|
+
.option('--no-cache', 'force fetch from API')
|
|
1203
|
+
.action(async (options) => {
|
|
1204
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1205
|
+
try {
|
|
1206
|
+
const useCache = options.cache !== false;
|
|
1207
|
+
if (options.photos && options.uploaded) {
|
|
1208
|
+
console.error('Error: --photos and --uploaded cannot be used together.');
|
|
1209
|
+
process.exit(1);
|
|
1210
|
+
}
|
|
1211
|
+
if ((options.photos || options.uploaded) && options.hour) {
|
|
1212
|
+
console.error('Error: --photos/--uploaded and --hour cannot be used together.');
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
const aliasQuery = options.photos
|
|
1216
|
+
? 'has:location'
|
|
1217
|
+
: options.uploaded
|
|
1218
|
+
? 'gyazocli_uploads'
|
|
1219
|
+
: undefined;
|
|
1220
|
+
if (aliasQuery) {
|
|
1221
|
+
const images = await (0, api_1.searchImages)(aliasQuery, parseInt(options.page, 10), parseInt(options.limit, 10));
|
|
1222
|
+
if (options.json) {
|
|
1223
|
+
console.log(JSON.stringify(images, null, 2));
|
|
1224
|
+
}
|
|
1225
|
+
else {
|
|
1226
|
+
const imagesForDisplay = await prepareImagesForDisplay(images, {
|
|
1227
|
+
cacheSearchResults: true,
|
|
1228
|
+
enrichLocation: true,
|
|
1229
|
+
useCache,
|
|
1230
|
+
});
|
|
1231
|
+
printListImages(imagesForDisplay);
|
|
1232
|
+
}
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (options.hour) {
|
|
1236
|
+
const parts = options.hour.split('-');
|
|
1237
|
+
if (parts.length !== 4) {
|
|
1238
|
+
console.error('Error: hour format must be yyyy-mm-dd-hh');
|
|
1239
|
+
process.exit(1);
|
|
1240
|
+
}
|
|
1241
|
+
const [year, month, day, hour] = parts;
|
|
1242
|
+
const imageIds = (0, storage_1.loadHourlyCache)(year, month, day, hour);
|
|
1243
|
+
if (!imageIds) {
|
|
1244
|
+
console.log(`No images found for ${options.hour} in cache.`);
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
let images = [];
|
|
1248
|
+
if (useCache) {
|
|
1249
|
+
images = imageIds.map(id => (0, storage_1.loadImageCache)(id)).filter(img => img !== null);
|
|
1250
|
+
}
|
|
1251
|
+
else {
|
|
1252
|
+
for (const imageId of imageIds) {
|
|
1253
|
+
try {
|
|
1254
|
+
const detail = await (0, api_1.getImageDetail)(imageId);
|
|
1255
|
+
(0, storage_1.saveImageCache)(imageId, detail);
|
|
1256
|
+
images.push(detail);
|
|
1257
|
+
}
|
|
1258
|
+
catch (_error) {
|
|
1259
|
+
// Skip failed items and continue with the rest.
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (options.json) {
|
|
1264
|
+
console.log(JSON.stringify(images, null, 2));
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
const imagesForDisplay = await prepareImagesForDisplay(images, {
|
|
1268
|
+
enrichLocation: true,
|
|
1269
|
+
useCache,
|
|
1270
|
+
});
|
|
1271
|
+
printListImages(imagesForDisplay);
|
|
1272
|
+
}
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
const images = await (0, api_1.listImages)(parseInt(options.page, 10), parseInt(options.limit, 10));
|
|
1276
|
+
if (options.json) {
|
|
1277
|
+
console.log(JSON.stringify(images, null, 2));
|
|
1278
|
+
}
|
|
1279
|
+
else {
|
|
1280
|
+
const imagesForDisplay = await prepareImagesForDisplay(images, {
|
|
1281
|
+
enrichLocation: true,
|
|
1282
|
+
useCache,
|
|
1283
|
+
});
|
|
1284
|
+
printListImages(imagesForDisplay);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
catch (error) {
|
|
1288
|
+
console.error('Error listing images:', error.message);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
program
|
|
1292
|
+
.command('get <image_id>')
|
|
1293
|
+
.description('Get detailed metadata for an image')
|
|
1294
|
+
.option('-j, --json', 'output as JSON')
|
|
1295
|
+
.option('--ocr', 'output OCR text only')
|
|
1296
|
+
.option('--objects', 'output object annotations only')
|
|
1297
|
+
.option('--no-cache', 'force fetch from API')
|
|
1298
|
+
.action(async (imageId, options) => {
|
|
1299
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1300
|
+
try {
|
|
1301
|
+
if (options.json && (options.ocr || options.objects)) {
|
|
1302
|
+
console.error('Error: --json cannot be used with --ocr or --objects.');
|
|
1303
|
+
process.exit(1);
|
|
1304
|
+
}
|
|
1305
|
+
if (options.ocr && options.objects) {
|
|
1306
|
+
console.error('Error: --ocr and --objects cannot be used together.');
|
|
1307
|
+
process.exit(1);
|
|
1308
|
+
}
|
|
1309
|
+
let image = options.cache !== false ? (0, storage_1.loadImageCache)(imageId) : null;
|
|
1310
|
+
if (!image) {
|
|
1311
|
+
image = await (0, api_1.getImageDetail)(imageId);
|
|
1312
|
+
(0, storage_1.saveImageCache)(imageId, image);
|
|
1313
|
+
}
|
|
1314
|
+
const supplemented = supplementAltTextFromSearchCache(image);
|
|
1315
|
+
image = supplemented.image;
|
|
1316
|
+
if (supplemented.supplemented) {
|
|
1317
|
+
(0, storage_1.saveImageCache)(imageId, image);
|
|
1318
|
+
}
|
|
1319
|
+
const ocrDescription = extractOcrDescription(image);
|
|
1320
|
+
const objects = extractObjectAnnotations(image);
|
|
1321
|
+
if (options.ocr) {
|
|
1322
|
+
if (!ocrDescription) {
|
|
1323
|
+
console.error('OCR not found for this image.');
|
|
1324
|
+
process.exit(1);
|
|
1325
|
+
}
|
|
1326
|
+
console.log(ocrDescription);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (options.objects) {
|
|
1330
|
+
if (objects.length === 0) {
|
|
1331
|
+
console.error('Object annotations not found for this image.');
|
|
1332
|
+
process.exit(1);
|
|
1333
|
+
}
|
|
1334
|
+
console.log(objects.map(formatObjectAnnotationLine).join('\n'));
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
if (options.json) {
|
|
1338
|
+
console.log(JSON.stringify(image, null, 2));
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
printGetMarkdown(image, ocrDescription, objects);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
catch (error) {
|
|
1345
|
+
console.error('Error getting image:', error.message);
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
program
|
|
1349
|
+
.command('search [query]')
|
|
1350
|
+
.description('Search images')
|
|
1351
|
+
.option('-j, --json', 'output as JSON')
|
|
1352
|
+
.option('--no-cache', 'force fetch from API')
|
|
1353
|
+
.action(async (query, options) => {
|
|
1354
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1355
|
+
try {
|
|
1356
|
+
if (!normalizeText(query)) {
|
|
1357
|
+
console.error('Error: Query is required.');
|
|
1358
|
+
console.error('Hint: Run `gyazo search -h` for usage.');
|
|
1359
|
+
process.exit(1);
|
|
1360
|
+
}
|
|
1361
|
+
const images = await (0, api_1.searchImages)(query);
|
|
1362
|
+
const useCache = options.cache !== false;
|
|
1363
|
+
if (options.json) {
|
|
1364
|
+
cacheSearchResultImages(images);
|
|
1365
|
+
console.log(JSON.stringify(images, null, 2));
|
|
1366
|
+
}
|
|
1367
|
+
else {
|
|
1368
|
+
const imagesForDisplay = await prepareImagesForDisplay(images, {
|
|
1369
|
+
cacheSearchResults: true,
|
|
1370
|
+
enrichLocation: true,
|
|
1371
|
+
useCache,
|
|
1372
|
+
});
|
|
1373
|
+
printListImages(imagesForDisplay);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
console.error('Error searching images:', error.message);
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
program
|
|
1381
|
+
.command('apps')
|
|
1382
|
+
.description('Rank metadata app names for a specific date')
|
|
1383
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
1384
|
+
.option('--today', 'target today only (overrides default weekly range)')
|
|
1385
|
+
.option('-l, --limit <number>', 'maximum ranking rows (max: 10)', '10')
|
|
1386
|
+
.option('--max-pages <number>', 'max pages to scan before stopping', '10')
|
|
1387
|
+
.option('-j, --json', 'output as JSON')
|
|
1388
|
+
.option('--no-cache', 'force fetch from API')
|
|
1389
|
+
.action(async (options) => {
|
|
1390
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1391
|
+
try {
|
|
1392
|
+
const targetDate = resolveRankingRangeOption(options);
|
|
1393
|
+
const requestedLimit = parsePositiveIntegerOption(options.limit, '--limit');
|
|
1394
|
+
const limit = Math.min(requestedLimit, 10);
|
|
1395
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
1396
|
+
const useCache = options.cache !== false;
|
|
1397
|
+
let ranking = [];
|
|
1398
|
+
let totalWithApp = 0;
|
|
1399
|
+
let totalImages = 0;
|
|
1400
|
+
if (useCache) {
|
|
1401
|
+
let cacheSummary = buildAppsRankingFromHourlyCache(targetDate);
|
|
1402
|
+
if (cacheSummary.totalImages === 0) {
|
|
1403
|
+
await warmDateCacheForApps(targetDate, maxPages, true);
|
|
1404
|
+
cacheSummary = buildAppsRankingFromHourlyCache(targetDate);
|
|
1405
|
+
}
|
|
1406
|
+
ranking = cacheSummary.ranking;
|
|
1407
|
+
totalWithApp = cacheSummary.imageCountWithApps;
|
|
1408
|
+
totalImages = cacheSummary.totalImages;
|
|
1409
|
+
}
|
|
1410
|
+
else {
|
|
1411
|
+
const imageIds = await warmDateCacheForApps(targetDate, maxPages, false);
|
|
1412
|
+
ranking = buildAppsRankingFromCache(imageIds);
|
|
1413
|
+
totalWithApp = ranking.reduce((sum, item) => sum + item.count, 0);
|
|
1414
|
+
totalImages = imageIds.length;
|
|
1415
|
+
}
|
|
1416
|
+
const displayedRanking = ranking.slice(0, limit);
|
|
1417
|
+
if (options.json) {
|
|
1418
|
+
console.log(JSON.stringify({
|
|
1419
|
+
date: targetDate.dateKey,
|
|
1420
|
+
image_count: totalImages,
|
|
1421
|
+
app_image_count: totalWithApp,
|
|
1422
|
+
total_apps: ranking.length,
|
|
1423
|
+
ranking: displayedRanking,
|
|
1424
|
+
}, null, 2));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (ranking.length === 0) {
|
|
1428
|
+
console.log(`No app metadata found for ${targetDate.dateKey}.`);
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
console.log(`Apps on ${targetDate.dateKey}`);
|
|
1432
|
+
displayedRanking.forEach((item, index) => {
|
|
1433
|
+
console.log(`${index + 1}. ${item.app}: ${item.count}`);
|
|
1434
|
+
});
|
|
1435
|
+
console.log(`Total images with app metadata: ${totalWithApp}`);
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
console.error('Error ranking apps:', error.message);
|
|
1439
|
+
process.exit(1);
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
program
|
|
1443
|
+
.command('domains')
|
|
1444
|
+
.description('Rank metadata URL domains for a specific date')
|
|
1445
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
1446
|
+
.option('--today', 'target today only (overrides default weekly range)')
|
|
1447
|
+
.option('-l, --limit <number>', 'maximum ranking rows (max: 10)', '10')
|
|
1448
|
+
.option('--max-pages <number>', 'max pages to scan before stopping', '10')
|
|
1449
|
+
.option('-j, --json', 'output as JSON')
|
|
1450
|
+
.option('--no-cache', 'force fetch from API')
|
|
1451
|
+
.action(async (options) => {
|
|
1452
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1453
|
+
try {
|
|
1454
|
+
const targetDate = resolveRankingRangeOption(options);
|
|
1455
|
+
const requestedLimit = parsePositiveIntegerOption(options.limit, '--limit');
|
|
1456
|
+
const limit = Math.min(requestedLimit, 10);
|
|
1457
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
1458
|
+
const useCache = options.cache !== false;
|
|
1459
|
+
let ranking = [];
|
|
1460
|
+
let totalWithDomain = 0;
|
|
1461
|
+
let totalImages = 0;
|
|
1462
|
+
if (useCache) {
|
|
1463
|
+
let cacheSummary = buildDomainsRankingFromHourlyCache(targetDate);
|
|
1464
|
+
if (cacheSummary.totalImages === 0) {
|
|
1465
|
+
await warmDateCacheForDomains(targetDate, maxPages, true);
|
|
1466
|
+
cacheSummary = buildDomainsRankingFromHourlyCache(targetDate);
|
|
1467
|
+
}
|
|
1468
|
+
ranking = cacheSummary.ranking;
|
|
1469
|
+
totalWithDomain = cacheSummary.imageCountWithDomains;
|
|
1470
|
+
totalImages = cacheSummary.totalImages;
|
|
1471
|
+
}
|
|
1472
|
+
else {
|
|
1473
|
+
const imageIds = await warmDateCacheForDomains(targetDate, maxPages, false);
|
|
1474
|
+
ranking = buildDomainsRankingFromCache(imageIds);
|
|
1475
|
+
totalWithDomain = ranking.reduce((sum, item) => sum + item.count, 0);
|
|
1476
|
+
totalImages = imageIds.length;
|
|
1477
|
+
}
|
|
1478
|
+
const displayedRanking = ranking.slice(0, limit);
|
|
1479
|
+
if (options.json) {
|
|
1480
|
+
console.log(JSON.stringify({
|
|
1481
|
+
date: targetDate.dateKey,
|
|
1482
|
+
image_count: totalImages,
|
|
1483
|
+
domain_image_count: totalWithDomain,
|
|
1484
|
+
total_domains: ranking.length,
|
|
1485
|
+
ranking: displayedRanking,
|
|
1486
|
+
}, null, 2));
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (ranking.length === 0) {
|
|
1490
|
+
console.log(`No domain metadata found for ${targetDate.dateKey}.`);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
console.log(`Domains on ${targetDate.dateKey}`);
|
|
1494
|
+
displayedRanking.forEach((item, index) => {
|
|
1495
|
+
console.log(`${index + 1}. ${item.domain}: ${item.count}`);
|
|
1496
|
+
});
|
|
1497
|
+
console.log(`Total images with domain metadata: ${totalWithDomain}`);
|
|
1498
|
+
}
|
|
1499
|
+
catch (error) {
|
|
1500
|
+
console.error('Error ranking domains:', error.message);
|
|
1501
|
+
process.exit(1);
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
program
|
|
1505
|
+
.command('tags')
|
|
1506
|
+
.description('Rank metadata tags for a specific date')
|
|
1507
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
1508
|
+
.option('--today', 'target today only (overrides default weekly range)')
|
|
1509
|
+
.option('-l, --limit <number>', 'maximum ranking rows (max: 10)', '10')
|
|
1510
|
+
.option('--max-pages <number>', 'max pages to scan before stopping', '10')
|
|
1511
|
+
.option('-j, --json', 'output as JSON')
|
|
1512
|
+
.option('--no-cache', 'force fetch from API')
|
|
1513
|
+
.action(async (options) => {
|
|
1514
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1515
|
+
try {
|
|
1516
|
+
const targetDate = resolveRankingRangeOption(options);
|
|
1517
|
+
const requestedLimit = parsePositiveIntegerOption(options.limit, '--limit');
|
|
1518
|
+
const limit = Math.min(requestedLimit, 10);
|
|
1519
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
1520
|
+
const useCache = options.cache !== false;
|
|
1521
|
+
let summary;
|
|
1522
|
+
let totalImages = 0;
|
|
1523
|
+
if (useCache) {
|
|
1524
|
+
let cacheSummary = buildTagsRankingFromHourlyCache(targetDate);
|
|
1525
|
+
if (cacheSummary.totalImages === 0) {
|
|
1526
|
+
await warmDateCacheForTags(targetDate, maxPages, true);
|
|
1527
|
+
cacheSummary = buildTagsRankingFromHourlyCache(targetDate);
|
|
1528
|
+
}
|
|
1529
|
+
summary = cacheSummary;
|
|
1530
|
+
totalImages = cacheSummary.totalImages;
|
|
1531
|
+
}
|
|
1532
|
+
else {
|
|
1533
|
+
const imageIds = await warmDateCacheForTags(targetDate, maxPages, false);
|
|
1534
|
+
summary = buildTagsRankingFromCache(imageIds);
|
|
1535
|
+
totalImages = imageIds.length;
|
|
1536
|
+
}
|
|
1537
|
+
const displayedRanking = summary.ranking.slice(0, limit);
|
|
1538
|
+
if (options.json) {
|
|
1539
|
+
console.log(JSON.stringify({
|
|
1540
|
+
date: targetDate.dateKey,
|
|
1541
|
+
image_count: totalImages,
|
|
1542
|
+
image_count_with_tags: summary.imageCountWithTags,
|
|
1543
|
+
total_tag_assignments: summary.totalTagAssignments,
|
|
1544
|
+
total_tags: summary.ranking.length,
|
|
1545
|
+
ranking: displayedRanking,
|
|
1546
|
+
}, null, 2));
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (summary.ranking.length === 0) {
|
|
1550
|
+
console.log(`No tag metadata found for ${targetDate.dateKey}.`);
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
console.log(`Tags on ${targetDate.dateKey}`);
|
|
1554
|
+
displayedRanking.forEach((item, index) => {
|
|
1555
|
+
console.log(`${index + 1}. #${item.tag}: ${item.count}`);
|
|
1556
|
+
});
|
|
1557
|
+
console.log(`Total images with tag metadata: ${summary.imageCountWithTags}`);
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
console.error('Error ranking tags:', error.message);
|
|
1561
|
+
process.exit(1);
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
program
|
|
1565
|
+
.command('locations')
|
|
1566
|
+
.description('Rank metadata locations for a specific date')
|
|
1567
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
1568
|
+
.option('--today', 'target today only (overrides default weekly range)')
|
|
1569
|
+
.option('-l, --limit <number>', 'maximum ranking rows (max: 10)', '10')
|
|
1570
|
+
.option('--max-pages <number>', 'max pages to scan before stopping', '10')
|
|
1571
|
+
.option('-j, --json', 'output as JSON')
|
|
1572
|
+
.option('--no-cache', 'force fetch from API')
|
|
1573
|
+
.action(async (options) => {
|
|
1574
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1575
|
+
try {
|
|
1576
|
+
const targetDate = resolveRankingRangeOption(options);
|
|
1577
|
+
const requestedLimit = parsePositiveIntegerOption(options.limit, '--limit');
|
|
1578
|
+
const limit = Math.min(requestedLimit, 10);
|
|
1579
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
1580
|
+
const useCache = options.cache !== false;
|
|
1581
|
+
let ranking = [];
|
|
1582
|
+
let totalWithLocation = 0;
|
|
1583
|
+
let totalImages = 0;
|
|
1584
|
+
if (useCache) {
|
|
1585
|
+
let cacheSummary = buildLocationsRankingFromHourlyCache(targetDate);
|
|
1586
|
+
if (cacheSummary.totalImages === 0) {
|
|
1587
|
+
await warmDateCacheForLocations(targetDate, maxPages, true);
|
|
1588
|
+
cacheSummary = buildLocationsRankingFromHourlyCache(targetDate);
|
|
1589
|
+
}
|
|
1590
|
+
ranking = cacheSummary.ranking;
|
|
1591
|
+
totalWithLocation = cacheSummary.imageCountWithLocations;
|
|
1592
|
+
totalImages = cacheSummary.totalImages;
|
|
1593
|
+
}
|
|
1594
|
+
else {
|
|
1595
|
+
const imageIds = await warmDateCacheForLocations(targetDate, maxPages, false);
|
|
1596
|
+
ranking = buildLocationsRankingFromCache(imageIds);
|
|
1597
|
+
totalWithLocation = ranking.reduce((sum, item) => sum + item.count, 0);
|
|
1598
|
+
totalImages = imageIds.length;
|
|
1599
|
+
}
|
|
1600
|
+
const displayedRanking = ranking.slice(0, limit);
|
|
1601
|
+
if (options.json) {
|
|
1602
|
+
console.log(JSON.stringify({
|
|
1603
|
+
date: targetDate.dateKey,
|
|
1604
|
+
image_count: totalImages,
|
|
1605
|
+
location_image_count: totalWithLocation,
|
|
1606
|
+
total_locations: ranking.length,
|
|
1607
|
+
ranking: displayedRanking,
|
|
1608
|
+
}, null, 2));
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
if (ranking.length === 0) {
|
|
1612
|
+
console.log(`No location metadata found for ${targetDate.dateKey}.`);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
console.log(`Locations on ${targetDate.dateKey}`);
|
|
1616
|
+
displayedRanking.forEach((item, index) => {
|
|
1617
|
+
console.log(`${index + 1}. ${item.location}: ${item.count}`);
|
|
1618
|
+
});
|
|
1619
|
+
console.log(`Total images with location metadata: ${totalWithLocation}`);
|
|
1620
|
+
}
|
|
1621
|
+
catch (error) {
|
|
1622
|
+
console.error('Error ranking locations:', error.message);
|
|
1623
|
+
process.exit(1);
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
program
|
|
1627
|
+
.command('stats')
|
|
1628
|
+
.description('Show weekly stats summary in Markdown')
|
|
1629
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'window end date anchor (default: yesterday)')
|
|
1630
|
+
.option('--days <number>', 'window length in days', '7')
|
|
1631
|
+
.option('--top <number>', 'rows per section', '10')
|
|
1632
|
+
.option('--max-pages <number>', 'max pages to fetch when warming cache', '10')
|
|
1633
|
+
.option('--no-cache', 'force fetch from API')
|
|
1634
|
+
.action(async (options) => {
|
|
1635
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1636
|
+
try {
|
|
1637
|
+
const { range, days, startLabel, endLabel } = buildStatsDateRange(options.date, options.days || '7');
|
|
1638
|
+
const top = Math.min(parsePositiveIntegerOption(options.top, '--top'), 20);
|
|
1639
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
1640
|
+
const useCache = options.cache !== false;
|
|
1641
|
+
let uploadTime;
|
|
1642
|
+
let apps = [];
|
|
1643
|
+
let domains = [];
|
|
1644
|
+
let tags = [];
|
|
1645
|
+
let totalUploads = 0;
|
|
1646
|
+
if (useCache) {
|
|
1647
|
+
uploadTime = buildUploadTimeSummaryFromHourlyCache(range);
|
|
1648
|
+
if (uploadTime.totalImages === 0) {
|
|
1649
|
+
await warmDateCacheForApps(range, maxPages, true);
|
|
1650
|
+
uploadTime = buildUploadTimeSummaryFromHourlyCache(range);
|
|
1651
|
+
}
|
|
1652
|
+
let appsSummary = buildAppsRankingFromHourlyCache(range);
|
|
1653
|
+
if (uploadTime.totalImages > 0 && appsSummary.totalImages === 0) {
|
|
1654
|
+
await warmDateCacheForApps(range, maxPages, true);
|
|
1655
|
+
appsSummary = buildAppsRankingFromHourlyCache(range);
|
|
1656
|
+
}
|
|
1657
|
+
let domainsSummary = buildDomainsRankingFromHourlyCache(range);
|
|
1658
|
+
if (uploadTime.totalImages > 0 && domainsSummary.totalImages === 0) {
|
|
1659
|
+
await warmDateCacheForDomains(range, maxPages, true);
|
|
1660
|
+
domainsSummary = buildDomainsRankingFromHourlyCache(range);
|
|
1661
|
+
}
|
|
1662
|
+
let tagsSummary = buildTagsRankingFromHourlyCache(range);
|
|
1663
|
+
if (uploadTime.totalImages > 0 && tagsSummary.totalImages === 0) {
|
|
1664
|
+
await warmDateCacheForTags(range, maxPages, true);
|
|
1665
|
+
tagsSummary = buildTagsRankingFromHourlyCache(range);
|
|
1666
|
+
}
|
|
1667
|
+
apps = appsSummary.ranking;
|
|
1668
|
+
domains = domainsSummary.ranking;
|
|
1669
|
+
tags = tagsSummary.ranking;
|
|
1670
|
+
totalUploads = uploadTime.totalImages;
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
const imageIds = await warmDateCacheForTags(range, maxPages, false);
|
|
1674
|
+
uploadTime = buildUploadTimeSummaryFromImageCache(imageIds, range);
|
|
1675
|
+
apps = buildAppsRankingFromCache(imageIds);
|
|
1676
|
+
domains = buildDomainsRankingFromCache(imageIds);
|
|
1677
|
+
tags = buildTagsRankingFromCache(imageIds).ranking;
|
|
1678
|
+
totalUploads = uploadTime.totalImages;
|
|
1679
|
+
}
|
|
1680
|
+
console.log(renderStatsMarkdown({
|
|
1681
|
+
startLabel,
|
|
1682
|
+
endLabel,
|
|
1683
|
+
days,
|
|
1684
|
+
totalUploads,
|
|
1685
|
+
uploadTime,
|
|
1686
|
+
apps,
|
|
1687
|
+
domains,
|
|
1688
|
+
tags,
|
|
1689
|
+
top,
|
|
1690
|
+
}));
|
|
1691
|
+
}
|
|
1692
|
+
catch (error) {
|
|
1693
|
+
console.error('Error building stats:', error.message);
|
|
1694
|
+
process.exit(1);
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
program
|
|
1698
|
+
.command('upload [path]')
|
|
1699
|
+
.description('Upload an image file (or read image bytes from stdin)')
|
|
1700
|
+
.option('--title <title>', 'image title')
|
|
1701
|
+
.option('--app <app>', 'application name', 'gyazocli')
|
|
1702
|
+
.option('--url <url>', 'source URL (sent as referer_url)')
|
|
1703
|
+
.option('--timestamp <unix_timestamp>', 'created_at unix timestamp (current or past)')
|
|
1704
|
+
.option('--desc <desc>', 'image description')
|
|
1705
|
+
.action(async (inputPath, options) => {
|
|
1706
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1707
|
+
let imageData;
|
|
1708
|
+
let filename = 'stdin-upload.bin';
|
|
1709
|
+
if (inputPath && inputPath !== '-') {
|
|
1710
|
+
const resolvedPath = path_1.default.resolve(inputPath);
|
|
1711
|
+
if (!fs_1.default.existsSync(resolvedPath)) {
|
|
1712
|
+
console.error(`Error: File not found: ${resolvedPath}`);
|
|
1713
|
+
process.exit(1);
|
|
1714
|
+
}
|
|
1715
|
+
imageData = fs_1.default.readFileSync(resolvedPath);
|
|
1716
|
+
filename = path_1.default.basename(resolvedPath);
|
|
1717
|
+
}
|
|
1718
|
+
else {
|
|
1719
|
+
if (process.stdin.isTTY) {
|
|
1720
|
+
console.error('Error: Provide an image path or pipe image data via stdin.');
|
|
1721
|
+
console.error('Hint: Run `gyazo upload -h` for usage.');
|
|
1722
|
+
process.exit(1);
|
|
1723
|
+
}
|
|
1724
|
+
imageData = await readStdinBuffer();
|
|
1725
|
+
if (imageData.length === 0) {
|
|
1726
|
+
console.error('Error: No image data received from stdin.');
|
|
1727
|
+
process.exit(1);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
const desc = ensureUploadDescTag(options.desc);
|
|
1731
|
+
const timestamp = parseUploadTimestamp(options.timestamp);
|
|
1732
|
+
try {
|
|
1733
|
+
const uploaded = await (0, api_1.uploadImage)({
|
|
1734
|
+
imageData,
|
|
1735
|
+
filename,
|
|
1736
|
+
title: options.title,
|
|
1737
|
+
app: options.app || 'gyazocli',
|
|
1738
|
+
refererUrl: options.url,
|
|
1739
|
+
desc,
|
|
1740
|
+
timestamp,
|
|
1741
|
+
});
|
|
1742
|
+
console.log(`URL: ${uploaded.permalink_url}`);
|
|
1743
|
+
console.log(`ID: ${uploaded.image_id}`);
|
|
1744
|
+
if (uploaded.created_at) {
|
|
1745
|
+
console.log(`Created at: ${formatCreatedAt(uploaded.created_at)}`);
|
|
1746
|
+
}
|
|
1747
|
+
console.log(`App: ${options.app || 'gyazocli'}`);
|
|
1748
|
+
console.log(`Desc: ${desc}`);
|
|
1749
|
+
}
|
|
1750
|
+
catch (error) {
|
|
1751
|
+
console.error('Error uploading image:', error.message);
|
|
1752
|
+
process.exit(1);
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
program
|
|
1756
|
+
.command('sync')
|
|
1757
|
+
.description('Sync images from yesterday back to N days')
|
|
1758
|
+
.option('--days <number>', 'number of days to sync (used when --date is omitted)')
|
|
1759
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'sync only this date/month/year range')
|
|
1760
|
+
.option('--max-pages <number>', 'max pages to fetch', '10')
|
|
1761
|
+
.action(async (options) => {
|
|
1762
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
1763
|
+
if (options.date && options.days) {
|
|
1764
|
+
console.error('Error: --date and --days cannot be used together.');
|
|
1765
|
+
process.exit(1);
|
|
1766
|
+
}
|
|
1767
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
1768
|
+
let startDate;
|
|
1769
|
+
let endDate;
|
|
1770
|
+
if (options.date) {
|
|
1771
|
+
const parsed = parseDateOption(options.date);
|
|
1772
|
+
startDate = parsed.start;
|
|
1773
|
+
endDate = parsed.end;
|
|
1774
|
+
}
|
|
1775
|
+
else {
|
|
1776
|
+
const days = options.days ? parsePositiveIntegerOption(options.days, '--days') : 1;
|
|
1777
|
+
const now = new Date();
|
|
1778
|
+
endDate = new Date(now);
|
|
1779
|
+
endDate.setDate(endDate.getDate() - 1);
|
|
1780
|
+
endDate.setHours(23, 59, 59, 999);
|
|
1781
|
+
startDate = new Date(now);
|
|
1782
|
+
startDate.setDate(startDate.getDate() - days - 1);
|
|
1783
|
+
startDate.setHours(0, 0, 0, 0);
|
|
1784
|
+
}
|
|
1785
|
+
console.log(`Syncing images between ${startDate.toISOString()} and ${endDate.toISOString()}...`);
|
|
1786
|
+
const hourlyIndices = new Map();
|
|
1787
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
1788
|
+
const images = await (0, api_1.listImages)(page, 100);
|
|
1789
|
+
if (images.length === 0)
|
|
1790
|
+
break;
|
|
1791
|
+
let reachedLimit = false;
|
|
1792
|
+
for (const img of images) {
|
|
1793
|
+
const createdAt = new Date(img.created_at);
|
|
1794
|
+
if (createdAt > endDate) {
|
|
1795
|
+
// Skip images newer than target range.
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
if (createdAt < startDate) {
|
|
1799
|
+
reachedLimit = true;
|
|
1800
|
+
break;
|
|
1801
|
+
}
|
|
1802
|
+
// Add to hourly index
|
|
1803
|
+
const y = createdAt.getFullYear().toString();
|
|
1804
|
+
const m = (createdAt.getMonth() + 1).toString().padStart(2, '0');
|
|
1805
|
+
const d = createdAt.getDate().toString().padStart(2, '0');
|
|
1806
|
+
const h = createdAt.getHours().toString().padStart(2, '0');
|
|
1807
|
+
const key = `${y}-${m}-${d}-${h}`;
|
|
1808
|
+
if (!hourlyIndices.has(key))
|
|
1809
|
+
hourlyIndices.set(key, new Set());
|
|
1810
|
+
hourlyIndices.get(key)?.add(img.image_id);
|
|
1811
|
+
const cached = (0, storage_1.loadImageCache)(img.image_id);
|
|
1812
|
+
if (cached && cached.ocr) {
|
|
1813
|
+
process.stdout.write(`s`);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
process.stdout.write(`.`);
|
|
1817
|
+
try {
|
|
1818
|
+
const detail = await (0, api_1.getImageDetail)(img.image_id);
|
|
1819
|
+
(0, storage_1.saveImageCache)(img.image_id, detail);
|
|
1820
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
1821
|
+
}
|
|
1822
|
+
catch (e) {
|
|
1823
|
+
process.stdout.write(`x`);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
console.log(`\nPage ${page} processed.`);
|
|
1827
|
+
if (reachedLimit)
|
|
1828
|
+
break;
|
|
1829
|
+
}
|
|
1830
|
+
// Save hourly indices
|
|
1831
|
+
console.log(`Updating hourly indices...`);
|
|
1832
|
+
for (const [key, ids] of hourlyIndices.entries()) {
|
|
1833
|
+
const [y, m, d, h] = key.split('-');
|
|
1834
|
+
const existing = (0, storage_1.loadHourlyCache)(y, m, d, h) || [];
|
|
1835
|
+
const merged = Array.from(new Set([...existing, ...ids]));
|
|
1836
|
+
(0, storage_1.saveHourlyCache)(y, m, d, h, merged);
|
|
1837
|
+
}
|
|
1838
|
+
console.log(`Sync complete.`);
|
|
1839
|
+
});
|
|
1840
|
+
program
|
|
1841
|
+
.command('import <type> <dir>')
|
|
1842
|
+
.description('Import legacy data (type: json|hourly)')
|
|
1843
|
+
.action(async (type, dir) => {
|
|
1844
|
+
const sourceDir = path_1.default.resolve(dir);
|
|
1845
|
+
if (!fs_1.default.existsSync(sourceDir)) {
|
|
1846
|
+
console.error(`Error: Source directory ${sourceDir} does not exist.`);
|
|
1847
|
+
process.exit(1);
|
|
1848
|
+
}
|
|
1849
|
+
if (type === 'json') {
|
|
1850
|
+
const targetDir = path_1.default.join((0, storage_1.getCacheDir)(), 'images');
|
|
1851
|
+
console.log(`Importing legacy Gyazo JSON from ${sourceDir}...`);
|
|
1852
|
+
let total = 0;
|
|
1853
|
+
const walk = (d) => {
|
|
1854
|
+
fs_1.default.readdirSync(d, { withFileTypes: true }).forEach(e => {
|
|
1855
|
+
const p = path_1.default.join(d, e.name);
|
|
1856
|
+
if (e.isDirectory())
|
|
1857
|
+
walk(p);
|
|
1858
|
+
else if (e.name.endsWith('.json')) {
|
|
1859
|
+
const id = e.name.replace('.json', '');
|
|
1860
|
+
const p1 = id[0] || '_', p2 = id[1] || '_';
|
|
1861
|
+
const dest = path_1.default.join(targetDir, p1, p2);
|
|
1862
|
+
if (!fs_1.default.existsSync(dest))
|
|
1863
|
+
fs_1.default.mkdirSync(dest, { recursive: true });
|
|
1864
|
+
fs_1.default.copyFileSync(p, path_1.default.join(dest, e.name));
|
|
1865
|
+
total++;
|
|
1866
|
+
if (total % 100 === 0)
|
|
1867
|
+
process.stdout.write('.');
|
|
1868
|
+
}
|
|
1869
|
+
});
|
|
1870
|
+
};
|
|
1871
|
+
walk(sourceDir);
|
|
1872
|
+
console.log(`\nImport complete. Copied ${total} files.`);
|
|
1873
|
+
}
|
|
1874
|
+
else if (type === 'hourly') {
|
|
1875
|
+
console.log(`Importing legacy Gyazo hourly data from ${sourceDir}...`);
|
|
1876
|
+
let total = 0;
|
|
1877
|
+
const years = fs_1.default.readdirSync(sourceDir).filter(f => /^[0-9]{4}$/.test(f));
|
|
1878
|
+
for (const y of years) {
|
|
1879
|
+
const months = fs_1.default.readdirSync(path_1.default.join(sourceDir, y)).filter(f => /^[0-9]{2}$/.test(f));
|
|
1880
|
+
for (const m of months) {
|
|
1881
|
+
const days = fs_1.default.readdirSync(path_1.default.join(sourceDir, y, m)).filter(f => /^[0-9]{2}$/.test(f));
|
|
1882
|
+
for (const d of days) {
|
|
1883
|
+
const hours = fs_1.default.readdirSync(path_1.default.join(sourceDir, y, m, d)).filter(f => /^[0-9]{2}$/.test(f));
|
|
1884
|
+
for (const h of hours) {
|
|
1885
|
+
const txt = path_1.default.join(sourceDir, y, m, d, h, 'image_ids.txt');
|
|
1886
|
+
if (fs_1.default.existsSync(txt)) {
|
|
1887
|
+
const ids = fs_1.default.readFileSync(txt, 'utf-8').split('\n').map(id => id.trim()).filter(id => id.length > 0);
|
|
1888
|
+
(0, storage_1.saveHourlyCache)(y, m, d, h, ids);
|
|
1889
|
+
total++;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
process.stdout.write('.');
|
|
1895
|
+
}
|
|
1896
|
+
console.log(`\nImport complete. Copied ${total} hourly index files.`);
|
|
1897
|
+
}
|
|
1898
|
+
else {
|
|
1899
|
+
console.error('Error: type must be "json" or "hourly"');
|
|
1900
|
+
process.exit(1);
|
|
1901
|
+
}
|
|
1902
|
+
});
|
|
1903
|
+
program.parseAsync(process.argv);
|