@yuiseki/gyazocli 0.8.0 → 0.10.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 CHANGED
@@ -196,6 +196,27 @@ already configured against that server can point at this one instead. Its
196
196
  `gyazo_upload` is deliberately absent: nothing here can write to your Gyazo
197
197
  account until there is a reason for it to.
198
198
 
199
+ ## Triage
200
+
201
+ Going through captures one at a time, deciding something about each:
202
+
203
+ ```bash
204
+ gyazo triage -q "password"
205
+ gyazo triage --id <image_id> # revisit one
206
+ ```
207
+
208
+ It prints everything a capture carries as markdown, one heading per field, and
209
+ at a terminal asks `Is it safe? [Y/n]` after each. Answers are appended to
210
+ `${XDG_STATE_HOME:-~/.local/state}/gyazocli/triage.jsonl`, outside the cache,
211
+ and a capture already answered is skipped next time.
212
+
213
+ Answering `n` sets that capture to `only_me`. The public API takes
214
+ `access_policy` at upload and has no endpoint that changes it afterwards, so
215
+ this needs gyazo.com cookies, from `GYAZO_COOKIE_FILE` or
216
+ `~/.config/gyazo/cookie.json` (a browser export works as-is). Without them it
217
+ prints the capture's page URL instead and says nothing was changed, and
218
+ `--no-apply` turns the change off entirely.
219
+
199
220
  ## Agent skill
200
221
 
201
222
  `skills/gyazo/` is a skill for coding agents that drive the CLI: what the
package/dist/api.js CHANGED
@@ -12,6 +12,7 @@ exports.listCollections = listCollections;
12
12
  exports.getCollectionDetail = getCollectionDetail;
13
13
  exports.listCollectionImages = listCollectionImages;
14
14
  exports.fetchImageRendition = fetchImageRendition;
15
+ exports.setAccessPolicy = setAccessPolicy;
15
16
  exports.uploadImage = uploadImage;
16
17
  const axios_1 = __importDefault(require("axios"));
17
18
  const form_data_1 = __importDefault(require("form-data"));
@@ -50,6 +51,15 @@ async function requestWithRetry(url, params = {}, headers) {
50
51
  return response.data;
51
52
  }
52
53
  catch (error) {
54
+ if (error.response && error.response.status === 401) {
55
+ // The status alone reads as a bug in the caller. It is not: the token
56
+ // is present and Gyazo will not take it. That happens when it is
57
+ // mistyped, when it has been revoked, and when Gyazo revokes tokens in
58
+ // bulk, as it did after the 2026-09-11 incident.
59
+ throw new Error('Gyazo rejected the access token (401). Issue a new one at ' +
60
+ 'https://gyazo.com/oauth/applications and save it with ' +
61
+ '`gyazo config set token <token>`.');
62
+ }
53
63
  if (error.response && error.response.status === 429) {
54
64
  const retryAfter = parseInt(error.response.headers['retry-after'] || '5', 10);
55
65
  console.warn(`Rate limited. Retrying after ${retryAfter} seconds...`);
@@ -136,6 +146,37 @@ async function fetchImageRendition(imageId, width, format = 'webp') {
136
146
  format,
137
147
  };
138
148
  }
149
+ /**
150
+ * The web app's own endpoint for an existing capture.
151
+ *
152
+ * The public API takes `access_policy` when uploading and offers nothing that
153
+ * changes it afterwards, so this speaks to the same route the site does. It
154
+ * needs the session cookie and the CSRF token that goes with it: without the
155
+ * token the request comes back 422 with an empty body.
156
+ */
157
+ async function setAccessPolicy(imageId, accessPolicy, cookieHeader) {
158
+ const page = await axios_1.default.get(`${webOrigin()}/${imageId}`, {
159
+ headers: { Cookie: cookieHeader },
160
+ responseType: 'text',
161
+ // A redirect to the login page means the cookies are stale; let it be
162
+ // seen rather than followed into an HTML page with no token.
163
+ maxRedirects: 0,
164
+ validateStatus: (status) => status >= 200 && status < 400,
165
+ });
166
+ const token = String(page.data).match(/<meta name="csrf-token" content="([^"]+)"/)?.[1];
167
+ if (!token) {
168
+ throw new Error('could not read a CSRF token from gyazo.com; the cookies are probably expired');
169
+ }
170
+ const response = await axios_1.default.patch(`${webOrigin()}/api/internal/images/${imageId}`, { access_policy: accessPolicy }, {
171
+ headers: {
172
+ Cookie: cookieHeader,
173
+ 'Content-Type': 'application/json',
174
+ 'X-CSRF-Token': token,
175
+ 'X-Requested-With': 'XMLHttpRequest',
176
+ },
177
+ });
178
+ return response.data;
179
+ }
139
180
  async function uploadImage(options) {
140
181
  const form = new form_data_1.default();
141
182
  form.append('access_token', config_1.config.GYAZO_ACCESS_TOKEN || '');
@@ -2,13 +2,31 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerConfigCommand = registerConfigCommand;
4
4
  const api_1 = require("../api");
5
+ const config_1 = require("../config");
5
6
  const credentials_1 = require("../credentials");
6
7
  function registerConfigCommand(program) {
7
8
  const configCmd = program.command('config').description('Manage configuration');
8
9
  configCmd
9
10
  .command('set <key> <value>')
10
11
  .description('Set a configuration value')
11
- .action((key, value) => {
12
+ .option('--no-verify', 'save a token without checking it against the API first')
13
+ .action(async (key, value, options) => {
14
+ // A token that Gyazo will not accept is worth catching here rather than
15
+ // at the next command: the page it comes from shows several strings of
16
+ // the same shape, and a saved bad token replaces a good one.
17
+ if (key === 'token' && options.verify !== false) {
18
+ (0, config_1.setAccessToken)(value);
19
+ try {
20
+ await (0, api_1.getCurrentUser)();
21
+ }
22
+ catch (error) {
23
+ console.error(`Error: this token was not accepted. ${error.message}`);
24
+ console.error('Nothing was saved. Check that the value is the access token');
25
+ console.error('from https://gyazo.com/oauth/applications, not the client ID or secret.');
26
+ console.error('Save it anyway with --no-verify.');
27
+ process.exit(1);
28
+ }
29
+ }
12
30
  (0, credentials_1.setStoredConfig)(key, value);
13
31
  });
14
32
  configCmd
@@ -14,6 +14,8 @@ function registerSyncCommand(program) {
14
14
  .option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'sync only this date/month/year range')
15
15
  .option('--max-pages <number>', 'max pages to fetch', '10')
16
16
  .option('--query <query>', 'fill the cache from a search instead of the listing')
17
+ .option('--refresh', 'fetch every capture again, even one already cached')
18
+ .option('--continue', 'resume the last walk of this query instead of starting at the top')
17
19
  .action(async (options) => {
18
20
  await (0, credentials_1.ensureAccessToken)();
19
21
  if (options.date && options.days) {
@@ -31,7 +33,32 @@ function registerSyncCommand(program) {
31
33
  console.error(' --query "has:exif since:2026-08-01 until:2026-08-31"');
32
34
  process.exit(1);
33
35
  }
36
+ if (options.continue && !options.query) {
37
+ console.error('Error: --continue needs --query, because it resumes a query.');
38
+ process.exit(1);
39
+ }
34
40
  const maxPages = (0, options_1.parsePositiveIntegerOption)(options.maxPages, '--max-pages');
41
+ /** The day part of an instant, in local time, as the operators want it. */
42
+ const dayOf = (date) => {
43
+ const pad = (value) => String(value).padStart(2, '0');
44
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
45
+ };
46
+ let query = options.query;
47
+ if (options.continue) {
48
+ if (/\b(date|since|until):/i.test(query)) {
49
+ console.error('Error: --continue cannot resume a query that bounds its own dates.');
50
+ console.error('Hint: drop date:, since: and until: from --query, or drop --continue.');
51
+ process.exit(1);
52
+ }
53
+ const state = (0, storage_1.loadSyncState)(query);
54
+ if (state) {
55
+ query = `${query} until:${state.oldestDay}`;
56
+ console.log(`Resuming from ${state.oldestDay} (walked ${state.updatedAt}).`);
57
+ }
58
+ else {
59
+ console.log('Nothing walked for this query yet; starting at the top.');
60
+ }
61
+ }
35
62
  let startDate;
36
63
  let endDate;
37
64
  if (options.date) {
@@ -49,16 +76,17 @@ function registerSyncCommand(program) {
49
76
  startDate.setDate(startDate.getDate() - days - 1);
50
77
  startDate.setHours(0, 0, 0, 0);
51
78
  }
52
- if (options.query) {
53
- console.log(`Syncing images matching ${JSON.stringify(options.query)}...`);
79
+ if (query) {
80
+ console.log(`Syncing images matching ${JSON.stringify(query)}...`);
54
81
  }
55
82
  else {
56
83
  console.log(`Syncing images between ${startDate.toISOString()} and ${endDate.toISOString()}...`);
57
84
  }
58
85
  const hourlyIndices = new Map();
86
+ let oldestSeen = null;
59
87
  for (let page = 1; page <= maxPages; page++) {
60
- const images = options.query
61
- ? await (0, api_1.searchImages)(options.query, page, 100)
88
+ const images = query
89
+ ? await (0, api_1.searchImages)(query, page, 100)
62
90
  : await (0, api_1.listImages)(page, 100);
63
91
  if (images.length === 0)
64
92
  break;
@@ -67,14 +95,17 @@ function registerSyncCommand(program) {
67
95
  const createdAt = new Date(img.created_at);
68
96
  // A query says for itself what it covers, and the results are not
69
97
  // ordered predictably enough to stop early on a date.
70
- if (!options.query && createdAt > endDate) {
98
+ if (!query && createdAt > endDate) {
71
99
  // Skip images newer than target range.
72
100
  continue;
73
101
  }
74
- if (!options.query && createdAt < startDate) {
102
+ if (!query && createdAt < startDate) {
75
103
  reachedLimit = true;
76
104
  break;
77
105
  }
106
+ if (!oldestSeen || createdAt < oldestSeen) {
107
+ oldestSeen = createdAt;
108
+ }
78
109
  // Add to hourly index
79
110
  const y = createdAt.getFullYear().toString();
80
111
  const m = (createdAt.getMonth() + 1).toString().padStart(2, '0');
@@ -84,8 +115,13 @@ function registerSyncCommand(program) {
84
115
  if (!hourlyIndices.has(key))
85
116
  hourlyIndices.set(key, new Set());
86
117
  hourlyIndices.get(key)?.add(img.image_id);
87
- const cached = (0, storage_1.loadImageCache)(img.image_id);
88
- if (cached && cached.ocr) {
118
+ // Already fetched is already fetched. This used to test `cached.ocr`,
119
+ // which is null in every response the API returns -- the OCR text
120
+ // lives under `metadata` -- so the check never fired and every sync
121
+ // re-fetched everything it had. On an API with undocumented rate
122
+ // limits that is the expensive kind of mistake.
123
+ const cached = options.refresh ? null : (0, storage_1.loadImageCache)(img.image_id);
124
+ if (cached) {
89
125
  process.stdout.write(`s`);
90
126
  continue;
91
127
  }
@@ -102,6 +138,12 @@ function registerSyncCommand(program) {
102
138
  console.log(`\nPage ${page} processed.`);
103
139
  if (reachedLimit)
104
140
  break;
141
+ // A breath between pages. The rate limits here are real and
142
+ // undocumented, and a walk of a hundred pages is exactly the shape
143
+ // that finds them.
144
+ if (page < maxPages) {
145
+ await new Promise((resolve) => setTimeout(resolve, 1000));
146
+ }
105
147
  }
106
148
  // Save hourly indices
107
149
  console.log(`Updating hourly indices...`);
@@ -111,6 +153,16 @@ function registerSyncCommand(program) {
111
153
  const merged = Array.from(new Set([...existing, ...ids]));
112
154
  (0, storage_1.saveHourlyCache)(y, m, d, h, merged);
113
155
  }
156
+ // Remember how far back this query got, so a later --continue can pick
157
+ // up there rather than walking the same pages again. Only for a query
158
+ // the caller did not bound itself: a bounded one says what it covers.
159
+ if (options.query && oldestSeen && !/\b(date|since|until):/i.test(options.query)) {
160
+ (0, storage_1.saveSyncState)({
161
+ query: options.query,
162
+ oldestDay: dayOf(oldestSeen),
163
+ updatedAt: new Date().toISOString(),
164
+ });
165
+ }
114
166
  console.log(`Sync complete.`);
115
167
  });
116
168
  }
@@ -0,0 +1,358 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.highlightTermsOf = highlightTermsOf;
7
+ exports.registerTriageCommand = registerTriageCommand;
8
+ /**
9
+ * The `triage` command: a search, rendered as markdown for reading rather than
10
+ * for parsing.
11
+ *
12
+ * `ls` and `search` print one line per capture, which is the right shape for
13
+ * scanning a day. Going through captures one at a time, deciding something
14
+ * about each, wants the opposite: everything a capture carries, laid out, with
15
+ * nothing invented and nothing hidden.
16
+ */
17
+ const node_readline_1 = __importDefault(require("node:readline"));
18
+ const api_1 = require("../api");
19
+ const credentials_1 = require("../credentials");
20
+ const format_1 = require("../format");
21
+ const cookies_1 = require("../cookies");
22
+ const ids_1 = require("../ids");
23
+ const options_1 = require("../options");
24
+ const memory_1 = require("../services/memory");
25
+ const triage_ledger_1 = require("../services/triage-ledger");
26
+ /**
27
+ * Fields worth a heading, in the order a person reads them. The id, the URLs
28
+ * and the coordinates are deliberately absent: the id is the heading, and the
29
+ * rest is noise when the job is deciding something about a capture.
30
+ */
31
+ const FIELD_ORDER = [
32
+ // First, because it is what a triage decision often turns on.
33
+ 'access_policy',
34
+ 'created_at',
35
+ 'app',
36
+ 'title',
37
+ 'desc',
38
+ 'page_url',
39
+ 'alt_text',
40
+ 'ocr',
41
+ 'address',
42
+ 'objects',
43
+ ];
44
+ const CYAN = '\u001b[36m';
45
+ const RESET = '\u001b[0m';
46
+ const ORANGE = '\u001b[38;5;208m';
47
+ /** Back to the default foreground, rather than resetting every attribute. */
48
+ const PLAIN = '\u001b[39m';
49
+ /** Operators whose value never appears in the text, so painting it is noise. */
50
+ const STRUCTURAL_KEYS = new Set(['has', 'type', 'date', 'since', 'until']);
51
+ /**
52
+ * The parts of a query that can show up in what a capture says: bare words,
53
+ * and the values of the operators that match text. `has:exif` contributes
54
+ * nothing, and a negated term should not be there to find.
55
+ */
56
+ function highlightTermsOf(query) {
57
+ const terms = [];
58
+ // Quoted values hold spaces: app:"Gyazo Android".
59
+ for (const token of query.match(/(?:[^\s"]|"[^"]*")+/g) || []) {
60
+ if (token.startsWith('-') || token === 'OR' || token === 'or')
61
+ continue;
62
+ const separator = token.indexOf(':');
63
+ const raw = separator === -1 ? token : token.slice(separator + 1);
64
+ if (separator !== -1 && STRUCTURAL_KEYS.has(token.slice(0, separator).toLowerCase()))
65
+ continue;
66
+ const value = raw.replace(/^"|"$/g, '').trim();
67
+ if (value)
68
+ terms.push(value);
69
+ }
70
+ return terms;
71
+ }
72
+ function escapeForRegExp(value) {
73
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ }
75
+ /** Paint every occurrence of a term, keeping the text's own case. */
76
+ function highlight(value, terms) {
77
+ if (terms.length === 0)
78
+ return value;
79
+ const pattern = new RegExp(terms.map(escapeForRegExp).join('|'), 'gi');
80
+ return value.replace(pattern, (match) => `${ORANGE}${match}${PLAIN}`);
81
+ }
82
+ function text(value) {
83
+ if (value === null || value === undefined)
84
+ return undefined;
85
+ if (typeof value === 'string')
86
+ return (0, format_1.normalizeText)(value);
87
+ if (typeof value === 'number' || typeof value === 'boolean')
88
+ return String(value);
89
+ return undefined;
90
+ }
91
+ /**
92
+ * What a capture actually carries, flattened to a name and a block of text.
93
+ * A field that is absent, null or empty is left out rather than printed as a
94
+ * heading with nothing under it.
95
+ */
96
+ function fieldsOf(image) {
97
+ const metadata = image?.metadata || {};
98
+ const ocr = metadata.ocr ?? image?.ocr;
99
+ const exif = metadata.exif_normalized ?? image?.exif_normalized;
100
+ const address = metadata.exif_address?.ja?.address ?? metadata.exif_address?.en?.address ?? undefined;
101
+ const objects = Array.isArray(metadata.localized_object_annotations)
102
+ ? metadata.localized_object_annotations
103
+ : metadata.localizedObjectAnnotations;
104
+ const candidates = {
105
+ // The API leaves this unset on most captures, where it means the default.
106
+ // Reading a hundred sections looking for the ones that are not `anyone`
107
+ // is easier when every section says which it is.
108
+ access_policy: text(image?.access_policy) ?? 'anyone',
109
+ created_at: image?.created_at ? (0, format_1.formatCreatedAtJa)(image.created_at) : undefined,
110
+ type: text(image?.type),
111
+ permalink_url: text(image?.permalink_url),
112
+ url: text(image?.url),
113
+ app: text(metadata.app),
114
+ title: text(metadata.title),
115
+ desc: text(metadata.desc),
116
+ page_url: text(metadata.url),
117
+ alt_text: text(image?.alt_text),
118
+ ocr: text(ocr?.description),
119
+ ocr_locale: text(ocr?.locale),
120
+ latitude: text(exif?.latitude),
121
+ longitude: text(exif?.longitude),
122
+ address: text(address),
123
+ objects: Array.isArray(objects)
124
+ ? objects
125
+ .map((o) => {
126
+ const name = text(o?.name_ja || o?.nameJa || o?.name);
127
+ const score = typeof o?.score === 'number' ? ` (${(o.score * 100).toFixed(1)}%)` : '';
128
+ return name ? `${name}${score}` : undefined;
129
+ })
130
+ .filter(Boolean)
131
+ .join('\n') || undefined
132
+ : undefined,
133
+ };
134
+ return FIELD_ORDER.map((key) => [key, candidates[key]]).filter((entry) => Boolean(entry[1]));
135
+ }
136
+ function registerTriageCommand(program) {
137
+ program
138
+ .command('triage [query]')
139
+ .description('Search, and go through the captures one at a time')
140
+ .option('-q, --query <query>', 'the search query')
141
+ .option('-l, --limit <number>', 'how many captures to go through in this run', '20')
142
+ .option('--max-pages <number>', 'how many search pages to walk looking for them', '20')
143
+ .option('--id <image_id...>', 'go through exactly these captures, answered or not')
144
+ .option('--color <when>', 'colour the headings: auto, always or never', 'auto')
145
+ .option('-i, --interactive', 'ask about each capture and record the answer')
146
+ .option('--no-interactive', 'print without asking, even at a terminal')
147
+ .option('--again', 'ask again about captures already answered')
148
+ .option('--cookies <path>', 'gyazo.com cookies, for making a capture private')
149
+ .option('--no-apply', 'answer without changing anything on Gyazo')
150
+ .option('--no-cache', 'force fetch from API')
151
+ .action(async (positional, options) => {
152
+ await (0, credentials_1.ensureAccessToken)();
153
+ const ids = options.id || [];
154
+ const query = (0, format_1.normalizeText)(options.query || positional) || '';
155
+ if (!query && ids.length === 0) {
156
+ console.error('Error: Query is required.');
157
+ console.error('Hint: gyazo triage -q "password"');
158
+ console.error(' gyazo triage --id <image_id> to revisit one');
159
+ process.exit(1);
160
+ }
161
+ const limit = (0, options_1.parsePositiveIntegerOption)(options.limit, '--limit');
162
+ const maxPages = (0, options_1.parsePositiveIntegerOption)(options.maxPages, '--max-pages');
163
+ // Colour when a person is reading, not when the output is being piped
164
+ // into a file. `--color always` is for a pager that understands it.
165
+ const colour = options.color === 'always' ||
166
+ (options.color !== 'never' && Boolean(process.stdout.isTTY) && !process.env.NO_COLOR);
167
+ // Asking only makes sense with someone there to answer. A pipe on either
168
+ // side means this is feeding a file, not a person.
169
+ const interactive = options.interactive === true ||
170
+ (options.interactive !== false &&
171
+ Boolean(process.stdin.isTTY) &&
172
+ Boolean(process.stdout.isTTY));
173
+ const terms = colour ? highlightTermsOf(query) : [];
174
+ // Read once, so a long triage does not touch the file per answer.
175
+ const cookieHeader = options.apply === false ? undefined : (0, cookies_1.loadCookieHeader)(options.cookies);
176
+ const print = (image, index) => {
177
+ console.log('');
178
+ if (index > 0) {
179
+ console.log('---');
180
+ console.log('');
181
+ }
182
+ const heading = `# ${image.image_id}`;
183
+ console.log(colour ? `${CYAN}${heading}${RESET}` : heading);
184
+ for (const [key, value] of fieldsOf(image)) {
185
+ console.log('');
186
+ console.log(`## ${key}`);
187
+ console.log('');
188
+ console.log(highlight(value, terms));
189
+ }
190
+ };
191
+ const goThrough = async (captures, skipped, pagesWalked) => {
192
+ if (!interactive) {
193
+ captures.forEach(print);
194
+ return;
195
+ }
196
+ // Lines are pulled from an iterator rather than asked for with
197
+ // rl.question: with piped input, question() resolves once and then
198
+ // never again, so everything after the first answer is lost.
199
+ const lines = node_readline_1.default.createInterface({ input: process.stdin });
200
+ const answers = lines[Symbol.asyncIterator]();
201
+ const ask = async (prompt) => {
202
+ process.stdout.write(prompt);
203
+ const { value, done } = await answers.next();
204
+ return done ? null : String(value);
205
+ };
206
+ const counts = { safe: 0, unsafe: 0 };
207
+ const toMakePrivate = [];
208
+ let madePrivate = 0;
209
+ let warnedAboutCookies = false;
210
+ let asked = 0;
211
+ try {
212
+ for (const [index, image] of captures.entries()) {
213
+ print(image, index);
214
+ console.log('');
215
+ const raw = await ask('Is it safe? [Y/n] ');
216
+ // Enter takes the default. q stops, and so does end of input: no
217
+ // answer is not the same as "safe".
218
+ if (raw === null) {
219
+ console.log('');
220
+ break;
221
+ }
222
+ const answer = raw.trim().toLowerCase();
223
+ if (answer === 'q' || answer === 'quit')
224
+ break;
225
+ const verdict = answer === 'n' || answer === 'no' ? 'unsafe' : 'safe';
226
+ (0, triage_ledger_1.appendTriageEntry)({
227
+ image_id: image.image_id,
228
+ verdict,
229
+ at: new Date().toISOString(),
230
+ query: query || undefined,
231
+ });
232
+ counts[verdict]++;
233
+ asked++;
234
+ if (verdict === 'unsafe') {
235
+ const link = image.permalink_url || `https://gyazo.com/${image.image_id}`;
236
+ const paint = (line) => (colour ? `${ORANGE}${line}${PLAIN}` : line);
237
+ if (options.apply === false) {
238
+ toMakePrivate.push(link);
239
+ console.log(paint(`→ ${link}`));
240
+ }
241
+ else if (!cookieHeader) {
242
+ // The public API has no way to change this, so without
243
+ // cookies the honest move is to hand over the link and say
244
+ // why, once.
245
+ toMakePrivate.push(link);
246
+ console.log(paint(`→ ${link}`));
247
+ if (!warnedAboutCookies) {
248
+ console.log(' (no gyazo.com cookies found, so nothing was changed. Put them in ' +
249
+ '~/.config/gyazo/cookie.json to have this set only_me for you.)');
250
+ warnedAboutCookies = true;
251
+ }
252
+ }
253
+ else {
254
+ try {
255
+ await (0, api_1.setAccessPolicy)(image.image_id, 'only_me', cookieHeader);
256
+ console.log(paint(`→ only_me: ${link}`));
257
+ madePrivate++;
258
+ }
259
+ catch (error) {
260
+ // A failure here is the interesting kind: the answer is
261
+ // recorded, the capture is still public, and saying so is
262
+ // the only way the difference is visible.
263
+ toMakePrivate.push(link);
264
+ console.log(paint(`→ ${link}`));
265
+ console.log(` could not set only_me: ${error.message}`);
266
+ }
267
+ }
268
+ }
269
+ }
270
+ }
271
+ finally {
272
+ lines.close();
273
+ }
274
+ console.log('');
275
+ console.log(`${asked} answered: ${counts.safe} safe, ${counts.unsafe} unsafe` +
276
+ (madePrivate > 0 ? `, ${madePrivate} set only_me` : '') +
277
+ '.');
278
+ if (skipped > 0) {
279
+ console.log(`${skipped} skipped as already answered, ${pagesWalked} pages walked.`);
280
+ }
281
+ if (toMakePrivate.length > 0) {
282
+ console.log('');
283
+ console.log(`${toMakePrivate.length} to make private (only_me), on their own pages:`);
284
+ for (const link of toMakePrivate)
285
+ console.log(` ${link}`);
286
+ }
287
+ console.log(`Written to ${(0, triage_ledger_1.getTriageLedgerPath)()}`);
288
+ };
289
+ try {
290
+ console.log(`triage: ${query || ids.join(', ')}`);
291
+ // Named captures are fetched as named, and asked about whether or not
292
+ // they were answered before: naming one is how a mistake is corrected.
293
+ if (ids.length > 0) {
294
+ const named = [];
295
+ for (const given of ids) {
296
+ const imageId = (0, ids_1.normalizeImageId)(given);
297
+ if (!imageId) {
298
+ console.error(`Error: '${given}' is not a Gyazo image ID or URL.`);
299
+ process.exit(1);
300
+ }
301
+ named.push(await (0, api_1.getImageDetail)(imageId));
302
+ }
303
+ await goThrough(named, 0, 0);
304
+ return;
305
+ }
306
+ // Walk the search until `limit` captures that have not been answered
307
+ // are in hand. A page where everything is already answered is not the
308
+ // end of the road, which is what stopping at one page made it.
309
+ const answered = options.again ? new Map() : (0, triage_ledger_1.loadTriageVerdicts)();
310
+ const selected = [];
311
+ let pagesWalked = 0;
312
+ let skipped = 0;
313
+ for (let page = 1; page <= maxPages && selected.length < limit; page++) {
314
+ const found = await (0, api_1.searchImages)(query, page, 100);
315
+ pagesWalked = page;
316
+ if (!found || found.length === 0)
317
+ break;
318
+ for (const image of found) {
319
+ if (answered.has(image.image_id)) {
320
+ skipped++;
321
+ continue;
322
+ }
323
+ selected.push(image);
324
+ if (selected.length >= limit)
325
+ break;
326
+ }
327
+ if (found.length < 100)
328
+ break;
329
+ }
330
+ if (selected.length === 0) {
331
+ console.log('');
332
+ if (skipped > 0) {
333
+ console.log(`Nothing left to ask about: ${skipped} already answered, ` +
334
+ `${pagesWalked} pages walked.`);
335
+ console.log(`Answers so far: ${(0, triage_ledger_1.getTriageLedgerPath)()}`);
336
+ }
337
+ else {
338
+ console.log('No captures matched.');
339
+ }
340
+ return;
341
+ }
342
+ // The search endpoint returns a lean image: no OCR, no coordinates.
343
+ // Triage is about what a capture says, so fetch the rest.
344
+ const enriched = await (0, memory_1.enrichImageLocations)(selected, {
345
+ useCache: options.cache !== false,
346
+ limit: selected.length,
347
+ });
348
+ console.log(`${enriched.images.length} captures` +
349
+ (skipped > 0 ? `, ${skipped} already answered and skipped` : '') +
350
+ `, ${pagesWalked} pages walked.`);
351
+ await goThrough(enriched.images, skipped, pagesWalked);
352
+ }
353
+ catch (error) {
354
+ console.error('Error triaging images:', error.message);
355
+ process.exit(1);
356
+ }
357
+ });
358
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.findCookieFile = findCookieFile;
7
+ exports.buildCookieHeader = buildCookieHeader;
8
+ exports.loadCookieHeader = loadCookieHeader;
9
+ /**
10
+ * Browser cookies for gyazo.com, for the one thing the API cannot do.
11
+ *
12
+ * `access_policy` is an upload parameter and there is no endpoint that updates
13
+ * it afterwards, so making an existing capture private means asking the web
14
+ * app the way the web app asks itself: a session cookie and a CSRF token.
15
+ *
16
+ * The file holds a live session. It is read when needed, never logged, and
17
+ * never written to.
18
+ */
19
+ const fs_1 = __importDefault(require("fs"));
20
+ const os_1 = __importDefault(require("os"));
21
+ const path_1 = __importDefault(require("path"));
22
+ const GYAZO_DOMAIN = /(^|\.)gyazo\.com$/i;
23
+ /**
24
+ * Where to look, nearest intention first. A file named outright is the only
25
+ * candidate: falling back from a path someone gave would use credentials they
26
+ * did not point at, which is the last thing this should do.
27
+ */
28
+ function candidatePaths() {
29
+ if (process.env.GYAZO_COOKIE_FILE)
30
+ return [process.env.GYAZO_COOKIE_FILE];
31
+ const paths = [];
32
+ const home = os_1.default.homedir();
33
+ paths.push(path_1.default.join(home, '.config', 'gyazo', 'cookie.json'));
34
+ paths.push(path_1.default.join(home, '.config', 'gyazo', 'cookies.json'));
35
+ // Handy while working in a checkout, though it only applies from there.
36
+ paths.push(path_1.default.join(process.cwd(), '.cookies', 'gyazo.com.json'));
37
+ return paths;
38
+ }
39
+ function findCookieFile(explicit) {
40
+ const paths = explicit ? [explicit] : candidatePaths();
41
+ return paths.find((candidate) => fs_1.default.existsSync(candidate));
42
+ }
43
+ /**
44
+ * A `Cookie` header from whatever shape the file is in: a browser export
45
+ * (an array of `{name, value, domain}`), a plain `{name: value}` object, or a
46
+ * header string already. Entries belonging to another domain are left out.
47
+ */
48
+ function buildCookieHeader(contents) {
49
+ const trimmed = contents.trim();
50
+ if (trimmed === '')
51
+ return undefined;
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(trimmed);
55
+ }
56
+ catch {
57
+ // Already a header, or a cookie file this does not understand.
58
+ return trimmed.includes('=') ? trimmed : undefined;
59
+ }
60
+ const pairs = [];
61
+ if (Array.isArray(parsed)) {
62
+ for (const entry of parsed) {
63
+ if (!entry || typeof entry.name !== 'string')
64
+ continue;
65
+ if (typeof entry.domain === 'string' && !GYAZO_DOMAIN.test(entry.domain.replace(/^\./, ''))) {
66
+ continue;
67
+ }
68
+ pairs.push(`${entry.name}=${entry.value ?? ''}`);
69
+ }
70
+ }
71
+ else if (parsed && typeof parsed === 'object') {
72
+ for (const [name, value] of Object.entries(parsed)) {
73
+ if (typeof value === 'string')
74
+ pairs.push(`${name}=${value}`);
75
+ }
76
+ }
77
+ return pairs.length > 0 ? pairs.join('; ') : undefined;
78
+ }
79
+ function loadCookieHeader(explicit) {
80
+ const file = findCookieFile(explicit);
81
+ if (!file)
82
+ return undefined;
83
+ try {
84
+ return buildCookieHeader(fs_1.default.readFileSync(file, 'utf-8'));
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
package/dist/format.js CHANGED
@@ -20,6 +20,7 @@ exports.buildEnLocationLabel = buildEnLocationLabel;
20
20
  exports.extractImageAddressText = extractImageAddressText;
21
21
  exports.extractImageLocationLabel = extractImageLocationLabel;
22
22
  exports.truncateText = truncateText;
23
+ exports.formatCreatedAtJa = formatCreatedAtJa;
23
24
  exports.formatCreatedAt = formatCreatedAt;
24
25
  exports.shortenImageId = shortenImageId;
25
26
  exports.formatTerminalLink = formatTerminalLink;
@@ -229,12 +230,37 @@ function truncateText(value, maxLength) {
229
230
  return value.slice(0, maxLength);
230
231
  return `${value.slice(0, maxLength - 3)}...`;
231
232
  }
233
+ const JA_WEEKDAYS = ['日', '月', '火', '水', '木', '金', '土'];
234
+ /**
235
+ * A timestamp as the person who took the capture experienced it: their own
236
+ * clock, in Japanese. The API sends UTC, so this converts rather than reading
237
+ * the digits out of the string.
238
+ */
239
+ function formatCreatedAtJa(value) {
240
+ const at = new Date(value);
241
+ if (Number.isNaN(at.getTime()))
242
+ return value;
243
+ const pad = (n) => String(n).padStart(2, '0');
244
+ return (`${at.getFullYear()}年${at.getMonth() + 1}月${at.getDate()}日` +
245
+ `(${JA_WEEKDAYS[at.getDay()]}) ${pad(at.getHours())}:${pad(at.getMinutes())}`);
246
+ }
247
+ /**
248
+ * A timestamp on the reader's own clock.
249
+ *
250
+ * This used to lift the digits out of the string, which showed the API's UTC
251
+ * as if it were local time: a capture taken at 18:09 in Tokyo was listed as
252
+ * 09:09. The filters were always local, so only the display was wrong, and it
253
+ * was wrong by a whole timezone.
254
+ */
232
255
  function formatCreatedAt(value) {
233
- const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})/);
234
- if (match) {
235
- return `${match[1]} ${match[2]}:${match[3]}`;
256
+ const at = new Date(value);
257
+ if (Number.isNaN(at.getTime())) {
258
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}):(\d{2})/);
259
+ return match ? `${match[1]} ${match[2]}:${match[3]}` : value;
236
260
  }
237
- return value;
261
+ const pad = (n) => String(n).padStart(2, '0');
262
+ return (`${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` +
263
+ `${pad(at.getHours())}:${pad(at.getMinutes())}`);
238
264
  }
239
265
  function shortenImageId(imageId) {
240
266
  if (!imageId)
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ const list_1 = require("./commands/list");
12
12
  const get_1 = require("./commands/get");
13
13
  const collection_1 = require("./commands/collection");
14
14
  const search_1 = require("./commands/search");
15
+ const triage_1 = require("./commands/triage");
15
16
  const apps_1 = require("./commands/apps");
16
17
  const domains_1 = require("./commands/domains");
17
18
  const tags_1 = require("./commands/tags");
@@ -26,12 +27,13 @@ program
26
27
  .name('gyazo')
27
28
  .description('Gyazo Memory CLI for AI Secretary')
28
29
  .option('--mcp-server', 'run as a Model Context Protocol server over stdio')
29
- .version('0.8.0');
30
+ .version('0.10.0');
30
31
  (0, config_1.registerConfigCommand)(program);
31
32
  (0, list_1.registerListCommand)(program);
32
33
  (0, get_1.registerGetCommand)(program);
33
34
  (0, collection_1.registerCollectionCommand)(program);
34
35
  (0, search_1.registerSearchCommand)(program);
36
+ (0, triage_1.registerTriageCommand)(program);
35
37
  (0, apps_1.registerAppsCommand)(program);
36
38
  (0, domains_1.registerDomainsCommand)(program);
37
39
  (0, tags_1.registerTagsCommand)(program);
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getTriageLedgerPath = getTriageLedgerPath;
7
+ exports.loadTriageEntries = loadTriageEntries;
8
+ exports.loadTriageVerdicts = loadTriageVerdicts;
9
+ exports.appendTriageEntry = appendTriageEntry;
10
+ /**
11
+ * What was decided about a capture, and where that is kept.
12
+ *
13
+ * Deliberately not under the cache: the cache holds copies of things Gyazo can
14
+ * send again, and can be deleted to reclaim disk. A judgement cannot be
15
+ * fetched again, so it lives in the state directory and survives `rm -rf
16
+ * ~/.cache/gyazocli`.
17
+ */
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const os_1 = __importDefault(require("os"));
20
+ const path_1 = __importDefault(require("path"));
21
+ function getTriageLedgerPath() {
22
+ const base = process.env.GYAZO_STATE_DIR ||
23
+ process.env.XDG_STATE_HOME ||
24
+ path_1.default.join(os_1.default.homedir(), '.local', 'state');
25
+ const dir = path_1.default.join(base, 'gyazocli');
26
+ if (!fs_1.default.existsSync(dir)) {
27
+ fs_1.default.mkdirSync(dir, { recursive: true });
28
+ }
29
+ return path_1.default.join(dir, 'triage.jsonl');
30
+ }
31
+ function loadTriageEntries() {
32
+ const file = getTriageLedgerPath();
33
+ if (!fs_1.default.existsSync(file))
34
+ return [];
35
+ return fs_1.default
36
+ .readFileSync(file, 'utf-8')
37
+ .split('\n')
38
+ .filter((line) => line.trim() !== '')
39
+ .map((line) => {
40
+ try {
41
+ return JSON.parse(line);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ })
47
+ .filter((entry) => entry !== null);
48
+ }
49
+ /** The latest verdict for each capture, since the file is append-only. */
50
+ function loadTriageVerdicts() {
51
+ const byImage = new Map();
52
+ for (const entry of loadTriageEntries()) {
53
+ byImage.set(entry.image_id, entry);
54
+ }
55
+ return byImage;
56
+ }
57
+ function appendTriageEntry(entry) {
58
+ fs_1.default.appendFileSync(getTriageLedgerPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
59
+ }
package/dist/storage.js CHANGED
@@ -15,8 +15,11 @@ exports.saveHourlyCache = saveHourlyCache;
15
15
  exports.loadHourlyCache = loadHourlyCache;
16
16
  exports.saveHourlyMetadataCache = saveHourlyMetadataCache;
17
17
  exports.loadHourlyMetadataCache = loadHourlyMetadataCache;
18
+ exports.loadSyncState = loadSyncState;
19
+ exports.saveSyncState = saveSyncState;
18
20
  const fs_1 = __importDefault(require("fs"));
19
21
  const path_1 = __importDefault(require("path"));
22
+ const crypto_1 = __importDefault(require("crypto"));
20
23
  const os_1 = __importDefault(require("os"));
21
24
  function getCacheDir() {
22
25
  if (process.env.GYAZO_CACHE_DIR) {
@@ -105,3 +108,25 @@ function loadHourlyMetadataCache(kind, year, month, day, hour) {
105
108
  }
106
109
  return null;
107
110
  }
111
+ function getSyncStatePath(query) {
112
+ const dir = path_1.default.join(getCacheDir(), 'sync');
113
+ if (!fs_1.default.existsSync(dir)) {
114
+ fs_1.default.mkdirSync(dir, { recursive: true });
115
+ }
116
+ const key = crypto_1.default.createHash('sha1').update(query).digest('hex');
117
+ return path_1.default.join(dir, `${key}.json`);
118
+ }
119
+ function loadSyncState(query) {
120
+ const file = getSyncStatePath(query);
121
+ if (!fs_1.default.existsSync(file))
122
+ return null;
123
+ try {
124
+ return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ function saveSyncState(state) {
131
+ fs_1.default.writeFileSync(getSyncStatePath(state.query), JSON.stringify(state, null, 2));
132
+ }
@@ -13,12 +13,16 @@ Adopt and document the existing top-level command structure.
13
13
 
14
14
  ### 1. Program Metadata
15
15
  - Binary name: `gyazo`
16
- - Version: `0.8.0`
16
+ - Version: `0.10.0`
17
17
  - Description: `Gyazo Memory CLI for AI Secretary`
18
18
 
19
19
  ### 2. Commands
20
20
  - `gyazo config set <key> <value>`
21
21
  - Currently supported key: `token`
22
+ - Options:
23
+ - `--no-verify` (save a token without checking it against the API first)
24
+ - A token is checked against `/api/users/me` before it is saved, and nothing
25
+ is written when Gyazo rejects it
22
26
  - `gyazo config get <key>`
23
27
  - `token` is masked
24
28
  - `me` fetches `/api/users/me`
@@ -48,6 +52,50 @@ Adopt and document the existing top-level command structure.
48
52
  - `-l, --limit <number>` (default: `20`)
49
53
  - `-j, --json`
50
54
  - `--no-cache`
55
+ - `gyazo triage [query]`
56
+ - Markdown for reading a search result capture by capture: the image ID as
57
+ `#`, one `##` heading per field it carries, captures separated by `---`
58
+ - Options:
59
+ - `-q, --query <query>` (the query, also accepted as a bare argument)
60
+ - `-l, --limit <number>` (default: `20`; how many captures this run goes
61
+ through, not an API page size)
62
+ - `--max-pages <number>` (default: `20`; pages of 100 to walk looking for
63
+ captures not yet answered)
64
+ - `--color <auto|always|never>` (default: `auto`, meaning a terminal)
65
+ - `-i, --interactive` / `--no-interactive` (default: interactive when both
66
+ stdin and stdout are a terminal)
67
+ - `--again` (ask again about captures already answered)
68
+ - `--no-cache`
69
+ - What the query matched is painted orange, case kept. Bare words and the
70
+ values of text operators count; `has:`, `type:`, `date:`, `since:` and
71
+ `until:` values, negated terms and `OR` do not. Only when colour is on,
72
+ so piped output stays plain
73
+ - `access_policy` is printed first, and an unset one is printed as `anyone`:
74
+ the API leaves it unset on most captures, and a reader looking for
75
+ `only_me` is better served by every section saying which it is
76
+ - Captures already answered are skipped, and the walk continues to the next
77
+ page rather than stopping: a page where everything has been answered is not
78
+ the end of the results
79
+ - `--id <image_id...>` goes through exactly those captures, answered or not,
80
+ which is how a mistaken answer is corrected. The ledger is append-only and
81
+ the last answer for a capture wins
82
+ - Answering `n` sets the capture to `only_me` when gyazo.com cookies are
83
+ available, and otherwise prints its page URL and says why. Gyazo's public
84
+ API takes `access_policy` at upload and has no endpoint that changes it
85
+ afterwards, so this speaks to the web app's own route the way the site
86
+ does: a session cookie plus the CSRF token from the capture's page. Without
87
+ the token the request answers 422 with an empty body
88
+ - Cookies are looked for at `GYAZO_COOKIE_FILE`, then
89
+ `~/.config/gyazo/cookie.json`, then `~/.config/gyazo/cookies.json`, then
90
+ `./.cookies/gyazo.com.json`. A path given outright is the only candidate.
91
+ A browser export, a `{name: value}` object and a header string are all
92
+ accepted, and entries for other domains are dropped
93
+ - `--no-apply` answers without changing anything
94
+ - Interactive mode asks `Is it safe? [Y/n]` per capture. Enter takes the
95
+ default, `n` marks it unsafe, `q` or end of input stops and keeps what was
96
+ answered. Answers are appended to
97
+ `${XDG_STATE_HOME:-~/.local/state}/gyazocli/triage.jsonl`, outside the
98
+ cache, because a judgement cannot be fetched again
51
99
  - `gyazo apps`
52
100
  - Default range: from 8 days ago to yesterday
53
101
  - Options:
@@ -120,10 +168,16 @@ Adopt and document the existing top-level command structure.
120
168
  - `--max-pages <number>` (default: `10`)
121
169
  - `--query <query>` (fill from a search instead of the listing; not with
122
170
  `--date` or `--days`, because the range belongs inside the query)
171
+ - `--refresh` (fetch every capture again, even one already cached)
172
+ - `--continue` (resume the last walk of this query, with `--query`)
123
173
  - `gyazo import <type> <dir>`
124
174
  - Supported types: `json`, `hourly`
125
175
 
126
176
  ### 3. Output and Behavior Notes
177
+ - Timestamps are printed on the reader's own clock. The API sends UTC, and
178
+ until 2026-09-16 the display lifted the digits out of the string, so a
179
+ capture taken at 18:09 in Tokyo was listed as 09:09. The `--date`, `--hour`
180
+ and `--today` filters were always local; only the display was wrong.
127
181
  - `-j, --json` is available on `config get`, `list`, `get`, `search`, `apps`, `domains`, `tags`, `locations`, and `summary`.
128
182
  - `summary` default output is Markdown with headings (`## Gyazo Summary`, `### YYYY-MM-DD`) and nested bullet lists.
129
183
  - There are no global `--plain` or `--verbose` flags in current implementation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuiseki/gyazocli",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Gyazo Memory CLI for AI Secretary",
5
5
  "repository": {
6
6
  "type": "git",
@@ -13,6 +13,12 @@ questions about the same period are answered locally.
13
13
  Check `gyazo config get me` first when a token might be missing; it prints the
14
14
  account or exits non-zero. Set one with `gyazo config set token <token>`.
15
15
 
16
+ A 401 means Gyazo will not take the token that is there, which is a different
17
+ problem from not having one: it has been mistyped or revoked. Say so and stop,
18
+ rather than retrying or reaching for another command; every authenticated path
19
+ will answer the same way. `config set token` checks a token against the API
20
+ before saving it, so a bad one cannot quietly replace a working one.
21
+
16
22
  Every command exits non-zero on failure, so `&&` chains and `set -e` behave.
17
23
 
18
24
  ## Reading captures
@@ -25,6 +31,7 @@ gyazo ls --hour 2026-08-30-14 # one hour, from the cache only
25
31
  gyazo ls --photos # shorthand for has:location
26
32
  gyazo get <image_id> # one capture in detail
27
33
  gyazo get <image_id> --ocr # just the OCR text
34
+ gyazo get <image_id> --objects # just what was detected in it
28
35
  gyazo <image_id> # same as get
29
36
  gyazo <https://gyazo.com/...> # same as get
30
37
  gyazo ./screenshot.png # an existing file uploads instead
@@ -61,6 +68,28 @@ Three things worth knowing before composing a query:
61
68
  - **There is no coordinate or radius search.** Search by place with `address:`,
62
69
  which matches the address in any language and matches postal codes too.
63
70
 
71
+ ## Going through captures one at a time
72
+
73
+ ```bash
74
+ gyazo triage -q "password" # markdown, one section per capture
75
+ gyazo triage -q "API key" --limit 50
76
+ gyazo triage --id <image_id> # revisit one, answered or not
77
+ gyazo triage -q "password" --no-interactive > review.md
78
+ ```
79
+
80
+ `triage` prints everything a capture carries, one `##` heading per field it
81
+ has, with the image ID as `#` and `---` between captures. At a terminal it
82
+ asks `Is it safe? [Y/n]` after each one and records the answer in
83
+ `${XDG_STATE_HOME:-~/.local/state}/gyazocli/triage.jsonl`; a capture already
84
+ answered is skipped next time, and the walk pages on to find ones that are not.
85
+
86
+ Answering `n` sets that capture to `only_me` when gyazo.com cookies are
87
+ available, because the public API cannot change an access policy after upload.
88
+
89
+ For an agent: pipe it (`--no-interactive`) and read the output. Do not answer
90
+ the prompts on the user's behalf. Whether a capture is safe to leave public is
91
+ theirs to decide, and `n` writes to their account.
92
+
64
93
  ## Summaries and rankings
65
94
 
66
95
  ```bash
@@ -98,6 +127,7 @@ endpoint returns the first 100 and cannot page.
98
127
  gyazo sync --days 7 # yesterday back through 7 days
99
128
  gyazo sync --date 2026-08 # a whole month
100
129
  gyazo sync --query "has:exif OR has:location" --max-pages 20
130
+ gyazo sync --query "has:exif" --max-pages 20 --continue # carry on from last time
101
131
  ```
102
132
 
103
133
  `sync` covers yesterday backwards and never today, because today is still
@@ -108,8 +138,16 @@ command with `--today`.
108
138
  gather one kind of capture without walking past everything else: photographs
109
139
  are a small fraction of a day's screenshots. Put any date range inside the
110
140
  query (`date:2026-08`, `since:... until:...`) rather than in `--date`, which
111
- `--query` refuses. Budget about 40 seconds per page of 100 captures that are
112
- not cached yet.
141
+ `--query` refuses.
142
+
143
+ A capture already in the cache is not fetched again, so a repeated sync costs
144
+ only the search pages: 100 cached captures take under a second, 100 new ones
145
+ about 40 seconds. `--continue` remembers how far back the last walk of that
146
+ query reached and resumes with `until:<that day>`, which is how to backfill a
147
+ long history a few hundred pages at a time without asking for the same pages
148
+ twice. Gyazo rate-limits without documenting it, so prefer resuming over
149
+ re-walking, and leave `--refresh` alone unless a capture really needs
150
+ re-fetching.
113
151
 
114
152
  ## Answering questions with captures
115
153
 
@@ -119,9 +157,10 @@ not cached yet.
119
157
  work, and `ls --date` over a wide range walks many pages.
120
158
  - OCR text is noisy: it comes from screenshots at whatever resolution, and
121
159
  `locale` is often `und`. Treat it as a hint, not a transcript.
122
- - `get --objects` prints detected objects, but the API no longer returns the
123
- field it reads, so it exits non-zero with "Object annotations not found" on
124
- every capture tested. Use the OCR text instead.
160
+ - `get --objects` prints what was detected in the image, with a confidence.
161
+ About 59% of captures carry annotations; the rest exit non-zero with "Object
162
+ annotations not found", which means this capture has none, not that the
163
+ command is broken. `--ocr` and `--objects` cannot be combined.
125
164
  - **Do not turn a capture into a claim it does not support.** A product page or
126
165
  a cart is interest; an order confirmation or a payment receipt is a purchase.
127
166
  Say which capture the conclusion rests on.