@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 +21 -0
- package/dist/api.js +41 -0
- package/dist/commands/config.js +19 -1
- package/dist/commands/sync.js +60 -8
- package/dist/commands/triage.js +358 -0
- package/dist/cookies.js +89 -0
- package/dist/format.js +30 -4
- package/dist/index.js +3 -1
- package/dist/services/triage-ledger.js +59 -0
- package/dist/storage.js +25 -0
- package/docs/ADR/003-cli-structure.md +55 -1
- package/package.json +1 -1
- package/skills/gyazo/SKILL.md +44 -5
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 || '');
|
package/dist/commands/config.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
package/dist/commands/sync.js
CHANGED
|
@@ -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 (
|
|
53
|
-
console.log(`Syncing images matching ${JSON.stringify(
|
|
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 =
|
|
61
|
-
? await (0, api_1.searchImages)(
|
|
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 (!
|
|
98
|
+
if (!query && createdAt > endDate) {
|
|
71
99
|
// Skip images newer than target range.
|
|
72
100
|
continue;
|
|
73
101
|
}
|
|
74
|
-
if (!
|
|
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
|
-
|
|
88
|
-
|
|
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
|
+
}
|
package/dist/cookies.js
ADDED
|
@@ -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
|
|
234
|
-
if (
|
|
235
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
package/skills/gyazo/SKILL.md
CHANGED
|
@@ -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.
|
|
112
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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.
|