@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UPLOAD_DESC_TAG = void 0;
|
|
4
|
+
exports.requireImageId = requireImageId;
|
|
5
|
+
exports.printGetMarkdown = printGetMarkdown;
|
|
6
|
+
exports.summarizeImageForList = summarizeImageForList;
|
|
7
|
+
exports.prepareImagesForDisplay = prepareImagesForDisplay;
|
|
8
|
+
exports.enrichImagesForLocationDisplay = enrichImagesForLocationDisplay;
|
|
9
|
+
exports.printListImages = printListImages;
|
|
10
|
+
exports.ensureUploadDescTag = ensureUploadDescTag;
|
|
11
|
+
exports.readStdinBuffer = readStdinBuffer;
|
|
12
|
+
/**
|
|
13
|
+
* Showing captures to a person: one line each for a list, a markdown block for
|
|
14
|
+
* a single capture, and the enrichment that has to happen first. A search
|
|
15
|
+
* result carries less than an image detail does, so a location worth printing
|
|
16
|
+
* sometimes means fetching the detail before printing anything.
|
|
17
|
+
*/
|
|
18
|
+
const api_1 = require("../api");
|
|
19
|
+
const storage_1 = require("../storage");
|
|
20
|
+
const ids_1 = require("../ids");
|
|
21
|
+
const format_1 = require("../format");
|
|
22
|
+
const memory_1 = require("./memory");
|
|
23
|
+
function requireImageId(input) {
|
|
24
|
+
const imageId = (0, ids_1.normalizeImageId)(input);
|
|
25
|
+
if (!imageId) {
|
|
26
|
+
console.error(`Error: '${input}' is not a Gyazo image ID or URL.`);
|
|
27
|
+
if ((0, ids_1.normalizeCollectionId)(input)) {
|
|
28
|
+
console.error(`Hint: that looks like a collection. Try \`gyazo collection ${input}\`.`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
console.error('Hint: pass a 32-character image ID or a https://gyazo.com/<id> URL.');
|
|
32
|
+
}
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
return imageId;
|
|
36
|
+
}
|
|
37
|
+
function printGetMarkdown(image, ocrDescription, objects = []) {
|
|
38
|
+
const lines = [];
|
|
39
|
+
lines.push('## Gyazo Image');
|
|
40
|
+
lines.push('');
|
|
41
|
+
lines.push(`- URL: <${image.permalink_url}>`);
|
|
42
|
+
lines.push(`- Created at: ${(0, format_1.formatCreatedAt)(image.created_at)}`);
|
|
43
|
+
const title = (0, format_1.normalizeText)(image.metadata?.title);
|
|
44
|
+
if (title)
|
|
45
|
+
lines.push(`- Title: ${title}`);
|
|
46
|
+
const address = (0, format_1.extractImageAddressText)(image);
|
|
47
|
+
if (address)
|
|
48
|
+
lines.push(`- Address: ${address}`);
|
|
49
|
+
const altText = (0, format_1.normalizeText)(image.alt_text);
|
|
50
|
+
if (altText)
|
|
51
|
+
lines.push(`- Alt text: ${altText}`);
|
|
52
|
+
if (objects.length > 0) {
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('### Objects');
|
|
55
|
+
for (const object of objects) {
|
|
56
|
+
lines.push(`- ${(0, format_1.formatObjectAnnotationLine)(object)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (ocrDescription) {
|
|
60
|
+
const preview = (0, format_1.buildOcrPreview)(ocrDescription, 5);
|
|
61
|
+
lines.push('');
|
|
62
|
+
lines.push('### OCR');
|
|
63
|
+
lines.push('```text');
|
|
64
|
+
lines.push(preview.text);
|
|
65
|
+
lines.push('```');
|
|
66
|
+
if (preview.truncated) {
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push(`> Truncated to first 5 lines. Use \`gyazo get --ocr ${image.image_id}\` for full text.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
console.log(lines.join('\n'));
|
|
72
|
+
}
|
|
73
|
+
function summarizeImageForList(img) {
|
|
74
|
+
const domain = (0, format_1.extractDomain)((0, format_1.normalizeText)(img.metadata?.url));
|
|
75
|
+
const cleanedTitle = (0, format_1.sanitizeSummaryText)(img.metadata?.title, domain);
|
|
76
|
+
const cleanedDesc = (0, format_1.sanitizeSummaryText)(img.metadata?.desc ?? img.desc, domain);
|
|
77
|
+
const locationLabel = (0, format_1.sanitizeSummaryText)((0, format_1.extractImageLocationLabel)(img));
|
|
78
|
+
const cleanedAltText = (0, format_1.sanitizeSummaryText)(img.alt_text);
|
|
79
|
+
let main = '(no title/description)';
|
|
80
|
+
if (cleanedTitle && cleanedDesc) {
|
|
81
|
+
main = `${cleanedTitle} | ${cleanedDesc}`;
|
|
82
|
+
}
|
|
83
|
+
else if (cleanedTitle) {
|
|
84
|
+
main = cleanedTitle;
|
|
85
|
+
}
|
|
86
|
+
else if (cleanedDesc) {
|
|
87
|
+
main = cleanedDesc;
|
|
88
|
+
}
|
|
89
|
+
if (cleanedAltText) {
|
|
90
|
+
if (main === '(no title/description)') {
|
|
91
|
+
main = cleanedAltText;
|
|
92
|
+
}
|
|
93
|
+
else if (cleanedAltText !== main) {
|
|
94
|
+
main = `${main} | alt: ${cleanedAltText}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const prefixes = [];
|
|
98
|
+
if (domain)
|
|
99
|
+
prefixes.push(`[${domain}]`);
|
|
100
|
+
if (locationLabel)
|
|
101
|
+
prefixes.push(`[${locationLabel}]`);
|
|
102
|
+
if (main === '(no title/description)') {
|
|
103
|
+
if (prefixes.length > 0)
|
|
104
|
+
return prefixes.join(' ');
|
|
105
|
+
return main;
|
|
106
|
+
}
|
|
107
|
+
if (prefixes.length > 0) {
|
|
108
|
+
return `${prefixes.join(' ')} ${main}`;
|
|
109
|
+
}
|
|
110
|
+
return main;
|
|
111
|
+
}
|
|
112
|
+
async function prepareImagesForDisplay(images, options = {}) {
|
|
113
|
+
const useCache = options.useCache !== false;
|
|
114
|
+
if (options.cacheSearchResults) {
|
|
115
|
+
(0, memory_1.cacheSearchResultImages)(images);
|
|
116
|
+
}
|
|
117
|
+
let prepared = images;
|
|
118
|
+
if (options.enrichLocation) {
|
|
119
|
+
prepared = await enrichImagesForLocationDisplay(prepared, useCache);
|
|
120
|
+
}
|
|
121
|
+
prepared = (0, memory_1.supplementAltTextForDisplay)(prepared, useCache);
|
|
122
|
+
return prepared;
|
|
123
|
+
}
|
|
124
|
+
async function enrichImagesForLocationDisplay(images, useCache = true) {
|
|
125
|
+
const enriched = [];
|
|
126
|
+
for (const img of images) {
|
|
127
|
+
let current = img;
|
|
128
|
+
if (!(0, format_1.shouldEnrichForLocationDisplay)(current)) {
|
|
129
|
+
enriched.push(current);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (useCache) {
|
|
133
|
+
const cached = (0, storage_1.loadImageCache)(img.image_id);
|
|
134
|
+
if (cached) {
|
|
135
|
+
current = (0, format_1.mergeImageForDisplay)(current, cached);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!(0, format_1.shouldEnrichForLocationDisplay)(current)) {
|
|
139
|
+
enriched.push(current);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const detail = await (0, api_1.getImageDetail)(img.image_id);
|
|
144
|
+
(0, storage_1.saveImageCache)(img.image_id, detail);
|
|
145
|
+
current = (0, format_1.mergeImageForDisplay)(current, detail);
|
|
146
|
+
}
|
|
147
|
+
catch (_error) {
|
|
148
|
+
// Keep current data when detail fetch fails.
|
|
149
|
+
}
|
|
150
|
+
enriched.push(current);
|
|
151
|
+
}
|
|
152
|
+
return enriched;
|
|
153
|
+
}
|
|
154
|
+
function printListImages(images) {
|
|
155
|
+
images.forEach(img => {
|
|
156
|
+
const summary = (0, format_1.truncateText)(summarizeImageForList(img), 120);
|
|
157
|
+
const created = (0, format_1.formatCreatedAt)(img.created_at);
|
|
158
|
+
const shortId = (0, format_1.shortenImageId)(img.image_id);
|
|
159
|
+
const imageUrl = img.permalink_url || `https://gyazo.com/${img.image_id}`;
|
|
160
|
+
const linkedId = (0, format_1.formatTerminalLink)(shortId, imageUrl);
|
|
161
|
+
console.log(`- [${created}] ${summary} (id: ${linkedId})`);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
exports.UPLOAD_DESC_TAG = '#gyazocli_uploads';
|
|
165
|
+
function ensureUploadDescTag(desc) {
|
|
166
|
+
const normalized = (0, format_1.normalizeText)(desc);
|
|
167
|
+
if (!normalized)
|
|
168
|
+
return exports.UPLOAD_DESC_TAG;
|
|
169
|
+
const words = normalized
|
|
170
|
+
.split(' ')
|
|
171
|
+
.filter(word => word.toLowerCase() !== exports.UPLOAD_DESC_TAG.toLowerCase());
|
|
172
|
+
words.push(exports.UPLOAD_DESC_TAG);
|
|
173
|
+
return words.join(' ').trim();
|
|
174
|
+
}
|
|
175
|
+
async function readStdinBuffer() {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const chunks = [];
|
|
178
|
+
process.stdin.on('data', (chunk) => {
|
|
179
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
180
|
+
});
|
|
181
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks)));
|
|
182
|
+
process.stdin.on('error', reject);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadImageIdsFromDateRangeCache = loadImageIdsFromDateRangeCache;
|
|
4
|
+
exports.normalizeHourlyMetadataEntries = normalizeHourlyMetadataEntries;
|
|
5
|
+
exports.warmDateCacheForApps = warmDateCacheForApps;
|
|
6
|
+
exports.warmDateCacheForDomains = warmDateCacheForDomains;
|
|
7
|
+
exports.warmDateCacheForTags = warmDateCacheForTags;
|
|
8
|
+
exports.warmDateCacheForLocations = warmDateCacheForLocations;
|
|
9
|
+
exports.warmDateCacheForList = warmDateCacheForList;
|
|
10
|
+
exports.warmDateCacheForRanking = warmDateCacheForRanking;
|
|
11
|
+
exports.buildHourlyMetadataEntriesFromImageCache = buildHourlyMetadataEntriesFromImageCache;
|
|
12
|
+
exports.loadOrBuildHourlyMetadataEntries = loadOrBuildHourlyMetadataEntries;
|
|
13
|
+
exports.cacheSearchResultImages = cacheSearchResultImages;
|
|
14
|
+
exports.supplementAltTextFromSearchCache = supplementAltTextFromSearchCache;
|
|
15
|
+
exports.supplementAltTextForDisplay = supplementAltTextForDisplay;
|
|
16
|
+
exports.listCaptures = listCaptures;
|
|
17
|
+
/**
|
|
18
|
+
* The memory this CLI keeps: the local cache of captures, and the walks over
|
|
19
|
+
* the Gyazo API that fill it. A command asks for a day or a range, and this
|
|
20
|
+
* layer answers from the cache when it can, fetching and writing through when
|
|
21
|
+
* it cannot.
|
|
22
|
+
*
|
|
23
|
+
* Two shapes are cached. Whole images, keyed by ID, and hourly buckets holding
|
|
24
|
+
* the IDs captured in that hour plus the metadata the rankings count, so a
|
|
25
|
+
* ranking does not have to open every image to answer.
|
|
26
|
+
*/
|
|
27
|
+
const api_1 = require("../api");
|
|
28
|
+
const storage_1 = require("../storage");
|
|
29
|
+
const dates_1 = require("../dates");
|
|
30
|
+
const format_1 = require("../format");
|
|
31
|
+
function loadImageIdsFromDateRangeCache(targetDate) {
|
|
32
|
+
const imageIds = new Set();
|
|
33
|
+
const dates = (0, dates_1.getDatePartsInRange)(targetDate.start, targetDate.end);
|
|
34
|
+
const hours = (0, dates_1.getDateHourStrings)();
|
|
35
|
+
for (const date of dates) {
|
|
36
|
+
for (const hour of hours) {
|
|
37
|
+
const ids = (0, storage_1.loadHourlyCache)(date.year, date.month, date.day, hour) || [];
|
|
38
|
+
for (const id of ids)
|
|
39
|
+
imageIds.add(id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Array.from(imageIds);
|
|
43
|
+
}
|
|
44
|
+
function normalizeHourlyMetadataEntries(valuesByImageId) {
|
|
45
|
+
if (!valuesByImageId || typeof valuesByImageId !== 'object')
|
|
46
|
+
return {};
|
|
47
|
+
const normalized = {};
|
|
48
|
+
for (const [imageId, rawValues] of Object.entries(valuesByImageId)) {
|
|
49
|
+
const values = Array.isArray(rawValues)
|
|
50
|
+
? rawValues.map(value => String(value))
|
|
51
|
+
: [];
|
|
52
|
+
normalized[imageId] = (0, format_1.normalizeRankingValues)(values);
|
|
53
|
+
}
|
|
54
|
+
return normalized;
|
|
55
|
+
}
|
|
56
|
+
async function warmDateCacheForApps(targetDate, maxPages, useCache) {
|
|
57
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'apps', format_1.extractImageApps);
|
|
58
|
+
}
|
|
59
|
+
async function warmDateCacheForDomains(targetDate, maxPages, useCache) {
|
|
60
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'domains', format_1.extractImageDomains);
|
|
61
|
+
}
|
|
62
|
+
async function warmDateCacheForTags(targetDate, maxPages, useCache) {
|
|
63
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'tags', format_1.extractImageTags);
|
|
64
|
+
}
|
|
65
|
+
async function warmDateCacheForLocations(targetDate, maxPages, useCache) {
|
|
66
|
+
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'locations', format_1.extractImageLocations);
|
|
67
|
+
}
|
|
68
|
+
async function warmDateCacheForList(targetDate, maxPages, useCache) {
|
|
69
|
+
const hourlyIndices = new Map();
|
|
70
|
+
const imageIds = new Set();
|
|
71
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
72
|
+
const images = await (0, api_1.listImages)(page, 100);
|
|
73
|
+
if (images.length === 0)
|
|
74
|
+
break;
|
|
75
|
+
let reachedLimit = false;
|
|
76
|
+
for (const img of images) {
|
|
77
|
+
const createdAt = new Date(img.created_at);
|
|
78
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
79
|
+
continue;
|
|
80
|
+
if (createdAt > targetDate.end)
|
|
81
|
+
continue;
|
|
82
|
+
if (createdAt < targetDate.start) {
|
|
83
|
+
reachedLimit = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
const dateParts = (0, dates_1.toDateParts)(createdAt);
|
|
87
|
+
const bucketKey = (0, dates_1.buildHourlyBucketKey)(dateParts.year, dateParts.month, dateParts.day, dateParts.hour);
|
|
88
|
+
if (!hourlyIndices.has(bucketKey)) {
|
|
89
|
+
hourlyIndices.set(bucketKey, new Set());
|
|
90
|
+
}
|
|
91
|
+
hourlyIndices.get(bucketKey)?.add(img.image_id);
|
|
92
|
+
imageIds.add(img.image_id);
|
|
93
|
+
let merged = img;
|
|
94
|
+
const cached = useCache ? (0, storage_1.loadImageCache)(img.image_id) : null;
|
|
95
|
+
if (cached) {
|
|
96
|
+
merged = (0, format_1.mergeImageForDisplay)(img, cached);
|
|
97
|
+
}
|
|
98
|
+
(0, storage_1.saveImageCache)(img.image_id, merged);
|
|
99
|
+
}
|
|
100
|
+
if (reachedLimit)
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
for (const [bucketKey, current] of hourlyIndices.entries()) {
|
|
104
|
+
const { year, month, day, hour } = (0, dates_1.splitHourlyBucketKey)(bucketKey);
|
|
105
|
+
if (useCache) {
|
|
106
|
+
const existing = (0, storage_1.loadHourlyCache)(year, month, day, hour) || [];
|
|
107
|
+
for (const id of existing)
|
|
108
|
+
current.add(id);
|
|
109
|
+
}
|
|
110
|
+
(0, storage_1.saveHourlyCache)(year, month, day, hour, Array.from(current));
|
|
111
|
+
for (const id of current)
|
|
112
|
+
imageIds.add(id);
|
|
113
|
+
}
|
|
114
|
+
if (useCache) {
|
|
115
|
+
for (const id of loadImageIdsFromDateRangeCache(targetDate)) {
|
|
116
|
+
imageIds.add(id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Array.from(imageIds);
|
|
120
|
+
}
|
|
121
|
+
async function warmDateCacheForRanking(targetDate, maxPages, useCache, metadataKind, extractValues) {
|
|
122
|
+
const hourlyIndices = new Map();
|
|
123
|
+
const hourlyMetadataEntries = new Map();
|
|
124
|
+
const existingHourlyMetadataEntries = new Map();
|
|
125
|
+
const imageIds = new Set();
|
|
126
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
127
|
+
const images = await (0, api_1.listImages)(page, 100);
|
|
128
|
+
if (images.length === 0)
|
|
129
|
+
break;
|
|
130
|
+
let reachedLimit = false;
|
|
131
|
+
for (const img of images) {
|
|
132
|
+
const createdAt = new Date(img.created_at);
|
|
133
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
134
|
+
continue;
|
|
135
|
+
if (createdAt > targetDate.end)
|
|
136
|
+
continue;
|
|
137
|
+
if (createdAt < targetDate.start) {
|
|
138
|
+
reachedLimit = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
const dateParts = (0, dates_1.toDateParts)(createdAt);
|
|
142
|
+
const bucketKey = (0, dates_1.buildHourlyBucketKey)(dateParts.year, dateParts.month, dateParts.day, dateParts.hour);
|
|
143
|
+
if (!hourlyIndices.has(bucketKey)) {
|
|
144
|
+
hourlyIndices.set(bucketKey, new Set());
|
|
145
|
+
}
|
|
146
|
+
if (!hourlyMetadataEntries.has(bucketKey)) {
|
|
147
|
+
hourlyMetadataEntries.set(bucketKey, new Map());
|
|
148
|
+
}
|
|
149
|
+
hourlyIndices.get(bucketKey)?.add(img.image_id);
|
|
150
|
+
imageIds.add(img.image_id);
|
|
151
|
+
let merged = img;
|
|
152
|
+
const cached = useCache ? (0, storage_1.loadImageCache)(img.image_id) : null;
|
|
153
|
+
if (cached) {
|
|
154
|
+
merged = (0, format_1.mergeImageForDisplay)(img, cached);
|
|
155
|
+
}
|
|
156
|
+
let values;
|
|
157
|
+
let hasExistingMetadataEntry = false;
|
|
158
|
+
if (useCache) {
|
|
159
|
+
let existingForBucket = existingHourlyMetadataEntries.get(bucketKey);
|
|
160
|
+
if (!existingForBucket) {
|
|
161
|
+
existingForBucket = normalizeHourlyMetadataEntries((0, storage_1.loadHourlyMetadataCache)(metadataKind, dateParts.year, dateParts.month, dateParts.day, dateParts.hour));
|
|
162
|
+
existingHourlyMetadataEntries.set(bucketKey, existingForBucket);
|
|
163
|
+
}
|
|
164
|
+
if (Object.prototype.hasOwnProperty.call(existingForBucket, img.image_id)) {
|
|
165
|
+
values = existingForBucket[img.image_id];
|
|
166
|
+
hasExistingMetadataEntry = true;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!values) {
|
|
170
|
+
values = (0, format_1.normalizeRankingValues)(extractValues(merged));
|
|
171
|
+
}
|
|
172
|
+
if (values.length === 0 && !hasExistingMetadataEntry) {
|
|
173
|
+
try {
|
|
174
|
+
const detail = await (0, api_1.getImageDetail)(img.image_id);
|
|
175
|
+
merged = (0, format_1.mergeImageForDisplay)(merged, detail);
|
|
176
|
+
values = (0, format_1.normalizeRankingValues)(extractValues(merged));
|
|
177
|
+
}
|
|
178
|
+
catch (_error) {
|
|
179
|
+
// Keep best effort result when detail fetch fails.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
(0, storage_1.saveImageCache)(img.image_id, merged);
|
|
183
|
+
hourlyMetadataEntries.get(bucketKey)?.set(img.image_id, values);
|
|
184
|
+
}
|
|
185
|
+
if (reachedLimit)
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
for (const [bucketKey, current] of hourlyIndices.entries()) {
|
|
189
|
+
const { year, month, day, hour } = (0, dates_1.splitHourlyBucketKey)(bucketKey);
|
|
190
|
+
if (useCache) {
|
|
191
|
+
const existing = (0, storage_1.loadHourlyCache)(year, month, day, hour) || [];
|
|
192
|
+
for (const id of existing)
|
|
193
|
+
current.add(id);
|
|
194
|
+
}
|
|
195
|
+
(0, storage_1.saveHourlyCache)(year, month, day, hour, Array.from(current));
|
|
196
|
+
for (const id of current)
|
|
197
|
+
imageIds.add(id);
|
|
198
|
+
const mergedMetadataEntries = useCache
|
|
199
|
+
? normalizeHourlyMetadataEntries((0, storage_1.loadHourlyMetadataCache)(metadataKind, year, month, day, hour))
|
|
200
|
+
: {};
|
|
201
|
+
const currentMetadataEntries = hourlyMetadataEntries.get(bucketKey) || new Map();
|
|
202
|
+
for (const [imageId, values] of currentMetadataEntries.entries()) {
|
|
203
|
+
mergedMetadataEntries[imageId] = values;
|
|
204
|
+
}
|
|
205
|
+
(0, storage_1.saveHourlyMetadataCache)(metadataKind, year, month, day, hour, mergedMetadataEntries);
|
|
206
|
+
}
|
|
207
|
+
if (useCache) {
|
|
208
|
+
const dates = (0, dates_1.getDatePartsInRange)(targetDate.start, targetDate.end);
|
|
209
|
+
const hours = (0, dates_1.getDateHourStrings)();
|
|
210
|
+
for (const date of dates) {
|
|
211
|
+
for (const hour of hours) {
|
|
212
|
+
const existing = (0, storage_1.loadHourlyCache)(date.year, date.month, date.day, hour) || [];
|
|
213
|
+
for (const id of existing)
|
|
214
|
+
imageIds.add(id);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return Array.from(imageIds);
|
|
219
|
+
}
|
|
220
|
+
function buildHourlyMetadataEntriesFromImageCache(year, month, day, hour, extractValues) {
|
|
221
|
+
const imageIds = (0, storage_1.loadHourlyCache)(year, month, day, hour) || [];
|
|
222
|
+
const valuesByImageId = {};
|
|
223
|
+
for (const imageId of imageIds) {
|
|
224
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
225
|
+
if (!image)
|
|
226
|
+
continue;
|
|
227
|
+
valuesByImageId[imageId] = (0, format_1.normalizeRankingValues)(extractValues(image));
|
|
228
|
+
}
|
|
229
|
+
return valuesByImageId;
|
|
230
|
+
}
|
|
231
|
+
function loadOrBuildHourlyMetadataEntries(metadataKind, year, month, day, hour, extractValues) {
|
|
232
|
+
const rawCached = (0, storage_1.loadHourlyMetadataCache)(metadataKind, year, month, day, hour);
|
|
233
|
+
if (rawCached !== null) {
|
|
234
|
+
return normalizeHourlyMetadataEntries(rawCached);
|
|
235
|
+
}
|
|
236
|
+
const built = buildHourlyMetadataEntriesFromImageCache(year, month, day, hour, extractValues);
|
|
237
|
+
const hasHourlyIndex = Boolean((0, storage_1.loadHourlyCache)(year, month, day, hour));
|
|
238
|
+
if (hasHourlyIndex || Object.keys(built).length > 0) {
|
|
239
|
+
(0, storage_1.saveHourlyMetadataCache)(metadataKind, year, month, day, hour, built);
|
|
240
|
+
}
|
|
241
|
+
return built;
|
|
242
|
+
}
|
|
243
|
+
function cacheSearchResultImages(images) {
|
|
244
|
+
for (const img of images) {
|
|
245
|
+
if (!img?.image_id)
|
|
246
|
+
continue;
|
|
247
|
+
(0, storage_1.saveSearchImageCache)(img.image_id, img);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function supplementAltTextFromSearchCache(image, useCache = true) {
|
|
251
|
+
const hasAltText = Boolean((0, format_1.normalizeText)(image.alt_text));
|
|
252
|
+
if (hasAltText)
|
|
253
|
+
return { image, supplemented: false };
|
|
254
|
+
if (!useCache)
|
|
255
|
+
return { image, supplemented: false };
|
|
256
|
+
const cached = (0, storage_1.loadSearchImageCache)(image.image_id);
|
|
257
|
+
const cachedAltText = (0, format_1.normalizeText)(cached?.alt_text);
|
|
258
|
+
const cachedHasAltText = Boolean(cachedAltText);
|
|
259
|
+
if (!cachedHasAltText)
|
|
260
|
+
return { image, supplemented: false };
|
|
261
|
+
return {
|
|
262
|
+
image: {
|
|
263
|
+
...image,
|
|
264
|
+
alt_text: cachedAltText,
|
|
265
|
+
},
|
|
266
|
+
supplemented: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function supplementAltTextForDisplay(images, useCache = true) {
|
|
270
|
+
return images.map(img => supplementAltTextFromSearchCache(img, useCache).image);
|
|
271
|
+
}
|
|
272
|
+
const ALIAS_QUERIES = {
|
|
273
|
+
photos: 'has:location',
|
|
274
|
+
uploaded: 'gyazocli_uploads',
|
|
275
|
+
};
|
|
276
|
+
function byNewestFirst(a, b) {
|
|
277
|
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
278
|
+
}
|
|
279
|
+
function page(images, pageNumber, limit) {
|
|
280
|
+
const start = (pageNumber - 1) * limit;
|
|
281
|
+
return images.slice(start, start + limit);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* The captures a `list` request asks for. Four ways in, in the order the
|
|
285
|
+
* options decide between them: a saved search alias, a date range, one hour of
|
|
286
|
+
* the cache, or simply the most recent page.
|
|
287
|
+
*
|
|
288
|
+
* Validation is the caller's: this takes options that already agree with each
|
|
289
|
+
* other, because how to refuse differs between the CLI and the MCP server.
|
|
290
|
+
*/
|
|
291
|
+
async function listCaptures(options) {
|
|
292
|
+
const { page: pageNumber, limit, maxPages, useCache } = options;
|
|
293
|
+
if (options.alias) {
|
|
294
|
+
const query = ALIAS_QUERIES[options.alias];
|
|
295
|
+
if (!options.date) {
|
|
296
|
+
return { images: await (0, api_1.searchImages)(query, pageNumber, limit) };
|
|
297
|
+
}
|
|
298
|
+
const collected = [];
|
|
299
|
+
for (let searchPage = 1; searchPage <= maxPages; searchPage++) {
|
|
300
|
+
const pageImages = await (0, api_1.searchImages)(query, searchPage, 100);
|
|
301
|
+
if (pageImages.length === 0)
|
|
302
|
+
break;
|
|
303
|
+
let reachedLimit = false;
|
|
304
|
+
for (const img of pageImages) {
|
|
305
|
+
const createdAt = new Date(img.created_at);
|
|
306
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
307
|
+
continue;
|
|
308
|
+
if (createdAt > options.date.end)
|
|
309
|
+
continue;
|
|
310
|
+
if (createdAt < options.date.start) {
|
|
311
|
+
reachedLimit = true;
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
collected.push(img);
|
|
315
|
+
}
|
|
316
|
+
if (reachedLimit)
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
collected.sort(byNewestFirst);
|
|
320
|
+
return { images: page(collected, pageNumber, limit) };
|
|
321
|
+
}
|
|
322
|
+
if (options.date) {
|
|
323
|
+
const targetDate = options.date;
|
|
324
|
+
let imageIds = [];
|
|
325
|
+
if (useCache) {
|
|
326
|
+
imageIds = loadImageIdsFromDateRangeCache(targetDate);
|
|
327
|
+
if (imageIds.length === 0) {
|
|
328
|
+
await warmDateCacheForList(targetDate, maxPages, true);
|
|
329
|
+
imageIds = loadImageIdsFromDateRangeCache(targetDate);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
imageIds = await warmDateCacheForList(targetDate, maxPages, false);
|
|
334
|
+
}
|
|
335
|
+
if (imageIds.length === 0) {
|
|
336
|
+
return { images: [], empty: 'date' };
|
|
337
|
+
}
|
|
338
|
+
const images = imageIds
|
|
339
|
+
.map((id) => (0, storage_1.loadImageCache)(id))
|
|
340
|
+
.filter((img) => img !== null)
|
|
341
|
+
.filter((img) => {
|
|
342
|
+
const createdAt = new Date(img.created_at);
|
|
343
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
344
|
+
return false;
|
|
345
|
+
return createdAt >= targetDate.start && createdAt <= targetDate.end;
|
|
346
|
+
});
|
|
347
|
+
images.sort(byNewestFirst);
|
|
348
|
+
return { images: page(images, pageNumber, limit) };
|
|
349
|
+
}
|
|
350
|
+
if (options.hour) {
|
|
351
|
+
const { year, month, day, hour } = options.hour;
|
|
352
|
+
const imageIds = (0, storage_1.loadHourlyCache)(year, month, day, hour);
|
|
353
|
+
if (!imageIds) {
|
|
354
|
+
return { images: [], empty: 'hour' };
|
|
355
|
+
}
|
|
356
|
+
if (useCache) {
|
|
357
|
+
return { images: imageIds.map((id) => (0, storage_1.loadImageCache)(id)).filter((img) => img !== null) };
|
|
358
|
+
}
|
|
359
|
+
const images = [];
|
|
360
|
+
for (const imageId of imageIds) {
|
|
361
|
+
try {
|
|
362
|
+
const detail = await (0, api_1.getImageDetail)(imageId);
|
|
363
|
+
(0, storage_1.saveImageCache)(imageId, detail);
|
|
364
|
+
images.push(detail);
|
|
365
|
+
}
|
|
366
|
+
catch (_error) {
|
|
367
|
+
// Skip failed items and continue with the rest.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { images };
|
|
371
|
+
}
|
|
372
|
+
return { images: await (0, api_1.listImages)(pageNumber, limit) };
|
|
373
|
+
}
|
|
@@ -6,12 +6,14 @@ Accepted
|
|
|
6
6
|
## Context
|
|
7
7
|
The current `gyazocli` implementation uses flat top-level commands (not nested under `images`). Documentation should reflect the command tree implemented in `src/index.ts`.
|
|
8
8
|
|
|
9
|
+
The commands are defined in `src/index.ts`; what they call lives in the modules described in [ADR 005](005-module-layout.md).
|
|
10
|
+
|
|
9
11
|
## Decision
|
|
10
12
|
Adopt and document the existing top-level command structure.
|
|
11
13
|
|
|
12
14
|
### 1. Program Metadata
|
|
13
15
|
- Binary name: `gyazo`
|
|
14
|
-
- Version: `0.0
|
|
16
|
+
- Version: `0.2.0`
|
|
15
17
|
- Description: `Gyazo Memory CLI for AI Secretary`
|
|
16
18
|
|
|
17
19
|
### 2. Commands
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# 004. Serve MCP from the CLI
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Accepted. `gyazo_search`, `gyazo_image`, `gyazo_latest_image`, `gyazo_list`,
|
|
6
|
+
`gyazo_summary` and `gyazo_collection` implemented, all read-only and all
|
|
7
|
+
metadata only. `gyazo_upload` deliberately not.
|
|
8
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
nota/gyazo-mcp-server is the official MCP server for Gyazo, and it works: a
|
|
12
|
+
client reached it through a tunnel and searched captures. It is published from
|
|
13
|
+
the nota organization, though, which we cannot publish to, so following it
|
|
14
|
+
means keeping a fork in step with it.
|
|
15
|
+
|
|
16
|
+
The CLI already holds everything such a server needs: the access token, the
|
|
17
|
+
API client, the cache, and the search that `gyazo search` uses.
|
|
18
|
+
|
|
19
|
+
## Decision
|
|
20
|
+
|
|
21
|
+
Serve MCP from this CLI, started with `gyazo --mcp-server` (also `--mcp`,
|
|
22
|
+
`mcp-server`, `mcp`), rather than shipping a second executable.
|
|
23
|
+
|
|
24
|
+
- Dispatched before commander parses. The server owns stdout for the whole
|
|
25
|
+
process, which does not fit inside a command action that shares stdout with
|
|
26
|
+
the usual human-readable output.
|
|
27
|
+
- The MCP SDK is required lazily, so every other command keeps its startup
|
|
28
|
+
time.
|
|
29
|
+
- Tool names and argument shapes follow nota/gyazo-mcp-server, so a client
|
|
30
|
+
configured against it keeps working when it is pointed here.
|
|
31
|
+
- The token is resolved the same way as for every other command, and the
|
|
32
|
+
server exits with a message on stderr when there is none, rather than
|
|
33
|
+
starting and failing every call.
|
|
34
|
+
|
|
35
|
+
## Metadata, not image bytes
|
|
36
|
+
|
|
37
|
+
Upstream returns image content as base64, compressing it with sharp to fit.
|
|
38
|
+
Trying that from a real client showed the ambition does not pay off: the bytes
|
|
39
|
+
are awkward to move through MCP and the model gets little from them that the
|
|
40
|
+
metadata does not already say. Gyazo captures carry OCR text, a title, the
|
|
41
|
+
application and page they came from, and sometimes a location, which is the
|
|
42
|
+
part a model can actually reason about.
|
|
43
|
+
|
|
44
|
+
So every tool here returns metadata and URLs, and none returns pixels. A
|
|
45
|
+
client that wants to show a capture opens the URL in the result. This also
|
|
46
|
+
drops sharp from the dependency list entirely.
|
|
47
|
+
|
|
48
|
+
## Read-only by construction
|
|
49
|
+
|
|
50
|
+
`gyazo_upload` is not implemented and no other tool writes. There is no need
|
|
51
|
+
for it yet, and a server that cannot write cannot be talked into writing. Every
|
|
52
|
+
tool carries `readOnlyHint`, and a test asserts that the tool list contains
|
|
53
|
+
nothing else.
|
|
54
|
+
|
|
55
|
+
## The same answers as the CLI
|
|
56
|
+
|
|
57
|
+
`gyazo_list` and `gyazo_summary` take the options their commands take, down to
|
|
58
|
+
the defaults, because a client that knows `gyazo list --date 2026-02-20
|
|
59
|
+
--photos` should not have to learn a second vocabulary. The names are
|
|
60
|
+
snake_case, which is what tool arguments look like: `max_pages`, `use_cache`.
|
|
61
|
+
|
|
62
|
+
The query behind each one moved into the services, so the command and the tool
|
|
63
|
+
call the same function. `listCaptures`, `buildSummary` and `readCollection`
|
|
64
|
+
answer the question; the command prints the answer and the tool serialises it.
|
|
65
|
+
Copying a query into a second caller is how two callers start disagreeing.
|
|
66
|
+
|
|
67
|
+
Validation stayed with each caller. Which options contradict each other is the
|
|
68
|
+
same question in both places, but the answers differ in kind: the CLI reports
|
|
69
|
+
and exits, and a server must not exit over one bad argument. `parseDateOption`
|
|
70
|
+
and `parseHourOption` grew non-exiting variants for that reason, and the
|
|
71
|
+
exiting ones are now thin wrappers over them.
|
|
72
|
+
|
|
73
|
+
## Consequences
|
|
74
|
+
|
|
75
|
+
- The result payload is the fields a model can act on: `image_id`,
|
|
76
|
+
`permalink_url`, `url`, `thumb_url`, `mimeType`, `created_at`, `alt_text`,
|
|
77
|
+
`ocr`, `metadata`, `exif_normalized`. Absent fields stay absent.
|
|
78
|
+
- No `uri` field, unlike upstream: it points at an MCP resource, and this
|
|
79
|
+
server does not serve resources yet.
|
|
80
|
+
- `gyazo_latest_image` takes no arguments, while upstream declared a `name`
|
|
81
|
+
property on it. Unknown properties are dropped, so a client configured
|
|
82
|
+
against upstream still works.
|
|
83
|
+
- The id handling moved to `src/ids.ts`, so the server can turn a URL into an
|
|
84
|
+
ID without loading commander and every command with it.
|
|
85
|
+
- The tests speak JSON-RPC to the built CLI over a pipe against a stub API, so
|
|
86
|
+
they cover the framing as well as the tool. CI additionally runs a handshake
|
|
87
|
+
against a production install with hoisting turned off, because `--mcp-server`
|
|
88
|
+
is the only path that loads the SDK.
|