@yuiseki/gyazocli 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -3
- package/dist/api.js +43 -13
- package/dist/commands/apps.js +72 -0
- package/dist/commands/collection.js +45 -0
- package/dist/commands/config.js +64 -0
- package/dist/commands/domains.js +72 -0
- package/dist/commands/get.js +70 -0
- package/dist/commands/import.js +74 -0
- package/dist/commands/list.js +101 -0
- package/dist/commands/locations.js +72 -0
- package/dist/commands/search.js +43 -0
- package/dist/commands/stats.js +81 -0
- package/dist/commands/summary.js +42 -0
- package/dist/commands/sync.js +95 -0
- package/dist/commands/tags.js +70 -0
- package/dist/commands/upload.js +72 -0
- package/dist/config.js +6 -0
- package/dist/credentials.js +13 -1
- package/dist/dates.js +250 -0
- package/dist/format.js +375 -0
- package/dist/ids.js +77 -0
- package/dist/index.js +87 -2257
- package/dist/mcp.js +328 -0
- package/dist/options.js +20 -0
- package/dist/services/analytics.js +474 -0
- package/dist/services/collections.js +97 -0
- package/dist/services/images.js +184 -0
- package/dist/services/memory.js +373 -0
- package/docs/ADR/003-cli-structure.md +3 -1
- package/docs/ADR/004-mcp-server.md +88 -0
- package/docs/ADR/005-module-layout.md +72 -0
- package/package.json +11 -1
package/dist/dates.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Dates, as this CLI means them: a day is a local day, because that is how the
|
|
4
|
+
* cache is laid out and how a person asking for "today" means it. Parses what
|
|
5
|
+
* --date and --days accept, builds the ranges the aggregations walk, and keys
|
|
6
|
+
* the hourly cache buckets.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.DATE_OPTION_PROBLEMS = exports.WEEKDAY_LABELS = void 0;
|
|
10
|
+
exports.isToday = isToday;
|
|
11
|
+
exports.parseUploadTimestamp = parseUploadTimestamp;
|
|
12
|
+
exports.tryParseDateOption = tryParseDateOption;
|
|
13
|
+
exports.parseDateOption = parseDateOption;
|
|
14
|
+
exports.formatDateYmd = formatDateYmd;
|
|
15
|
+
exports.buildRecentWeekRangeUntilYesterday = buildRecentWeekRangeUntilYesterday;
|
|
16
|
+
exports.resolveRankingRangeOption = resolveRankingRangeOption;
|
|
17
|
+
exports.buildStatsDateRange = buildStatsDateRange;
|
|
18
|
+
exports.getDateHourStrings = getDateHourStrings;
|
|
19
|
+
exports.buildHourlyBucketKey = buildHourlyBucketKey;
|
|
20
|
+
exports.splitHourlyBucketKey = splitHourlyBucketKey;
|
|
21
|
+
exports.toDateParts = toDateParts;
|
|
22
|
+
exports.getDatePartsInRange = getDatePartsInRange;
|
|
23
|
+
exports.parseHourOption = parseHourOption;
|
|
24
|
+
const options_1 = require("./options");
|
|
25
|
+
exports.WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
26
|
+
function isToday(date) {
|
|
27
|
+
const today = new Date();
|
|
28
|
+
return date.getDate() === today.getDate() &&
|
|
29
|
+
date.getMonth() === today.getMonth() &&
|
|
30
|
+
date.getFullYear() === today.getFullYear();
|
|
31
|
+
}
|
|
32
|
+
function parseUploadTimestamp(value) {
|
|
33
|
+
if (!value)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (!/^\d+$/.test(value)) {
|
|
36
|
+
console.error('Error: --timestamp must be a unix timestamp in seconds.');
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const parsed = Number(value);
|
|
40
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
41
|
+
console.error('Error: --timestamp is out of range.');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
const now = Math.floor(Date.now() / 1000);
|
|
45
|
+
if (parsed > now) {
|
|
46
|
+
console.error('Error: --timestamp must be current time or in the past.');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Reads a date the way --date accepts it, without deciding what to do when it
|
|
53
|
+
* cannot: the CLI reports and exits, the MCP server throws.
|
|
54
|
+
*/
|
|
55
|
+
function tryParseDateOption(value) {
|
|
56
|
+
if (!value) {
|
|
57
|
+
const today = new Date();
|
|
58
|
+
const year = String(today.getFullYear());
|
|
59
|
+
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
60
|
+
const day = String(today.getDate()).padStart(2, '0');
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
value: {
|
|
64
|
+
granularity: 'day',
|
|
65
|
+
dateKey: `${year}-${month}-${day}`,
|
|
66
|
+
start: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 0, 0, 0, 0),
|
|
67
|
+
end: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (/^\d{4}$/.test(value)) {
|
|
72
|
+
const year = Number(value);
|
|
73
|
+
return {
|
|
74
|
+
ok: true,
|
|
75
|
+
value: {
|
|
76
|
+
granularity: 'year',
|
|
77
|
+
dateKey: value,
|
|
78
|
+
start: new Date(year, 0, 1, 0, 0, 0, 0),
|
|
79
|
+
end: new Date(year, 11, 31, 23, 59, 59, 999),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (/^\d{4}-\d{2}$/.test(value)) {
|
|
84
|
+
const [yearText, monthText] = value.split('-');
|
|
85
|
+
const year = Number(yearText);
|
|
86
|
+
const month = Number(monthText);
|
|
87
|
+
const probe = new Date(year, month - 1, 1);
|
|
88
|
+
if (probe.getFullYear() !== year || probe.getMonth() !== month - 1) {
|
|
89
|
+
return { ok: false, problem: 'month' };
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
value: {
|
|
94
|
+
granularity: 'month',
|
|
95
|
+
dateKey: value,
|
|
96
|
+
start: new Date(year, month - 1, 1, 0, 0, 0, 0),
|
|
97
|
+
end: new Date(year, month, 0, 23, 59, 59, 999),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
102
|
+
const [yearText, monthText, dayText] = value.split('-');
|
|
103
|
+
const year = Number(yearText);
|
|
104
|
+
const month = Number(monthText);
|
|
105
|
+
const day = Number(dayText);
|
|
106
|
+
const probe = new Date(year, month - 1, day);
|
|
107
|
+
if (probe.getFullYear() !== year ||
|
|
108
|
+
probe.getMonth() !== month - 1 ||
|
|
109
|
+
probe.getDate() !== day) {
|
|
110
|
+
return { ok: false, problem: 'day' };
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: true,
|
|
114
|
+
value: {
|
|
115
|
+
granularity: 'day',
|
|
116
|
+
dateKey: value,
|
|
117
|
+
start: new Date(year, month - 1, day, 0, 0, 0, 0),
|
|
118
|
+
end: new Date(year, month - 1, day, 23, 59, 59, 999),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { ok: false, problem: 'format' };
|
|
123
|
+
}
|
|
124
|
+
exports.DATE_OPTION_PROBLEMS = {
|
|
125
|
+
month: '--date month is invalid.',
|
|
126
|
+
day: '--date day is invalid.',
|
|
127
|
+
format: '--date format must be yyyy or yyyy-mm or yyyy-mm-dd.',
|
|
128
|
+
};
|
|
129
|
+
function parseDateOption(value) {
|
|
130
|
+
const parsed = tryParseDateOption(value);
|
|
131
|
+
if (!parsed.ok) {
|
|
132
|
+
console.error(`Error: ${exports.DATE_OPTION_PROBLEMS[parsed.problem]}`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
return parsed.value;
|
|
136
|
+
}
|
|
137
|
+
function formatDateYmd(date) {
|
|
138
|
+
const year = String(date.getFullYear());
|
|
139
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
140
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
141
|
+
return `${year}-${month}-${day}`;
|
|
142
|
+
}
|
|
143
|
+
function buildRecentWeekRangeUntilYesterday() {
|
|
144
|
+
const today = new Date();
|
|
145
|
+
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1, 23, 59, 59, 999);
|
|
146
|
+
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 8, 0, 0, 0, 0);
|
|
147
|
+
return {
|
|
148
|
+
granularity: 'day',
|
|
149
|
+
dateKey: `${formatDateYmd(start)}..${formatDateYmd(end)}`,
|
|
150
|
+
start,
|
|
151
|
+
end,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function resolveRankingRangeOption(options) {
|
|
155
|
+
if (options.today && options.date) {
|
|
156
|
+
console.error('Error: --today and --date cannot be used together.');
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
if (options.today) {
|
|
160
|
+
return parseDateOption();
|
|
161
|
+
}
|
|
162
|
+
if (options.date) {
|
|
163
|
+
return parseDateOption(options.date);
|
|
164
|
+
}
|
|
165
|
+
return buildRecentWeekRangeUntilYesterday();
|
|
166
|
+
}
|
|
167
|
+
function buildStatsDateRange(dateOption, daysOption) {
|
|
168
|
+
if (!dateOption && daysOption === '7') {
|
|
169
|
+
const weekly = buildRecentWeekRangeUntilYesterday();
|
|
170
|
+
return {
|
|
171
|
+
range: weekly,
|
|
172
|
+
days: 7,
|
|
173
|
+
startLabel: formatDateYmd(weekly.start),
|
|
174
|
+
endLabel: formatDateYmd(weekly.end),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const days = (0, options_1.parsePositiveIntegerOption)(daysOption, '--days');
|
|
178
|
+
let endDate;
|
|
179
|
+
if (dateOption) {
|
|
180
|
+
const parsed = parseDateOption(dateOption);
|
|
181
|
+
endDate = new Date(parsed.end);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const now = new Date();
|
|
185
|
+
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999);
|
|
186
|
+
}
|
|
187
|
+
const startDate = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate(), 0, 0, 0, 0);
|
|
188
|
+
startDate.setDate(startDate.getDate() - (days - 1));
|
|
189
|
+
const startLabel = formatDateYmd(startDate);
|
|
190
|
+
const endLabel = formatDateYmd(endDate);
|
|
191
|
+
return {
|
|
192
|
+
range: {
|
|
193
|
+
granularity: 'day',
|
|
194
|
+
dateKey: `${startLabel}..${endLabel}`,
|
|
195
|
+
start: startDate,
|
|
196
|
+
end: endDate,
|
|
197
|
+
},
|
|
198
|
+
days,
|
|
199
|
+
startLabel,
|
|
200
|
+
endLabel,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function getDateHourStrings() {
|
|
204
|
+
const hours = [];
|
|
205
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
206
|
+
hours.push(String(hour).padStart(2, '0'));
|
|
207
|
+
}
|
|
208
|
+
return hours;
|
|
209
|
+
}
|
|
210
|
+
function buildHourlyBucketKey(year, month, day, hour) {
|
|
211
|
+
return `${year}-${month}-${day}-${hour}`;
|
|
212
|
+
}
|
|
213
|
+
function splitHourlyBucketKey(key) {
|
|
214
|
+
const [year, month, day, hour] = key.split('-');
|
|
215
|
+
return { year, month, day, hour };
|
|
216
|
+
}
|
|
217
|
+
function toDateParts(date) {
|
|
218
|
+
return {
|
|
219
|
+
year: String(date.getFullYear()),
|
|
220
|
+
month: String(date.getMonth() + 1).padStart(2, '0'),
|
|
221
|
+
day: String(date.getDate()).padStart(2, '0'),
|
|
222
|
+
hour: String(date.getHours()).padStart(2, '0'),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
function getDatePartsInRange(start, end) {
|
|
226
|
+
const dates = [];
|
|
227
|
+
const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0);
|
|
228
|
+
const last = new Date(end.getFullYear(), end.getMonth(), end.getDate(), 0, 0, 0, 0);
|
|
229
|
+
while (cursor.getTime() <= last.getTime()) {
|
|
230
|
+
dates.push({
|
|
231
|
+
year: String(cursor.getFullYear()),
|
|
232
|
+
month: String(cursor.getMonth() + 1).padStart(2, '0'),
|
|
233
|
+
day: String(cursor.getDate()).padStart(2, '0'),
|
|
234
|
+
});
|
|
235
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
236
|
+
}
|
|
237
|
+
return dates;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* `yyyy-mm-dd-hh`, the shape the hourly cache is keyed by. Returns null rather
|
|
241
|
+
* than reporting, because how to report differs between the CLI and the MCP
|
|
242
|
+
* server.
|
|
243
|
+
*/
|
|
244
|
+
function parseHourOption(value) {
|
|
245
|
+
const parts = (value || '').split('-');
|
|
246
|
+
if (parts.length !== 4)
|
|
247
|
+
return null;
|
|
248
|
+
const [year, month, day, hour] = parts;
|
|
249
|
+
return { year, month, day, hour };
|
|
250
|
+
}
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reading and presenting the fields of a Gyazo image: the domain a capture
|
|
4
|
+
* came from, the address recorded in its EXIF, its OCR text, the objects
|
|
5
|
+
* detected in it. Nothing here talks to the API or the cache, so both the CLI
|
|
6
|
+
* and the aggregations can share it.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.normalizeText = normalizeText;
|
|
10
|
+
exports.extractDomain = extractDomain;
|
|
11
|
+
exports.isXDomain = isXDomain;
|
|
12
|
+
exports.cleanTextForDomain = cleanTextForDomain;
|
|
13
|
+
exports.stripInlineUrls = stripInlineUrls;
|
|
14
|
+
exports.sanitizeSummaryText = sanitizeSummaryText;
|
|
15
|
+
exports.getAddressEntry = getAddressEntry;
|
|
16
|
+
exports.getAddressComponent = getAddressComponent;
|
|
17
|
+
exports.buildJaLocationLabel = buildJaLocationLabel;
|
|
18
|
+
exports.buildEnLocationLabel = buildEnLocationLabel;
|
|
19
|
+
exports.extractImageAddressText = extractImageAddressText;
|
|
20
|
+
exports.extractImageLocationLabel = extractImageLocationLabel;
|
|
21
|
+
exports.truncateText = truncateText;
|
|
22
|
+
exports.formatCreatedAt = formatCreatedAt;
|
|
23
|
+
exports.shortenImageId = shortenImageId;
|
|
24
|
+
exports.formatTerminalLink = formatTerminalLink;
|
|
25
|
+
exports.normalizeOcrText = normalizeOcrText;
|
|
26
|
+
exports.extractOcrDescription = extractOcrDescription;
|
|
27
|
+
exports.buildOcrPreview = buildOcrPreview;
|
|
28
|
+
exports.extractObjectAnnotations = extractObjectAnnotations;
|
|
29
|
+
exports.formatObjectAnnotationLine = formatObjectAnnotationLine;
|
|
30
|
+
exports.extractImageApps = extractImageApps;
|
|
31
|
+
exports.extractImageDomains = extractImageDomains;
|
|
32
|
+
exports.extractImageLocations = extractImageLocations;
|
|
33
|
+
exports.normalizeTagText = normalizeTagText;
|
|
34
|
+
exports.extractTagFromLinkValue = extractTagFromLinkValue;
|
|
35
|
+
exports.extractImageTags = extractImageTags;
|
|
36
|
+
exports.normalizeRankingValues = normalizeRankingValues;
|
|
37
|
+
exports.shouldEnrichForLocationDisplay = shouldEnrichForLocationDisplay;
|
|
38
|
+
exports.mergeImageForDisplay = mergeImageForDisplay;
|
|
39
|
+
function normalizeText(value) {
|
|
40
|
+
if (!value)
|
|
41
|
+
return undefined;
|
|
42
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
43
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
44
|
+
}
|
|
45
|
+
function extractDomain(value) {
|
|
46
|
+
if (!value)
|
|
47
|
+
return undefined;
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(value);
|
|
50
|
+
return url.hostname.replace(/^www\./, '');
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(`https://${value}`);
|
|
55
|
+
return url.hostname.replace(/^www\./, '');
|
|
56
|
+
}
|
|
57
|
+
catch (_e) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function isXDomain(domain) {
|
|
63
|
+
if (!domain)
|
|
64
|
+
return false;
|
|
65
|
+
return domain === 'x.com' ||
|
|
66
|
+
domain.endsWith('.x.com') ||
|
|
67
|
+
domain === 'twitter.com' ||
|
|
68
|
+
domain.endsWith('.twitter.com');
|
|
69
|
+
}
|
|
70
|
+
function cleanTextForDomain(value, domain) {
|
|
71
|
+
if (!isXDomain(domain))
|
|
72
|
+
return value;
|
|
73
|
+
return value
|
|
74
|
+
.replace(/^Xユーザーの/, '')
|
|
75
|
+
.replace(/\s*\/\s*X$/, '')
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
function stripInlineUrls(value) {
|
|
79
|
+
return value
|
|
80
|
+
.replace(/https?:\/\/\S+/g, '')
|
|
81
|
+
.replace(/\bwww\.\S+/g, '')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
84
|
+
}
|
|
85
|
+
function sanitizeSummaryText(value, domain) {
|
|
86
|
+
if (!value)
|
|
87
|
+
return undefined;
|
|
88
|
+
return normalizeText(stripInlineUrls(cleanTextForDomain(value, domain)));
|
|
89
|
+
}
|
|
90
|
+
function getAddressEntry(exifAddress, locale) {
|
|
91
|
+
if (!exifAddress || typeof exifAddress !== 'object')
|
|
92
|
+
return undefined;
|
|
93
|
+
if (typeof exifAddress.address === 'string')
|
|
94
|
+
return exifAddress;
|
|
95
|
+
const entry = exifAddress[locale];
|
|
96
|
+
if (!entry || typeof entry !== 'object')
|
|
97
|
+
return undefined;
|
|
98
|
+
return entry;
|
|
99
|
+
}
|
|
100
|
+
function getAddressComponent(addressEntry, type) {
|
|
101
|
+
if (!addressEntry || typeof addressEntry !== 'object')
|
|
102
|
+
return undefined;
|
|
103
|
+
const components = Array.isArray(addressEntry.address_components)
|
|
104
|
+
? addressEntry.address_components
|
|
105
|
+
: [];
|
|
106
|
+
for (const component of components) {
|
|
107
|
+
if (!component || typeof component !== 'object')
|
|
108
|
+
continue;
|
|
109
|
+
const types = Array.isArray(component.types) ? component.types : [];
|
|
110
|
+
if (!types.includes(type))
|
|
111
|
+
continue;
|
|
112
|
+
const value = normalizeText(component.long_name || component.short_name);
|
|
113
|
+
if (value)
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
function buildJaLocationLabel(exifAddress) {
|
|
119
|
+
const ja = getAddressEntry(exifAddress, 'ja');
|
|
120
|
+
if (!ja)
|
|
121
|
+
return undefined;
|
|
122
|
+
const pref = getAddressComponent(ja, 'administrative_area_level_1');
|
|
123
|
+
const locality = getAddressComponent(ja, 'locality') || getAddressComponent(ja, 'administrative_area_level_2');
|
|
124
|
+
const sublocality = getAddressComponent(ja, 'sublocality_level_2') ||
|
|
125
|
+
getAddressComponent(ja, 'sublocality_level_1') ||
|
|
126
|
+
getAddressComponent(ja, 'sublocality_level_3');
|
|
127
|
+
const fromComponents = normalizeText([pref, locality, sublocality].filter(Boolean).join(''));
|
|
128
|
+
if (fromComponents)
|
|
129
|
+
return fromComponents;
|
|
130
|
+
const raw = normalizeText(ja.address);
|
|
131
|
+
if (!raw)
|
|
132
|
+
return undefined;
|
|
133
|
+
const compact = raw
|
|
134
|
+
.replace(/^日本、?/, '')
|
|
135
|
+
.replace(/〒\d{3}-\d{4}\s*/g, '')
|
|
136
|
+
.replace(/[0-90-9].*$/, '')
|
|
137
|
+
.trim();
|
|
138
|
+
return normalizeText(compact);
|
|
139
|
+
}
|
|
140
|
+
function buildEnLocationLabel(exifAddress) {
|
|
141
|
+
const en = getAddressEntry(exifAddress, 'en');
|
|
142
|
+
if (!en)
|
|
143
|
+
return undefined;
|
|
144
|
+
const pref = getAddressComponent(en, 'administrative_area_level_1');
|
|
145
|
+
const locality = getAddressComponent(en, 'locality') || getAddressComponent(en, 'administrative_area_level_2');
|
|
146
|
+
const sublocality = getAddressComponent(en, 'sublocality_level_2') ||
|
|
147
|
+
getAddressComponent(en, 'sublocality_level_1') ||
|
|
148
|
+
getAddressComponent(en, 'sublocality_level_3');
|
|
149
|
+
const fromComponents = normalizeText([sublocality, locality, pref].filter(Boolean).join(', '));
|
|
150
|
+
if (fromComponents)
|
|
151
|
+
return fromComponents;
|
|
152
|
+
return normalizeText(en.address);
|
|
153
|
+
}
|
|
154
|
+
function extractImageAddressText(img) {
|
|
155
|
+
const exifAddress = img.metadata?.exif_address ?? img.exif_address;
|
|
156
|
+
if (!exifAddress)
|
|
157
|
+
return undefined;
|
|
158
|
+
if (typeof exifAddress === 'string')
|
|
159
|
+
return normalizeText(exifAddress);
|
|
160
|
+
if (typeof exifAddress !== 'object')
|
|
161
|
+
return undefined;
|
|
162
|
+
const ja = getAddressEntry(exifAddress, 'ja');
|
|
163
|
+
const jaAddress = normalizeText(ja?.address);
|
|
164
|
+
if (jaAddress)
|
|
165
|
+
return jaAddress;
|
|
166
|
+
const en = getAddressEntry(exifAddress, 'en');
|
|
167
|
+
const enAddress = normalizeText(en?.address);
|
|
168
|
+
if (enAddress)
|
|
169
|
+
return enAddress;
|
|
170
|
+
for (const value of Object.values(exifAddress)) {
|
|
171
|
+
if (!value || typeof value !== 'object')
|
|
172
|
+
continue;
|
|
173
|
+
const raw = normalizeText(value.address);
|
|
174
|
+
if (raw)
|
|
175
|
+
return raw;
|
|
176
|
+
}
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
function extractImageLocationLabel(img) {
|
|
180
|
+
const exifAddress = img.metadata?.exif_address ?? img.exif_address;
|
|
181
|
+
if (!exifAddress)
|
|
182
|
+
return undefined;
|
|
183
|
+
if (typeof exifAddress === 'string')
|
|
184
|
+
return normalizeText(exifAddress);
|
|
185
|
+
if (typeof exifAddress !== 'object')
|
|
186
|
+
return undefined;
|
|
187
|
+
const jaLabel = buildJaLocationLabel(exifAddress);
|
|
188
|
+
if (jaLabel)
|
|
189
|
+
return jaLabel;
|
|
190
|
+
const enLabel = buildEnLocationLabel(exifAddress);
|
|
191
|
+
if (enLabel)
|
|
192
|
+
return enLabel;
|
|
193
|
+
for (const value of Object.values(exifAddress)) {
|
|
194
|
+
if (!value || typeof value !== 'object')
|
|
195
|
+
continue;
|
|
196
|
+
const raw = normalizeText(value.address);
|
|
197
|
+
if (raw)
|
|
198
|
+
return raw;
|
|
199
|
+
}
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
function truncateText(value, maxLength) {
|
|
203
|
+
if (value.length <= maxLength)
|
|
204
|
+
return value;
|
|
205
|
+
if (maxLength <= 3)
|
|
206
|
+
return value.slice(0, maxLength);
|
|
207
|
+
return `${value.slice(0, maxLength - 3)}...`;
|
|
208
|
+
}
|
|
209
|
+
function formatCreatedAt(value) {
|
|
210
|
+
const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})/);
|
|
211
|
+
if (match) {
|
|
212
|
+
return `${match[1]} ${match[2]}:${match[3]}`;
|
|
213
|
+
}
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
function shortenImageId(imageId) {
|
|
217
|
+
if (!imageId)
|
|
218
|
+
return '';
|
|
219
|
+
if (imageId.length <= 4)
|
|
220
|
+
return imageId;
|
|
221
|
+
return `${imageId.slice(0, 4)}...`;
|
|
222
|
+
}
|
|
223
|
+
function formatTerminalLink(label, url) {
|
|
224
|
+
if (!url || !process.stdout.isTTY)
|
|
225
|
+
return label;
|
|
226
|
+
return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
|
|
227
|
+
}
|
|
228
|
+
function normalizeOcrText(value) {
|
|
229
|
+
if (!value)
|
|
230
|
+
return undefined;
|
|
231
|
+
const normalized = value
|
|
232
|
+
.replace(/\r\n/g, '\n')
|
|
233
|
+
.replace(/\r/g, '\n')
|
|
234
|
+
.split('\n')
|
|
235
|
+
.map(line => line.trimEnd())
|
|
236
|
+
.join('\n')
|
|
237
|
+
.trim();
|
|
238
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
239
|
+
}
|
|
240
|
+
function extractOcrDescription(image) {
|
|
241
|
+
const direct = normalizeOcrText(image?.ocr?.description);
|
|
242
|
+
if (direct)
|
|
243
|
+
return direct;
|
|
244
|
+
return normalizeOcrText(image?.metadata?.ocr?.description);
|
|
245
|
+
}
|
|
246
|
+
function buildOcrPreview(ocrText, maxLines) {
|
|
247
|
+
const lines = ocrText.split('\n');
|
|
248
|
+
if (lines.length <= maxLines) {
|
|
249
|
+
return { text: ocrText, truncated: false };
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
text: lines.slice(0, maxLines).join('\n'),
|
|
253
|
+
truncated: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function extractObjectAnnotations(image) {
|
|
257
|
+
const rawAnnotations = image?.localizedObjectAnnotations ||
|
|
258
|
+
image?.localized_object_annotations ||
|
|
259
|
+
image?.metadata?.localizedObjectAnnotations ||
|
|
260
|
+
image?.metadata?.localized_object_annotations ||
|
|
261
|
+
[];
|
|
262
|
+
if (!Array.isArray(rawAnnotations))
|
|
263
|
+
return [];
|
|
264
|
+
const bestByName = new Map();
|
|
265
|
+
for (const annotation of rawAnnotations) {
|
|
266
|
+
if (!annotation || typeof annotation !== 'object')
|
|
267
|
+
continue;
|
|
268
|
+
const name = normalizeText(annotation.name_ja || annotation.nameJa || annotation.name);
|
|
269
|
+
if (!name)
|
|
270
|
+
continue;
|
|
271
|
+
const score = typeof annotation.score === 'number' ? annotation.score : undefined;
|
|
272
|
+
const existing = bestByName.get(name);
|
|
273
|
+
if (!existing) {
|
|
274
|
+
bestByName.set(name, { name, score });
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
const existingScore = existing.score ?? -1;
|
|
278
|
+
const nextScore = score ?? -1;
|
|
279
|
+
if (nextScore > existingScore) {
|
|
280
|
+
bestByName.set(name, { name, score });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return Array.from(bestByName.values()).sort((a, b) => {
|
|
284
|
+
const sa = a.score ?? -1;
|
|
285
|
+
const sb = b.score ?? -1;
|
|
286
|
+
return sb - sa;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
function formatObjectAnnotationLine(annotation) {
|
|
290
|
+
if (typeof annotation.score === 'number') {
|
|
291
|
+
return `${annotation.name} (${(annotation.score * 100).toFixed(1)}%)`;
|
|
292
|
+
}
|
|
293
|
+
return annotation.name;
|
|
294
|
+
}
|
|
295
|
+
function extractImageApps(image) {
|
|
296
|
+
const app = normalizeText(image?.metadata?.app);
|
|
297
|
+
return app ? [app] : [];
|
|
298
|
+
}
|
|
299
|
+
function extractImageDomains(image) {
|
|
300
|
+
const domain = extractDomain(normalizeText(image?.metadata?.url));
|
|
301
|
+
return domain ? [domain] : [];
|
|
302
|
+
}
|
|
303
|
+
function extractImageLocations(image) {
|
|
304
|
+
const location = normalizeText(extractImageLocationLabel(image));
|
|
305
|
+
return location ? [location] : [];
|
|
306
|
+
}
|
|
307
|
+
function normalizeTagText(value) {
|
|
308
|
+
if (!value)
|
|
309
|
+
return undefined;
|
|
310
|
+
const normalized = normalizeText(value);
|
|
311
|
+
if (!normalized)
|
|
312
|
+
return undefined;
|
|
313
|
+
const stripped = normalized.replace(/^[##]+/, '').trim();
|
|
314
|
+
return stripped.length > 0 ? stripped : undefined;
|
|
315
|
+
}
|
|
316
|
+
function extractTagFromLinkValue(value) {
|
|
317
|
+
if (typeof value === 'string') {
|
|
318
|
+
return normalizeTagText(value);
|
|
319
|
+
}
|
|
320
|
+
if (!value || typeof value !== 'object')
|
|
321
|
+
return undefined;
|
|
322
|
+
const candidates = [
|
|
323
|
+
value.tag,
|
|
324
|
+
value.name,
|
|
325
|
+
value.title,
|
|
326
|
+
value.text,
|
|
327
|
+
value.keyword,
|
|
328
|
+
];
|
|
329
|
+
for (const candidate of candidates) {
|
|
330
|
+
const tag = normalizeTagText(candidate);
|
|
331
|
+
if (tag)
|
|
332
|
+
return tag;
|
|
333
|
+
}
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
function extractImageTags(image) {
|
|
337
|
+
const rawLinks = image?.metadata?.links ?? image?.links;
|
|
338
|
+
if (!Array.isArray(rawLinks))
|
|
339
|
+
return [];
|
|
340
|
+
const tags = [];
|
|
341
|
+
for (const rawLink of rawLinks) {
|
|
342
|
+
const tag = extractTagFromLinkValue(rawLink);
|
|
343
|
+
if (tag)
|
|
344
|
+
tags.push(tag);
|
|
345
|
+
}
|
|
346
|
+
return normalizeRankingValues(tags);
|
|
347
|
+
}
|
|
348
|
+
function normalizeRankingValues(values) {
|
|
349
|
+
const uniqueByLower = new Map();
|
|
350
|
+
for (const raw of values) {
|
|
351
|
+
const value = normalizeText(raw);
|
|
352
|
+
if (!value)
|
|
353
|
+
continue;
|
|
354
|
+
const key = value.toLocaleLowerCase();
|
|
355
|
+
if (!uniqueByLower.has(key)) {
|
|
356
|
+
uniqueByLower.set(key, value);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return Array.from(uniqueByLower.values());
|
|
360
|
+
}
|
|
361
|
+
function shouldEnrichForLocationDisplay(img) {
|
|
362
|
+
const locationLabel = sanitizeSummaryText(extractImageLocationLabel(img));
|
|
363
|
+
return !locationLabel;
|
|
364
|
+
}
|
|
365
|
+
function mergeImageForDisplay(base, detail) {
|
|
366
|
+
return {
|
|
367
|
+
...base,
|
|
368
|
+
...detail,
|
|
369
|
+
metadata: {
|
|
370
|
+
...(base?.metadata || {}),
|
|
371
|
+
...(detail?.metadata || {}),
|
|
372
|
+
},
|
|
373
|
+
ocr: detail?.ocr ?? base?.ocr,
|
|
374
|
+
};
|
|
375
|
+
}
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Turning what a human or an agent typed into a Gyazo ID. Kept apart from the
|
|
4
|
+
* CLI itself so that other entry points, such as the MCP server, can reuse it
|
|
5
|
+
* without loading commander and its commands.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.normalizeImageId = normalizeImageId;
|
|
9
|
+
exports.normalizeCollectionId = normalizeCollectionId;
|
|
10
|
+
const IMAGE_ID_PATTERN = /^[0-9a-f]{32}$/i;
|
|
11
|
+
const GYAZO_HOST_PATTERN = /(^|\.)gyazo\.com$/i;
|
|
12
|
+
/**
|
|
13
|
+
* Accept either a bare Gyazo image id (32 hex characters) or any Gyazo URL that
|
|
14
|
+
* carries one, and return the canonical lowercase id. Returns null otherwise.
|
|
15
|
+
*/
|
|
16
|
+
function normalizeImageId(input) {
|
|
17
|
+
const trimmed = (input || '').trim();
|
|
18
|
+
if (!trimmed)
|
|
19
|
+
return null;
|
|
20
|
+
if (IMAGE_ID_PATTERN.test(trimmed)) {
|
|
21
|
+
return trimmed.toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(trimmed);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (!GYAZO_HOST_PATTERN.test(url.hostname)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
37
|
+
const lastSegment = segments[segments.length - 1];
|
|
38
|
+
if (!lastSegment)
|
|
39
|
+
return null;
|
|
40
|
+
// /collections/<id> is a collection, not an image.
|
|
41
|
+
if (segments[segments.length - 2] === 'collections')
|
|
42
|
+
return null;
|
|
43
|
+
const withoutExtension = lastSegment.replace(/\.[a-z0-9]+$/i, '');
|
|
44
|
+
return IMAGE_ID_PATTERN.test(withoutExtension) ? withoutExtension.toLowerCase() : null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A collection ID looks exactly like an image ID (32 hex characters), so only
|
|
48
|
+
* the URL form tells the two apart. `/collections/<id>` is a collection;
|
|
49
|
+
* `/<id>` is an image.
|
|
50
|
+
*/
|
|
51
|
+
function normalizeCollectionId(input) {
|
|
52
|
+
const trimmed = (input || '').trim();
|
|
53
|
+
if (!trimmed)
|
|
54
|
+
return null;
|
|
55
|
+
if (IMAGE_ID_PATTERN.test(trimmed)) {
|
|
56
|
+
return trimmed.toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
let url;
|
|
62
|
+
try {
|
|
63
|
+
url = new URL(trimmed);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (!GYAZO_HOST_PATTERN.test(url.hostname)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
72
|
+
if (segments.length < 2 || segments[segments.length - 2] !== 'collections') {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const withoutExtension = segments[segments.length - 1].replace(/\.[a-z0-9]+$/i, '');
|
|
76
|
+
return IMAGE_ID_PATTERN.test(withoutExtension) ? withoutExtension.toLowerCase() : null;
|
|
77
|
+
}
|