@yuiseki/gyazocli 0.3.0 → 0.5.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 +47 -10
- package/dist/api.js +59 -0
- package/dist/config.js +2 -0
- package/dist/format.js +23 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +302 -27
- package/dist/services/collections.js +51 -6
- package/dist/services/memory.js +90 -0
- package/docs/ADR/003-cli-structure.md +1 -1
- package/docs/ADR/004-mcp-server.md +26 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,12 +114,33 @@ Configured in a client:
|
|
|
114
114
|
}
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
### Query syntax
|
|
118
|
+
|
|
119
|
+
Bare words match the OCR text, title and description. These operators were
|
|
120
|
+
checked against the live API, each with a value that should match, reading the
|
|
121
|
+
results back from the detail endpoint to confirm the filter had applied:
|
|
122
|
+
|
|
123
|
+
| Operator | Matches |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `address:広島`, `address:Hiroshima`, `address:730-0041` | the reverse-geocoded address of a capture with GPS, in any language or case, postal codes included |
|
|
126
|
+
| `app:"Gyazo Android"` | the application the capture came from |
|
|
127
|
+
| `title:`, `url:`, `desc:` | the page it was captured from |
|
|
128
|
+
| `ocr:` | the text in the image |
|
|
129
|
+
| `type:png` | the file type |
|
|
130
|
+
| `has:location` | only captures with coordinates |
|
|
131
|
+
| `has:exif` | only captures with EXIF, which is not the same thing |
|
|
132
|
+
| `since:2026-08-30 until:2026-08-31` | the upload date |
|
|
133
|
+
| `-address:広島` | negation |
|
|
134
|
+
|
|
135
|
+
There is no coordinate or radius search. `location:`, `geo:`, `near:`,
|
|
136
|
+
`bbox:`, `city:`, `lat:` and the like all return nothing, exactly as an
|
|
137
|
+
invented operator does, so search by place with `address:`.
|
|
138
|
+
|
|
117
139
|
### Tools
|
|
118
140
|
|
|
119
141
|
- `gyazo_search`: full-text search over your captures. Arguments: `query`
|
|
120
142
|
(required, up to 200 characters), `page` (default 1), `per` (default 20,
|
|
121
|
-
max 100)
|
|
122
|
-
`app:"Google Chrome"`, `url:google.com`, `cat since:2024-01-01 until:2024-12-31`.
|
|
143
|
+
max 100), `include_location`. See the query syntax below.
|
|
123
144
|
- `gyazo_image`: metadata for one capture. Argument: `id_or_url` (required),
|
|
124
145
|
which accepts a bare 32-character ID, a `https://gyazo.com/<id>` permalink or
|
|
125
146
|
a direct image URL.
|
|
@@ -131,15 +152,31 @@ Configured in a client:
|
|
|
131
152
|
- `gyazo_summary`: what a day or a range adds up to, with the same options as
|
|
132
153
|
`gyazo summary`: `date`, `today`, `limit`, `max_pages`, `use_cache`. No
|
|
133
154
|
arguments means the week up to yesterday.
|
|
155
|
+
- `gyazo_recent`: what arrived since a moment or since a capture you have
|
|
156
|
+
already seen. Arguments: `minutes`, `since`, `after_image_id`, `limit`,
|
|
157
|
+
`max_pages`. No arguments means the last 30 minutes.
|
|
134
158
|
- `gyazo_collection`: a collection and the captures in it. Arguments:
|
|
135
|
-
`id_or_url` (required)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
`id_or_url` (required), `sort` (`added`, `created` or `captured`), `page` and
|
|
160
|
+
`per`. Reports `total_image_count`, `returned_image_count` and `truncated`.
|
|
161
|
+
- `gyazo_collections`: the collections, with their IDs, filtered by `query`
|
|
162
|
+
against their names.
|
|
163
|
+
- `gyazo_image_content`: the pixels of one capture, as image content.
|
|
164
|
+
Arguments: `id_or_url` (required), `width` (default 1024), `format`
|
|
165
|
+
(`webp` or `jpeg`) and `max_bytes`.
|
|
166
|
+
|
|
167
|
+
All of them are read-only. Everything except `gyazo_image_content` returns
|
|
168
|
+
metadata rather than image bytes: URLs, timestamps, OCR text, title, source
|
|
169
|
+
application and page, and location when the capture carries one. URLs, timestamp, OCR text, title, source application and page, and
|
|
170
|
+
location when the capture carries one. A capture with a location gets a `location` holding
|
|
171
|
+
`latitude`, `longitude`, `country_code` and an address in Japanese and
|
|
172
|
+
English, each with its `locality` and `admin1`, plus `altitude_m` and
|
|
173
|
+
`heading_deg` where the response carries the raw EXIF, which is the case for
|
|
174
|
+
captures read through a collection. `captured_at` is when the shutter was
|
|
175
|
+
pressed, as distinct from the upload time in `created_at`.
|
|
176
|
+
|
|
177
|
+
For the pixels, `gyazo_image_content` returns a width-limited rendition, one
|
|
178
|
+
capture at a time. Returning image bytes from the list and search tools is
|
|
179
|
+
what made this awkward in practice, so those stay metadata-only.
|
|
143
180
|
|
|
144
181
|
Tool names and arguments follow
|
|
145
182
|
[nota/gyazo-mcp-server](https://github.com/nota/gyazo-mcp-server), so a client
|
package/dist/api.js
CHANGED
|
@@ -8,6 +8,10 @@ exports.getImageDetail = getImageDetail;
|
|
|
8
8
|
exports.searchImages = searchImages;
|
|
9
9
|
exports.getCurrentUser = getCurrentUser;
|
|
10
10
|
exports.getCollection = getCollection;
|
|
11
|
+
exports.listCollections = listCollections;
|
|
12
|
+
exports.getCollectionDetail = getCollectionDetail;
|
|
13
|
+
exports.listCollectionImages = listCollectionImages;
|
|
14
|
+
exports.fetchImageRendition = fetchImageRendition;
|
|
11
15
|
exports.uploadImage = uploadImage;
|
|
12
16
|
const axios_1 = __importDefault(require("axios"));
|
|
13
17
|
const form_data_1 = __importDefault(require("form-data"));
|
|
@@ -15,6 +19,7 @@ const config_1 = require("./config");
|
|
|
15
19
|
const DEFAULT_API_ORIGIN = 'https://api.gyazo.com';
|
|
16
20
|
const DEFAULT_UPLOAD_ORIGIN = 'https://upload.gyazo.com';
|
|
17
21
|
const DEFAULT_WEB_ORIGIN = 'https://gyazo.com';
|
|
22
|
+
const DEFAULT_IMAGE_ORIGIN = 'https://i.gyazo.com';
|
|
18
23
|
function stripTrailingSlash(origin) {
|
|
19
24
|
return origin.replace(/\/+$/, '');
|
|
20
25
|
}
|
|
@@ -27,11 +32,17 @@ function uploadOrigin() {
|
|
|
27
32
|
function webOrigin() {
|
|
28
33
|
return stripTrailingSlash(config_1.config.GYAZO_WEB_ORIGIN || DEFAULT_WEB_ORIGIN);
|
|
29
34
|
}
|
|
35
|
+
function imageOrigin() {
|
|
36
|
+
return stripTrailingSlash(config_1.config.GYAZO_IMAGE_ORIGIN || DEFAULT_IMAGE_ORIGIN);
|
|
37
|
+
}
|
|
30
38
|
const apiBaseUrl = () => `${apiOrigin()}/api/images`;
|
|
31
39
|
const apiSearchUrl = () => `${apiOrigin()}/api/search`;
|
|
32
40
|
const apiUsersMeUrl = () => `${apiOrigin()}/api/users/me`;
|
|
33
41
|
const apiUploadUrl = () => `${uploadOrigin()}/api/upload`;
|
|
34
42
|
const webCollectionUrl = (id) => `${webOrigin()}/collections/${id}.json`;
|
|
43
|
+
const apiCollectionsUrl = () => `${apiOrigin()}/api/v2/collections`;
|
|
44
|
+
const apiCollectionUrl = (id) => `${apiCollectionsUrl()}/${id}`;
|
|
45
|
+
const apiCollectionImagesUrl = (id) => `${apiCollectionUrl(id)}/images`;
|
|
35
46
|
async function requestWithRetry(url, params = {}, headers) {
|
|
36
47
|
const requestHeaders = headers ?? { Authorization: `Bearer ${config_1.config.GYAZO_ACCESS_TOKEN}` };
|
|
37
48
|
try {
|
|
@@ -73,6 +84,54 @@ async function getCollection(collectionId, options = {}) {
|
|
|
73
84
|
}
|
|
74
85
|
return requestWithRetry(webCollectionUrl(collectionId), {}, headers);
|
|
75
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* The collections the token can see, newest activity first as the API orders
|
|
89
|
+
* them. Needed to turn a collection people call by name into an ID.
|
|
90
|
+
*/
|
|
91
|
+
async function listCollections() {
|
|
92
|
+
const data = await requestWithRetry(apiCollectionsUrl());
|
|
93
|
+
if (Array.isArray(data))
|
|
94
|
+
return data;
|
|
95
|
+
return Array.isArray(data?.collections) ? data.collections : [];
|
|
96
|
+
}
|
|
97
|
+
/** A collection's own fields, without its images. */
|
|
98
|
+
async function getCollectionDetail(collectionId) {
|
|
99
|
+
return requestWithRetry(apiCollectionUrl(collectionId));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A page of a collection's images. Unlike the public web endpoint, which
|
|
103
|
+
* returns the first 100 and ignores every paging parameter, this one really
|
|
104
|
+
* pages, and its images carry the raw EXIF.
|
|
105
|
+
*/
|
|
106
|
+
async function listCollectionImages(collectionId, page = 1, per = 100) {
|
|
107
|
+
const data = await requestWithRetry(apiCollectionImagesUrl(collectionId), { page, per });
|
|
108
|
+
if (Array.isArray(data))
|
|
109
|
+
return data;
|
|
110
|
+
return Array.isArray(data?.images) ? data.images : [];
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A width-limited rendition of a capture.
|
|
114
|
+
*
|
|
115
|
+
* The original can be several megabytes, which is no use to a model, and
|
|
116
|
+
* resizing locally would mean a native image library. Gyazo will do it: the
|
|
117
|
+
* rendition route takes a width and needs no credentials, so a capture can be
|
|
118
|
+
* handed over at a size that fits. 1024 wide lands around 130 KB as webp.
|
|
119
|
+
*/
|
|
120
|
+
async function fetchImageRendition(imageId, width, format = 'webp') {
|
|
121
|
+
const extension = format === 'jpeg' ? 'jpg' : 'webp';
|
|
122
|
+
const url = `${imageOrigin()}/thumb/${width}_w/${imageId}.${extension}`;
|
|
123
|
+
const response = await axios_1.default.get(url, { responseType: 'arraybuffer' });
|
|
124
|
+
const data = Buffer.from(response.data);
|
|
125
|
+
const contentType = String(response.headers['content-type'] || '').split(';')[0].trim();
|
|
126
|
+
return {
|
|
127
|
+
data,
|
|
128
|
+
mimeType: contentType || `image/${format}`,
|
|
129
|
+
bytes: data.length,
|
|
130
|
+
url,
|
|
131
|
+
width,
|
|
132
|
+
format,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
76
135
|
async function uploadImage(options) {
|
|
77
136
|
const form = new form_data_1.default();
|
|
78
137
|
form.append('access_token', config_1.config.GYAZO_ACCESS_TOKEN || '');
|
package/dist/config.js
CHANGED
|
@@ -16,6 +16,7 @@ const configSchema = zod_1.z.object({
|
|
|
16
16
|
GYAZO_API_ORIGIN: zod_1.z.string().optional(),
|
|
17
17
|
GYAZO_UPLOAD_ORIGIN: zod_1.z.string().optional(),
|
|
18
18
|
GYAZO_WEB_ORIGIN: zod_1.z.string().optional(),
|
|
19
|
+
GYAZO_IMAGE_ORIGIN: zod_1.z.string().optional(),
|
|
19
20
|
});
|
|
20
21
|
exports.config = configSchema.parse({
|
|
21
22
|
GYAZO_ACCESS_TOKEN: process.env.GYAZO_ACCESS_TOKEN,
|
|
@@ -25,6 +26,7 @@ exports.config = configSchema.parse({
|
|
|
25
26
|
GYAZO_API_ORIGIN: process.env.GYAZO_API_ORIGIN,
|
|
26
27
|
GYAZO_UPLOAD_ORIGIN: process.env.GYAZO_UPLOAD_ORIGIN,
|
|
27
28
|
GYAZO_WEB_ORIGIN: process.env.GYAZO_WEB_ORIGIN,
|
|
29
|
+
GYAZO_IMAGE_ORIGIN: process.env.GYAZO_IMAGE_ORIGIN,
|
|
28
30
|
});
|
|
29
31
|
function setAccessToken(token) {
|
|
30
32
|
exports.config.GYAZO_ACCESS_TOKEN = token;
|
package/dist/format.js
CHANGED
|
@@ -13,6 +13,7 @@ exports.cleanTextForDomain = cleanTextForDomain;
|
|
|
13
13
|
exports.stripInlineUrls = stripInlineUrls;
|
|
14
14
|
exports.sanitizeSummaryText = sanitizeSummaryText;
|
|
15
15
|
exports.getAddressEntry = getAddressEntry;
|
|
16
|
+
exports.getAddressComponentCode = getAddressComponentCode;
|
|
16
17
|
exports.getAddressComponent = getAddressComponent;
|
|
17
18
|
exports.buildJaLocationLabel = buildJaLocationLabel;
|
|
18
19
|
exports.buildEnLocationLabel = buildEnLocationLabel;
|
|
@@ -97,6 +98,28 @@ function getAddressEntry(exifAddress, locale) {
|
|
|
97
98
|
return undefined;
|
|
98
99
|
return entry;
|
|
99
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* The short form of a component, which is what a country code is: `JP` rather
|
|
103
|
+
* than `日本` or `Japan`, and the same in every language.
|
|
104
|
+
*/
|
|
105
|
+
function getAddressComponentCode(addressEntry, type) {
|
|
106
|
+
if (!addressEntry || typeof addressEntry !== 'object')
|
|
107
|
+
return undefined;
|
|
108
|
+
const components = Array.isArray(addressEntry.address_components)
|
|
109
|
+
? addressEntry.address_components
|
|
110
|
+
: [];
|
|
111
|
+
for (const component of components) {
|
|
112
|
+
if (!component || typeof component !== 'object')
|
|
113
|
+
continue;
|
|
114
|
+
const types = Array.isArray(component.types) ? component.types : [];
|
|
115
|
+
if (!types.includes(type))
|
|
116
|
+
continue;
|
|
117
|
+
const value = normalizeText(component.short_name);
|
|
118
|
+
if (value)
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
100
123
|
function getAddressComponent(addressEntry, type) {
|
|
101
124
|
if (!addressEntry || typeof addressEntry !== 'object')
|
|
102
125
|
return undefined;
|
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ program
|
|
|
26
26
|
.name('gyazo')
|
|
27
27
|
.description('Gyazo Memory CLI for AI Secretary')
|
|
28
28
|
.option('--mcp-server', 'run as a Model Context Protocol server over stdio')
|
|
29
|
-
.version('0.
|
|
29
|
+
.version('0.5.0');
|
|
30
30
|
(0, config_1.registerConfigCommand)(program);
|
|
31
31
|
(0, list_1.registerListCommand)(program);
|
|
32
32
|
(0, get_1.registerGetCommand)(program);
|
package/dist/mcp.js
CHANGED
|
@@ -20,15 +20,34 @@ const api_1 = require("./api");
|
|
|
20
20
|
const credentials_1 = require("./credentials");
|
|
21
21
|
const ids_1 = require("./ids");
|
|
22
22
|
const dates_1 = require("./dates");
|
|
23
|
+
const format_1 = require("./format");
|
|
23
24
|
const memory_1 = require("./services/memory");
|
|
24
25
|
const analytics_1 = require("./services/analytics");
|
|
25
26
|
const collections_1 = require("./services/collections");
|
|
27
|
+
/**
|
|
28
|
+
* The operators below were checked against the live API rather than taken from
|
|
29
|
+
* documentation: each one was run with a value that should match, and the
|
|
30
|
+
* results were read back from the detail endpoint to confirm the filter had
|
|
31
|
+
* actually applied. An operator Gyazo does not know returns nothing at all
|
|
32
|
+
* rather than falling back to a text search, so an untested guess costs the
|
|
33
|
+
* model a turn.
|
|
34
|
+
*/
|
|
26
35
|
const SEARCH_QUERY_DESCRIPTION = [
|
|
27
|
-
'Search keyword
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
36
|
+
'Search keyword, up to 200 characters. Bare words match the OCR text, title and',
|
|
37
|
+
'description.',
|
|
38
|
+
'Operators, all confirmed to work: address: matches the reverse-geocoded address of',
|
|
39
|
+
'a capture with GPS, in any language and case, and also matches postal codes, so',
|
|
40
|
+
'address:広島 and address:Hiroshima and address:730-0041 all find the same photos;',
|
|
41
|
+
'app: the application it came from, as app:"Gyazo Android"; title:, url: and desc:',
|
|
42
|
+
'the page it was captured from; ocr: the text in the image; type: the file type, as',
|
|
43
|
+
'type:png; has:location only captures with coordinates; has:exif only captures with',
|
|
44
|
+
'EXIF, which is not the same thing; since: and until: bound the upload date, as',
|
|
45
|
+
'since:2026-08-30 until:2026-08-31. A leading - negates, as -address:広島. Quote a',
|
|
46
|
+
'value that contains spaces.',
|
|
47
|
+
'There is no coordinate or radius search: location:, geo:, near:, bbox:, city: and',
|
|
48
|
+
'the like all return nothing. To search by place, use address: with a place name.',
|
|
49
|
+
'If nothing suitable comes back, rephrase the query to match what the user meant and',
|
|
50
|
+
'search again rather than giving up on the first attempt.',
|
|
32
51
|
].join(' ');
|
|
33
52
|
function serverVersion() {
|
|
34
53
|
// The published tarball always contains package.json, and dist/ sits one
|
|
@@ -40,10 +59,45 @@ function present(value) {
|
|
|
40
59
|
return value !== null && value !== undefined;
|
|
41
60
|
}
|
|
42
61
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
|
|
62
|
+
* Both languages, always. A Japanese address reads poorly for a place abroad,
|
|
63
|
+
* and an English one reads poorly at home, and which of those applies is not
|
|
64
|
+
* something this server can decide for the model.
|
|
65
|
+
*/
|
|
66
|
+
const ADDRESS_LOCALES = ['ja', 'en'];
|
|
67
|
+
function readNumber(value) {
|
|
68
|
+
if (typeof value === 'number')
|
|
69
|
+
return Number.isFinite(value) ? value : undefined;
|
|
70
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
71
|
+
const parsed = Number(value);
|
|
72
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
function readAddresses(exifAddress) {
|
|
77
|
+
const addresses = {};
|
|
78
|
+
for (const locale of ADDRESS_LOCALES) {
|
|
79
|
+
const entry = (0, format_1.getAddressEntry)(exifAddress, locale);
|
|
80
|
+
const text = (0, format_1.normalizeText)(entry?.address);
|
|
81
|
+
if (!text)
|
|
82
|
+
continue;
|
|
83
|
+
const locality = (0, format_1.getAddressComponent)(entry, 'locality');
|
|
84
|
+
const admin1 = (0, format_1.getAddressComponent)(entry, 'administrative_area_level_1');
|
|
85
|
+
addresses[locale] = {
|
|
86
|
+
text,
|
|
87
|
+
...(locality ? { locality } : {}),
|
|
88
|
+
...(admin1 ? { admin1 } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return Object.keys(addresses).length > 0 ? addresses : undefined;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Where a capture was taken, in the fields a model can reason about.
|
|
95
|
+
*
|
|
96
|
+
* The coordinates live under `metadata`; the top-level `exif_normalized` is
|
|
97
|
+
* null in every response this CLI reads, though it is still honoured in case
|
|
98
|
+
* an endpoint starts filling it. Altitude and heading only exist in the raw
|
|
99
|
+
* EXIF, which `/api/images/<id>` does not return, so they appear for captures
|
|
100
|
+
* read through a collection and are absent otherwise rather than guessed.
|
|
47
101
|
*/
|
|
48
102
|
function readLocation(image) {
|
|
49
103
|
const source = image?.metadata?.exif_normalized ?? image?.exif_normalized;
|
|
@@ -52,7 +106,28 @@ function readLocation(image) {
|
|
|
52
106
|
if (typeof latitude !== 'number' || typeof longitude !== 'number') {
|
|
53
107
|
return undefined;
|
|
54
108
|
}
|
|
55
|
-
|
|
109
|
+
const exif = image?.metadata?.exif;
|
|
110
|
+
const altitude = readNumber(exif?.['Altitude']);
|
|
111
|
+
const heading = readNumber(exif?.['GPS Image Direction']);
|
|
112
|
+
const headingReferenceCode = (0, format_1.normalizeText)(exif?.['GPS Image Direction Reference']);
|
|
113
|
+
const headingReference = headingReferenceCode === 'M' ? 'magnetic' : headingReferenceCode === 'T' ? 'true' : undefined;
|
|
114
|
+
const exifAddress = image?.metadata?.exif_address;
|
|
115
|
+
const addresses = readAddresses(exifAddress);
|
|
116
|
+
const countryCode = ADDRESS_LOCALES.map((locale) => (0, format_1.getAddressComponentCode)((0, format_1.getAddressEntry)(exifAddress, locale), 'country')).find(present);
|
|
117
|
+
return {
|
|
118
|
+
latitude,
|
|
119
|
+
longitude,
|
|
120
|
+
...(altitude !== undefined ? { altitude_m: altitude } : {}),
|
|
121
|
+
...(heading !== undefined ? { heading_deg: heading } : {}),
|
|
122
|
+
...(heading !== undefined && headingReference ? { heading_reference: headingReference } : {}),
|
|
123
|
+
...(countryCode ? { country_code: countryCode } : {}),
|
|
124
|
+
...(addresses ? { address: addresses } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** When the shutter was pressed, as opposed to when the capture was uploaded. */
|
|
128
|
+
function readCapturedAt(image) {
|
|
129
|
+
const capturedAt = image?.exif_captured_at ?? image?.metadata?.exif_normalized?.time ?? undefined;
|
|
130
|
+
return present(capturedAt) && typeof capturedAt === 'string' ? capturedAt : undefined;
|
|
56
131
|
}
|
|
57
132
|
/**
|
|
58
133
|
* The OCR text, from wherever this response carries it. Same mistake as the
|
|
@@ -65,6 +140,7 @@ function readOcr(image) {
|
|
|
65
140
|
}
|
|
66
141
|
function toMetadata(image) {
|
|
67
142
|
const location = readLocation(image);
|
|
143
|
+
const capturedAt = readCapturedAt(image);
|
|
68
144
|
const ocr = readOcr(image);
|
|
69
145
|
return {
|
|
70
146
|
image_id: image.image_id,
|
|
@@ -73,6 +149,7 @@ function toMetadata(image) {
|
|
|
73
149
|
...(present(image.thumb_url) ? { thumb_url: image.thumb_url } : {}),
|
|
74
150
|
...(present(image.type) ? { mimeType: `image/${image.type}` } : {}),
|
|
75
151
|
created_at: image.created_at,
|
|
152
|
+
...(capturedAt !== undefined ? { captured_at: capturedAt } : {}),
|
|
76
153
|
...(present(image.alt_text) && image.alt_text !== '' ? { alt_text: image.alt_text } : {}),
|
|
77
154
|
...(ocr !== undefined ? { ocr } : {}),
|
|
78
155
|
...(location !== undefined ? { location } : {}),
|
|
@@ -106,6 +183,15 @@ function logged(name, handler) {
|
|
|
106
183
|
function asJsonResult(payload) {
|
|
107
184
|
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
108
185
|
}
|
|
186
|
+
const INCLUDE_LOCATION = zod_1.z
|
|
187
|
+
.boolean()
|
|
188
|
+
.default(true)
|
|
189
|
+
.describe('Fill in the location, which the listing and search endpoints leave out. Costs one ' +
|
|
190
|
+
'extra lookup per capture the local cache does not already hold. Set false when the ' +
|
|
191
|
+
'coordinates do not matter');
|
|
192
|
+
async function withLocations(images, includeLocation) {
|
|
193
|
+
return includeLocation ? (0, memory_1.enrichImageLocations)(images) : images;
|
|
194
|
+
}
|
|
109
195
|
function asMetadataListResult(images) {
|
|
110
196
|
if (!images || images.length === 0) {
|
|
111
197
|
return NO_IMAGES;
|
|
@@ -149,21 +235,15 @@ function createMcpServer() {
|
|
|
149
235
|
.max(100)
|
|
150
236
|
.default(20)
|
|
151
237
|
.describe('Number of results per page (max: 100)'),
|
|
238
|
+
include_location: INCLUDE_LOCATION,
|
|
152
239
|
},
|
|
153
240
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
154
|
-
}, logged('gyazo_search', async ({ query, page, per }) => {
|
|
241
|
+
}, logged('gyazo_search', async ({ query, page, per, include_location: includeLocation }) => {
|
|
155
242
|
const images = await (0, api_1.searchImages)(query, page, per);
|
|
156
243
|
if (!images || images.length === 0) {
|
|
157
244
|
return NO_IMAGES;
|
|
158
245
|
}
|
|
159
|
-
return
|
|
160
|
-
content: [
|
|
161
|
-
{
|
|
162
|
-
type: 'text',
|
|
163
|
-
text: JSON.stringify(images.map(toMetadata), null, 2),
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
};
|
|
246
|
+
return asMetadataListResult(await withLocations(images, includeLocation));
|
|
167
247
|
}));
|
|
168
248
|
server.registerTool('gyazo_image', {
|
|
169
249
|
title: 'Describe one Gyazo capture',
|
|
@@ -250,6 +330,7 @@ function createMcpServer() {
|
|
|
250
330
|
.boolean()
|
|
251
331
|
.default(true)
|
|
252
332
|
.describe('Answer from the local cache where possible. Set false to force a fetch'),
|
|
333
|
+
include_location: INCLUDE_LOCATION,
|
|
253
334
|
},
|
|
254
335
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
255
336
|
}, logged('gyazo_list', async (args) => {
|
|
@@ -278,7 +359,7 @@ function createMcpServer() {
|
|
|
278
359
|
hour: hour || undefined,
|
|
279
360
|
alias,
|
|
280
361
|
});
|
|
281
|
-
return asMetadataListResult(images);
|
|
362
|
+
return asMetadataListResult(await withLocations(images, args.include_location));
|
|
282
363
|
}));
|
|
283
364
|
server.registerTool('gyazo_summary', {
|
|
284
365
|
title: 'Summarise a stretch of Gyazo captures',
|
|
@@ -331,30 +412,224 @@ function createMcpServer() {
|
|
|
331
412
|
.default('added')
|
|
332
413
|
.describe('Image order: added (as the collection holds them), created (upload time) or ' +
|
|
333
414
|
'captured (when the photo was taken)'),
|
|
415
|
+
page: zod_1.z.number().int().min(1).default(1).describe('Page of images to read'),
|
|
416
|
+
per: zod_1.z
|
|
417
|
+
.number()
|
|
418
|
+
.int()
|
|
419
|
+
.min(1)
|
|
420
|
+
.max(100)
|
|
421
|
+
.default(100)
|
|
422
|
+
.describe('Images per page (max: 100)'),
|
|
334
423
|
},
|
|
335
424
|
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
336
|
-
}, logged('gyazo_collection', async ({ id_or_url, sort }) => {
|
|
425
|
+
}, logged('gyazo_collection', async ({ id_or_url, sort, page, per }) => {
|
|
337
426
|
const collectionId = (0, ids_1.normalizeCollectionId)(id_or_url);
|
|
338
427
|
if (!collectionId) {
|
|
339
428
|
throw new Error(`'${id_or_url}' is not a Gyazo collection ID or URL. Pass a 32-character ID or a ` +
|
|
340
429
|
'https://gyazo.com/collections/<id> URL. A https://gyazo.com/<id> URL is a single ' +
|
|
341
430
|
'capture, which gyazo_image reads.');
|
|
342
431
|
}
|
|
343
|
-
const
|
|
432
|
+
const result = await (0, collections_1.readCollection)(collectionId, {
|
|
344
433
|
sort: sort,
|
|
434
|
+
paginated: true,
|
|
435
|
+
page,
|
|
436
|
+
per,
|
|
345
437
|
});
|
|
438
|
+
const { collection, images } = result;
|
|
346
439
|
return asJsonResult({
|
|
347
440
|
id: collection?.id ?? collectionId,
|
|
348
|
-
...(collection?.name
|
|
441
|
+
...(present(collection?.name) ? { name: collection.name } : {}),
|
|
349
442
|
...(collection?.description ? { description: collection.description } : {}),
|
|
350
|
-
...(collection?.url
|
|
351
|
-
...(
|
|
352
|
-
? { total_image_count:
|
|
443
|
+
...(present(collection?.url) ? { url: collection.url } : {}),
|
|
444
|
+
...(result.totalImageCount !== undefined
|
|
445
|
+
? { total_image_count: result.totalImageCount }
|
|
353
446
|
: {}),
|
|
354
|
-
|
|
447
|
+
returned_image_count: result.returnedImageCount,
|
|
448
|
+
page: result.page,
|
|
449
|
+
per: result.per,
|
|
450
|
+
// Said out loud, because a collection that stops without saying so
|
|
451
|
+
// reads as a complete answer. Ask for the next page to see the rest.
|
|
452
|
+
truncated: result.truncated,
|
|
453
|
+
...(present(collection?.user) ? { user: collection.user } : {}),
|
|
355
454
|
images: images.map(toMetadata),
|
|
356
455
|
});
|
|
357
456
|
}));
|
|
457
|
+
server.registerTool('gyazo_recent', {
|
|
458
|
+
title: 'What the user captured recently',
|
|
459
|
+
description: 'The captures that arrived since a moment, or since a capture you have already ' +
|
|
460
|
+
'seen. Use this when the user says they just captured something, and pass ' +
|
|
461
|
+
'after_image_id with the newest capture you have already looked at so that you ' +
|
|
462
|
+
'get only what is new. With no arguments it covers the last 30 minutes.',
|
|
463
|
+
inputSchema: {
|
|
464
|
+
minutes: zod_1.z
|
|
465
|
+
.number()
|
|
466
|
+
.int()
|
|
467
|
+
.min(1)
|
|
468
|
+
.max(1440)
|
|
469
|
+
.optional()
|
|
470
|
+
.describe('How far back to look, in minutes. Defaults to 30 when nothing else is given'),
|
|
471
|
+
since: zod_1.z
|
|
472
|
+
.string()
|
|
473
|
+
.optional()
|
|
474
|
+
.describe('An ISO 8601 timestamp to look back to, instead of minutes'),
|
|
475
|
+
after_image_id: zod_1.z
|
|
476
|
+
.string()
|
|
477
|
+
.optional()
|
|
478
|
+
.describe('The newest capture you have already seen, as an ID or a Gyazo URL. Returns ' +
|
|
479
|
+
'only what came after it, and reports if it cannot be found'),
|
|
480
|
+
limit: zod_1.z
|
|
481
|
+
.number()
|
|
482
|
+
.int()
|
|
483
|
+
.min(1)
|
|
484
|
+
.max(100)
|
|
485
|
+
.default(20)
|
|
486
|
+
.describe('Most captures to return (max: 100)'),
|
|
487
|
+
max_pages: zod_1.z
|
|
488
|
+
.number()
|
|
489
|
+
.int()
|
|
490
|
+
.min(1)
|
|
491
|
+
.max(20)
|
|
492
|
+
.default(5)
|
|
493
|
+
.describe('How many pages of 100 to walk before giving up on the boundary'),
|
|
494
|
+
include_location: INCLUDE_LOCATION,
|
|
495
|
+
},
|
|
496
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
497
|
+
}, logged('gyazo_recent', async (args) => {
|
|
498
|
+
const { limit, max_pages: maxPages } = args;
|
|
499
|
+
let afterImageId;
|
|
500
|
+
if (args.after_image_id) {
|
|
501
|
+
const normalized = (0, ids_1.normalizeImageId)(args.after_image_id);
|
|
502
|
+
if (!normalized) {
|
|
503
|
+
throw new Error(`'${args.after_image_id}' is not a Gyazo image ID or URL. Pass the ID of the ` +
|
|
504
|
+
'newest capture you have already seen.');
|
|
505
|
+
}
|
|
506
|
+
afterImageId = normalized;
|
|
507
|
+
}
|
|
508
|
+
let since;
|
|
509
|
+
if (args.since) {
|
|
510
|
+
if (args.minutes !== undefined) {
|
|
511
|
+
throw new Error('since and minutes cannot be used together.');
|
|
512
|
+
}
|
|
513
|
+
const parsed = new Date(args.since);
|
|
514
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
515
|
+
throw new Error(`'${args.since}' is not a timestamp this can read. Pass an ISO 8601 value such ` +
|
|
516
|
+
'as 2026-09-08T11:42:00+09:00.');
|
|
517
|
+
}
|
|
518
|
+
since = parsed;
|
|
519
|
+
}
|
|
520
|
+
else if (args.minutes !== undefined) {
|
|
521
|
+
since = new Date(Date.now() - args.minutes * 60_000);
|
|
522
|
+
}
|
|
523
|
+
else if (!afterImageId) {
|
|
524
|
+
since = new Date(Date.now() - 30 * 60_000);
|
|
525
|
+
}
|
|
526
|
+
const result = await (0, memory_1.listCapturesSince)({ since, afterImageId, limit, maxPages });
|
|
527
|
+
if (result.watermarkMissing) {
|
|
528
|
+
throw new Error(`after_image_id ${afterImageId} was not found in the ${result.pagesWalked} most ` +
|
|
529
|
+
'recent pages of captures. It may be older than that, or belong to another ' +
|
|
530
|
+
'account. Ask for a window in minutes instead, or raise max_pages.');
|
|
531
|
+
}
|
|
532
|
+
return asMetadataListResult(await withLocations(result.images, args.include_location));
|
|
533
|
+
}));
|
|
534
|
+
server.registerTool('gyazo_collections', {
|
|
535
|
+
title: 'Find a Gyazo collection by name',
|
|
536
|
+
description: 'The collections the user has, with their IDs and how many captures each holds. ' +
|
|
537
|
+
'Use this to turn a collection the user names out loud into the ID that ' +
|
|
538
|
+
'gyazo_collection needs.',
|
|
539
|
+
inputSchema: {
|
|
540
|
+
query: zod_1.z
|
|
541
|
+
.string()
|
|
542
|
+
.optional()
|
|
543
|
+
.describe('Part of a collection name to match, case-insensitively. Omit for all of them'),
|
|
544
|
+
},
|
|
545
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
546
|
+
}, logged('gyazo_collections', async ({ query }) => {
|
|
547
|
+
const collections = await (0, collections_1.findCollections)(query);
|
|
548
|
+
if (collections.length === 0) {
|
|
549
|
+
return {
|
|
550
|
+
content: [
|
|
551
|
+
{
|
|
552
|
+
type: 'text',
|
|
553
|
+
text: query
|
|
554
|
+
? `No collections match ${JSON.stringify(query)}.`
|
|
555
|
+
: 'No collections found.',
|
|
556
|
+
},
|
|
557
|
+
],
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return asJsonResult(collections.map((collection) => ({
|
|
561
|
+
id: collection.id,
|
|
562
|
+
...(present(collection.name) ? { name: collection.name } : {}),
|
|
563
|
+
...(collection.description ? { description: collection.description } : {}),
|
|
564
|
+
...(present(collection.total_image_count)
|
|
565
|
+
? { total_image_count: collection.total_image_count }
|
|
566
|
+
: {}),
|
|
567
|
+
...(present(collection.url) ? { url: collection.url } : {}),
|
|
568
|
+
...(present(collection.list_updated_at)
|
|
569
|
+
? { list_updated_at: collection.list_updated_at }
|
|
570
|
+
: {}),
|
|
571
|
+
})));
|
|
572
|
+
}));
|
|
573
|
+
server.registerTool('gyazo_image_content', {
|
|
574
|
+
title: 'Look at a Gyazo capture',
|
|
575
|
+
description: 'The pixels of one capture, as image content you can actually look at. Use it ' +
|
|
576
|
+
'after gyazo_recent or gyazo_image when the metadata is not enough and you need ' +
|
|
577
|
+
'to see what the user is looking at: a sign, a menu, a building. Returns a ' +
|
|
578
|
+
'width-limited rendition rather than the original, which is usually several ' +
|
|
579
|
+
'megabytes. One capture at a time: for a set, read the metadata first and ask ' +
|
|
580
|
+
'for the ones that matter.',
|
|
581
|
+
inputSchema: {
|
|
582
|
+
id_or_url: zod_1.z
|
|
583
|
+
.string()
|
|
584
|
+
.min(1)
|
|
585
|
+
.describe('ID or URL of the capture on Gyazo'),
|
|
586
|
+
width: zod_1.z
|
|
587
|
+
.number()
|
|
588
|
+
.int()
|
|
589
|
+
.min(64)
|
|
590
|
+
.max(2000)
|
|
591
|
+
.default(1024)
|
|
592
|
+
.describe('Width in pixels. 1024 is legible for signs and menus; 512 is cheaper'),
|
|
593
|
+
format: zod_1.z
|
|
594
|
+
.enum(['webp', 'jpeg'])
|
|
595
|
+
.default('webp')
|
|
596
|
+
.describe('webp is about a third the size of jpeg for the same width'),
|
|
597
|
+
max_bytes: zod_1.z
|
|
598
|
+
.number()
|
|
599
|
+
.int()
|
|
600
|
+
.min(10_000)
|
|
601
|
+
.max(4_000_000)
|
|
602
|
+
.default(750_000)
|
|
603
|
+
.describe('Refuse rather than return anything larger than this'),
|
|
604
|
+
},
|
|
605
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
606
|
+
}, logged('gyazo_image_content', async ({ id_or_url, width, format, max_bytes: maxBytes }) => {
|
|
607
|
+
const imageId = (0, ids_1.normalizeImageId)(id_or_url);
|
|
608
|
+
if (!imageId) {
|
|
609
|
+
throw new Error(`'${id_or_url}' is not a Gyazo image ID or URL. Pass a 32-character ID or a ` +
|
|
610
|
+
'https://gyazo.com/<id> URL.');
|
|
611
|
+
}
|
|
612
|
+
const rendition = await (0, api_1.fetchImageRendition)(imageId, width, format);
|
|
613
|
+
if (rendition.bytes > maxBytes) {
|
|
614
|
+
throw new Error(`The ${width}px ${format} rendition of ${imageId} is ${rendition.bytes} bytes, over ` +
|
|
615
|
+
`the max_bytes limit of ${maxBytes}. Ask for a smaller width, or raise max_bytes. ` +
|
|
616
|
+
`The rendition is at ${rendition.url}.`);
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
content: [
|
|
620
|
+
{
|
|
621
|
+
type: 'text',
|
|
622
|
+
text: `${imageId} at ${rendition.width}px wide, ${rendition.bytes} bytes as ` +
|
|
623
|
+
`${rendition.mimeType}. This is a resized rendition, not the original.`,
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
type: 'image',
|
|
627
|
+
data: rendition.data.toString('base64'),
|
|
628
|
+
mimeType: rendition.mimeType,
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
};
|
|
632
|
+
}));
|
|
358
633
|
return server;
|
|
359
634
|
}
|
|
360
635
|
async function runMcpServer() {
|
|
@@ -7,6 +7,7 @@ exports.collectionSortKey = collectionSortKey;
|
|
|
7
7
|
exports.sortCollectionImages = sortCollectionImages;
|
|
8
8
|
exports.printCollectionMarkdown = printCollectionMarkdown;
|
|
9
9
|
exports.readCollection = readCollection;
|
|
10
|
+
exports.findCollections = findCollections;
|
|
10
11
|
/**
|
|
11
12
|
* Collections. A collection ID is indistinguishable from an image ID, so the
|
|
12
13
|
* only unambiguous way in is the URL form, which is why the check here is
|
|
@@ -14,6 +15,7 @@ exports.readCollection = readCollection;
|
|
|
14
15
|
* edits collections.
|
|
15
16
|
*/
|
|
16
17
|
const api_1 = require("../api");
|
|
18
|
+
const credentials_1 = require("../credentials");
|
|
17
19
|
const ids_1 = require("../ids");
|
|
18
20
|
const format_1 = require("../format");
|
|
19
21
|
const images_1 = require("./images");
|
|
@@ -84,14 +86,57 @@ function printCollectionMarkdown(collection, images) {
|
|
|
84
86
|
(0, images_1.printListImages)(images);
|
|
85
87
|
}
|
|
86
88
|
}
|
|
87
|
-
/**
|
|
88
|
-
* A collection and its images in the requested order. The API returns the
|
|
89
|
-
* images in the order they were added, which is the default here too.
|
|
90
|
-
*/
|
|
91
89
|
async function readCollection(collectionId, options = {}) {
|
|
90
|
+
const sort = options.sort || 'added';
|
|
91
|
+
const page = options.page && options.page > 0 ? options.page : 1;
|
|
92
|
+
const per = options.per && options.per > 0 ? Math.min(options.per, 100) : 100;
|
|
93
|
+
const useApi = Boolean(options.paginated) && !options.anonymous && Boolean((0, credentials_1.resolveAccessToken)());
|
|
94
|
+
if (useApi) {
|
|
95
|
+
const [collection, images] = await Promise.all([
|
|
96
|
+
(0, api_1.getCollectionDetail)(collectionId),
|
|
97
|
+
(0, api_1.listCollectionImages)(collectionId, page, per),
|
|
98
|
+
]);
|
|
99
|
+
const total = collection?.total_image_count;
|
|
100
|
+
const sorted = sortCollectionImages(images, sort);
|
|
101
|
+
return {
|
|
102
|
+
collection,
|
|
103
|
+
images: sorted,
|
|
104
|
+
page,
|
|
105
|
+
per,
|
|
106
|
+
returnedImageCount: sorted.length,
|
|
107
|
+
totalImageCount: typeof total === 'number' ? total : undefined,
|
|
108
|
+
truncated: typeof total === 'number' ? page * per < total : false,
|
|
109
|
+
source: 'api',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Only --anonymous drops the token here: a private collection of your own
|
|
113
|
+
// reads fine through the web endpoint with it.
|
|
92
114
|
const collection = await (0, api_1.getCollection)(collectionId, {
|
|
93
115
|
anonymous: Boolean(options.anonymous),
|
|
94
116
|
});
|
|
95
|
-
const images = sortCollectionImages(Array.isArray(collection?.images) ? collection.images : [],
|
|
96
|
-
|
|
117
|
+
const images = sortCollectionImages(Array.isArray(collection?.images) ? collection.images : [], sort);
|
|
118
|
+
const total = collection?.total_image_count;
|
|
119
|
+
return {
|
|
120
|
+
collection,
|
|
121
|
+
images,
|
|
122
|
+
page: 1,
|
|
123
|
+
per: images.length,
|
|
124
|
+
returnedImageCount: images.length,
|
|
125
|
+
totalImageCount: typeof total === 'number' ? total : undefined,
|
|
126
|
+
truncated: typeof total === 'number' ? images.length < total : false,
|
|
127
|
+
source: 'web',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Collections whose name contains the query, or all of them when there is no
|
|
132
|
+
* query. Matching is case-insensitive and ignores surrounding whitespace,
|
|
133
|
+
* because a name people say out loud rarely matches one stored with emoji and
|
|
134
|
+
* padding.
|
|
135
|
+
*/
|
|
136
|
+
async function findCollections(query) {
|
|
137
|
+
const collections = await (0, api_1.listCollections)();
|
|
138
|
+
const needle = (0, format_1.normalizeText)(query)?.toLowerCase();
|
|
139
|
+
if (!needle)
|
|
140
|
+
return collections;
|
|
141
|
+
return collections.filter((collection) => (collection.name || '').toLowerCase().includes(needle));
|
|
97
142
|
}
|
package/dist/services/memory.js
CHANGED
|
@@ -14,6 +14,8 @@ exports.cacheSearchResultImages = cacheSearchResultImages;
|
|
|
14
14
|
exports.supplementAltTextFromSearchCache = supplementAltTextFromSearchCache;
|
|
15
15
|
exports.supplementAltTextForDisplay = supplementAltTextForDisplay;
|
|
16
16
|
exports.listCaptures = listCaptures;
|
|
17
|
+
exports.listCapturesSince = listCapturesSince;
|
|
18
|
+
exports.enrichImageLocations = enrichImageLocations;
|
|
17
19
|
/**
|
|
18
20
|
* The memory this CLI keeps: the local cache of captures, and the walks over
|
|
19
21
|
* the Gyazo API that fill it. A command asks for a day or a range, and this
|
|
@@ -371,3 +373,91 @@ async function listCaptures(options) {
|
|
|
371
373
|
}
|
|
372
374
|
return { images: await (0, api_1.listImages)(pageNumber, limit) };
|
|
373
375
|
}
|
|
376
|
+
/**
|
|
377
|
+
* What arrived since a moment, or since a capture. The listing comes back
|
|
378
|
+
* newest first, so the walk stops at the first capture that is older than the
|
|
379
|
+
* boundary rather than reading to the end.
|
|
380
|
+
*
|
|
381
|
+
* A watermark that never turns up is reported, not papered over: returning
|
|
382
|
+
* everything walked would read as "all of this is new", which is the wrong
|
|
383
|
+
* answer told confidently.
|
|
384
|
+
*/
|
|
385
|
+
async function listCapturesSince(options) {
|
|
386
|
+
const { since, afterImageId, limit, maxPages } = options;
|
|
387
|
+
const collected = [];
|
|
388
|
+
let pagesWalked = 0;
|
|
389
|
+
let reachedBoundary = false;
|
|
390
|
+
for (let page = 1; page <= maxPages && !reachedBoundary; page++) {
|
|
391
|
+
const images = await (0, api_1.listImages)(page, 100);
|
|
392
|
+
pagesWalked = page;
|
|
393
|
+
if (images.length === 0)
|
|
394
|
+
break;
|
|
395
|
+
for (const image of images) {
|
|
396
|
+
if (afterImageId && image.image_id === afterImageId) {
|
|
397
|
+
reachedBoundary = true;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
if (since) {
|
|
401
|
+
const createdAt = new Date(image.created_at);
|
|
402
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
403
|
+
continue;
|
|
404
|
+
if (createdAt < since) {
|
|
405
|
+
reachedBoundary = true;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
collected.push(image);
|
|
410
|
+
}
|
|
411
|
+
if (images.length < 100)
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
if (afterImageId && !reachedBoundary) {
|
|
415
|
+
return { images: [], pagesWalked, watermarkMissing: true };
|
|
416
|
+
}
|
|
417
|
+
return { images: collected.slice(0, limit), pagesWalked };
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Fill in what the lean endpoints leave out.
|
|
421
|
+
*
|
|
422
|
+
* The listing and the search endpoints return an image without its
|
|
423
|
+
* coordinates or its address, whatever the capture actually carries; only the
|
|
424
|
+
* detail endpoint has them. So a caller that needs a location has to ask again
|
|
425
|
+
* per image, which is what this does: the cache first, the API for the rest,
|
|
426
|
+
* a few at a time, writing what it fetches back to the cache so the next look
|
|
427
|
+
* is free.
|
|
428
|
+
*/
|
|
429
|
+
async function enrichImageLocations(images, options = {}) {
|
|
430
|
+
const useCache = options.useCache !== false;
|
|
431
|
+
const limit = options.limit ?? 40;
|
|
432
|
+
const concurrency = Math.max(1, options.concurrency ?? 5);
|
|
433
|
+
const enriched = [...images];
|
|
434
|
+
const pending = [];
|
|
435
|
+
for (let index = 0; index < enriched.length && pending.length < limit; index++) {
|
|
436
|
+
const image = enriched[index];
|
|
437
|
+
if (image?.metadata?.exif_normalized || image?.metadata?.exif_address)
|
|
438
|
+
continue;
|
|
439
|
+
if (useCache) {
|
|
440
|
+
const cached = (0, storage_1.loadImageCache)(image?.image_id);
|
|
441
|
+
if (cached) {
|
|
442
|
+
enriched[index] = (0, format_1.mergeImageForDisplay)(image, cached);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
pending.push(index);
|
|
447
|
+
}
|
|
448
|
+
for (let start = 0; start < pending.length; start += concurrency) {
|
|
449
|
+
const batch = pending.slice(start, start + concurrency);
|
|
450
|
+
await Promise.all(batch.map(async (index) => {
|
|
451
|
+
const image = enriched[index];
|
|
452
|
+
try {
|
|
453
|
+
const detail = await (0, api_1.getImageDetail)(image.image_id);
|
|
454
|
+
(0, storage_1.saveImageCache)(image.image_id, detail);
|
|
455
|
+
enriched[index] = (0, format_1.mergeImageForDisplay)(image, detail);
|
|
456
|
+
}
|
|
457
|
+
catch (_error) {
|
|
458
|
+
// A capture that cannot be fetched keeps what the listing said.
|
|
459
|
+
}
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
return enriched;
|
|
463
|
+
}
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Status
|
|
4
4
|
|
|
5
|
-
Accepted. `gyazo_search`, `gyazo_image`,
|
|
6
|
-
`
|
|
7
|
-
|
|
5
|
+
Accepted. Nine tools, all read-only: `gyazo_search`, `gyazo_image`,
|
|
6
|
+
`gyazo_image_content`, `gyazo_latest_image`, `gyazo_list`, `gyazo_recent`,
|
|
7
|
+
`gyazo_summary`, `gyazo_collection`, `gyazo_collections`. `gyazo_upload`
|
|
8
|
+
deliberately not.
|
|
8
9
|
|
|
9
10
|
## Context
|
|
10
11
|
|
|
@@ -45,6 +46,28 @@ So every tool here returns metadata and URLs, and none returns pixels. A
|
|
|
45
46
|
client that wants to show a capture opens the URL in the result. This also
|
|
46
47
|
drops sharp from the dependency list entirely.
|
|
47
48
|
|
|
49
|
+
## Pixels, after all, for one capture at a time
|
|
50
|
+
|
|
51
|
+
Metadata-only held for lists and searches and turned out to be too strict for
|
|
52
|
+
a single capture. The use case that matters is a person walking somewhere
|
|
53
|
+
unfamiliar saying "look at this": GPS and OCR give the place and the letters,
|
|
54
|
+
not what they are looking at.
|
|
55
|
+
|
|
56
|
+
`gyazo_image_content` is a separate tool rather than a flag on `gyazo_image`,
|
|
57
|
+
so a call that only wants metadata cannot come back with a megabyte. It
|
|
58
|
+
returns a width-limited rendition from Gyazo's own resize route, which needs
|
|
59
|
+
no credentials, so there is still no image library here. 1024px is about 130 KB
|
|
60
|
+
as webp. Over `max_bytes` it refuses and says what to change, rather than
|
|
61
|
+
sending something the host will drop.
|
|
62
|
+
|
|
63
|
+
## Differential retrieval
|
|
64
|
+
|
|
65
|
+
Four captures in a row, then "look at what I just captured", is not a question
|
|
66
|
+
`gyazo_latest_image` can answer. `gyazo_recent` takes a window in minutes, an
|
|
67
|
+
explicit `since`, or `after_image_id` as a watermark, and returns what came
|
|
68
|
+
after. A watermark that cannot be found is reported: returning everything
|
|
69
|
+
walked would read as "all of this is new".
|
|
70
|
+
|
|
48
71
|
## Read-only by construction
|
|
49
72
|
|
|
50
73
|
`gyazo_upload` is not implemented and no other tool writes. There is no need
|