@yuiseki/gyazocli 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -3
- package/dist/api.js +43 -13
- package/dist/commands/apps.js +72 -0
- package/dist/commands/collection.js +45 -0
- package/dist/commands/config.js +64 -0
- package/dist/commands/domains.js +72 -0
- package/dist/commands/get.js +70 -0
- package/dist/commands/import.js +74 -0
- package/dist/commands/list.js +101 -0
- package/dist/commands/locations.js +72 -0
- package/dist/commands/search.js +43 -0
- package/dist/commands/stats.js +81 -0
- package/dist/commands/summary.js +42 -0
- package/dist/commands/sync.js +95 -0
- package/dist/commands/tags.js +70 -0
- package/dist/commands/upload.js +72 -0
- package/dist/config.js +6 -0
- package/dist/credentials.js +13 -1
- package/dist/dates.js +250 -0
- package/dist/format.js +375 -0
- package/dist/ids.js +77 -0
- package/dist/index.js +87 -2257
- package/dist/mcp.js +328 -0
- package/dist/options.js +20 -0
- package/dist/services/analytics.js +474 -0
- package/dist/services/collections.js +97 -0
- package/dist/services/images.js +184 -0
- package/dist/services/memory.js +373 -0
- package/docs/ADR/003-cli-structure.md +3 -1
- package/docs/ADR/004-mcp-server.md +88 -0
- package/docs/ADR/005-module-layout.md +72 -0
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -16,17 +16,37 @@ gyazo sync --days 10
|
|
|
16
16
|
gyazo --help
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
### Shorthands
|
|
20
|
+
|
|
21
|
+
The first argument may stand on its own when it is unambiguous. A Gyazo
|
|
22
|
+
image ID is 32 hex characters, so it can never be mistaken for a path.
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gyazo path/to/image.png # same as: gyazo upload path/to/image.png
|
|
26
|
+
gyazo 49a008e2f254f513063b6ec4d3082940 # same as: gyazo get 49a008e2f254f513063b6ec4d3082940
|
|
27
|
+
gyazo https://gyazo.com/49a008e2f254f513063b6ec4d3082940 # same as above
|
|
28
|
+
gyazo https://gyazo.com/collections/21ca16a1023c667a7a437be561a65018 # same as: gyazo collection <id>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A collection ID is 32 hex characters just like an image ID, so a bare ID is read
|
|
32
|
+
as an image. Only the `/collections/<id>` URL form is unambiguous. When a command
|
|
33
|
+
is given the wrong kind of ID it points at the other one.
|
|
34
|
+
|
|
35
|
+
Subcommand names always win, so `gyazo search` stays `gyazo search` even
|
|
36
|
+
if a file of that name exists in the working directory.
|
|
37
|
+
|
|
19
38
|
### Detail
|
|
20
39
|
|
|
21
40
|
- `gyazo config set token <token>`: Save your access token
|
|
22
41
|
- `gyazo config get token|me`: Show saved token (masked) or `me` profile info
|
|
23
42
|
- `gyazo ls` (`gyazo list`): List images (`--date`/`--today`, `--photos`, `--uploaded`, `-H` available; `--photos/--uploaded` can be combined with `--date`/`--today`)
|
|
24
43
|
- `gyazo search <query>`: Search images
|
|
25
|
-
- `gyazo
|
|
44
|
+
- `gyazo collection <collection_id|url>` (`col`, `cols`, `collections`): Show a collection and the images in it (`--sort added|created|captured`, `-A`, `-j` available)
|
|
45
|
+
- `gyazo get <image_id|url>`: Show image details (`--ocr`, `--objects`, `-j` available). Accepts a bare image ID, a `https://gyazo.com/<id>` permalink, or a `https://i.gyazo.com/<id>.png` URL
|
|
26
46
|
- `gyazo apps|domains|tags|locations`: Show rankings
|
|
27
47
|
- `gyazo summary`: Show day-by-day weekly summary in Markdown (`##`/`###` headings, image count, apps, domains, tags, locations per day)
|
|
28
48
|
- `gyazo stats`: Show weekly summary
|
|
29
|
-
- `gyazo upload [path]`: Upload an image (uses stdin when path is omitted)
|
|
49
|
+
- `gyazo upload [path]`: Upload an image (uses stdin when path is omitted). Prints the permalink URL alone; use `-j` for the full response
|
|
30
50
|
- `gyazo sync`: Sync cache
|
|
31
51
|
|
|
32
52
|
Date range notes:
|
|
@@ -34,7 +54,97 @@ Date range notes:
|
|
|
34
54
|
- Use `--today` for today only, or `--date <yyyy|yyyy-mm|yyyy-mm-dd>` for a custom range
|
|
35
55
|
|
|
36
56
|
JSON output:
|
|
37
|
-
- `-j, --json` is available for `config get`, `ls`, `get`, `search`, `apps`, `domains`, `tags`, `locations`, and `
|
|
57
|
+
- `-j, --json` is available for `config get`, `ls`, `get`, `search`, `apps`, `domains`, `tags`, `locations`, `summary`, and `upload`
|
|
58
|
+
|
|
59
|
+
### Anonymous access
|
|
60
|
+
|
|
61
|
+
A Gyazo ID is long enough to act as the key to the image, so public images and
|
|
62
|
+
collections read fine with no token. An access token, on the other hand, allows
|
|
63
|
+
things this CLI does not expose (deleting, for one), so the way to give an agent
|
|
64
|
+
read-only access is to give it no token at all.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
gyazo collection <id> # uses the token if there is one, otherwise reads anonymously
|
|
68
|
+
gyazo collection <id> --anonymous # ignores the token even when one is configured
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Anonymous reads have limits worth knowing:
|
|
72
|
+
|
|
73
|
+
- A collection or image set to `only_me` returns 404, indistinguishable from one
|
|
74
|
+
that does not exist. That is deliberate: it keeps the ID from confirming what
|
|
75
|
+
exists.
|
|
76
|
+
- An image with `metadata_is_public: false` still returns 200, but `metadata` and
|
|
77
|
+
`created_at` come back `null`, so OCR, EXIF and location are gone. The web
|
|
78
|
+
endpoint withholds these even from the owner's token; `gyazo get` uses
|
|
79
|
+
`api.gyazo.com` for that reason.
|
|
80
|
+
|
|
81
|
+
Exit codes:
|
|
82
|
+
- `0` on success, `1` on a usage error or a failed API call
|
|
83
|
+
|
|
84
|
+
Environment variables:
|
|
85
|
+
- `GYAZO_ACCESS_TOKEN`: access token (takes precedence over the saved config)
|
|
86
|
+
- `GYAZO_CACHE_DIR`: cache location
|
|
87
|
+
- `GYAZO_API_ORIGIN` / `GYAZO_UPLOAD_ORIGIN` / `GYAZO_WEB_ORIGIN`: override the endpoints (used by the test suite)
|
|
88
|
+
|
|
89
|
+
## MCP server
|
|
90
|
+
|
|
91
|
+
`gyazo --mcp-server` runs the CLI as a Model Context Protocol server over
|
|
92
|
+
stdio, so an MCP client can search your captures. `--mcp`, `mcp-server` and
|
|
93
|
+
`mcp` start the same thing.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
gyazo --mcp-server
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
It needs an access token before it starts, from `gyazo config set token` or
|
|
100
|
+
from `GYAZO_ACCESS_TOKEN` in the client's environment. stdout carries only
|
|
101
|
+
JSON-RPC; anything meant for a human goes to stderr.
|
|
102
|
+
|
|
103
|
+
Configured in a client:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"gyazo": {
|
|
109
|
+
"command": "npx",
|
|
110
|
+
"args": ["-y", "@yuiseki/gyazocli", "--mcp-server"],
|
|
111
|
+
"env": { "GYAZO_ACCESS_TOKEN": "your_access_token" }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Tools
|
|
118
|
+
|
|
119
|
+
- `gyazo_search`: full-text search over your captures. Arguments: `query`
|
|
120
|
+
(required, up to 200 characters), `page` (default 1), `per` (default 20,
|
|
121
|
+
max 100). Search syntax is the same as Gyazo's: `cat`, `title:cat`,
|
|
122
|
+
`app:"Google Chrome"`, `url:google.com`, `cat since:2024-01-01 until:2024-12-31`.
|
|
123
|
+
- `gyazo_image`: metadata for one capture. Argument: `id_or_url` (required),
|
|
124
|
+
which accepts a bare 32-character ID, a `https://gyazo.com/<id>` permalink or
|
|
125
|
+
a direct image URL.
|
|
126
|
+
- `gyazo_latest_image`: metadata for the capture uploaded most recently. No
|
|
127
|
+
arguments.
|
|
128
|
+
- `gyazo_list`: the captures, newest first, with the same options as
|
|
129
|
+
`gyazo list`: `page`, `limit`, `date`, `today`, `hour`, `photos`, `uploaded`,
|
|
130
|
+
`max_pages`, `use_cache`. No arguments means the most recent page.
|
|
131
|
+
- `gyazo_summary`: what a day or a range adds up to, with the same options as
|
|
132
|
+
`gyazo summary`: `date`, `today`, `limit`, `max_pages`, `use_cache`. No
|
|
133
|
+
arguments means the week up to yesterday.
|
|
134
|
+
- `gyazo_collection`: a collection and the captures in it. Arguments:
|
|
135
|
+
`id_or_url` (required) and `sort` (`added`, `created` or `captured`).
|
|
136
|
+
|
|
137
|
+
All of them are read-only, and all of them return metadata rather than image
|
|
138
|
+
bytes: URLs, timestamp, OCR text, title, source application and page, and
|
|
139
|
+
location when the capture carries one. Use the URLs in a result to show the
|
|
140
|
+
capture itself. Handing base64 image data to a model turned out not to work
|
|
141
|
+
well in practice, and describing a capture does.
|
|
142
|
+
|
|
143
|
+
Tool names and arguments follow
|
|
144
|
+
[nota/gyazo-mcp-server](https://github.com/nota/gyazo-mcp-server), so a client
|
|
145
|
+
already configured against that server can point at this one instead. Its
|
|
146
|
+
`gyazo_upload` is deliberately absent: nothing here can write to your Gyazo
|
|
147
|
+
account until there is a reason for it to.
|
|
38
148
|
|
|
39
149
|
## Development
|
|
40
150
|
|
|
@@ -45,6 +155,17 @@ npm install
|
|
|
45
155
|
npm run build
|
|
46
156
|
```
|
|
47
157
|
|
|
158
|
+
### Test
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
npm test
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Release
|
|
165
|
+
|
|
166
|
+
Push a `v*` tag and Actions stages the package on npm, where a maintainer
|
|
167
|
+
approves it with 2FA. See [RELEASE.md](RELEASE.md).
|
|
168
|
+
|
|
48
169
|
### Link local CLI with npm link
|
|
49
170
|
|
|
50
171
|
```bash
|
package/dist/api.js
CHANGED
|
@@ -7,18 +7,35 @@ exports.listImages = listImages;
|
|
|
7
7
|
exports.getImageDetail = getImageDetail;
|
|
8
8
|
exports.searchImages = searchImages;
|
|
9
9
|
exports.getCurrentUser = getCurrentUser;
|
|
10
|
+
exports.getCollection = getCollection;
|
|
10
11
|
exports.uploadImage = uploadImage;
|
|
11
12
|
const axios_1 = __importDefault(require("axios"));
|
|
12
13
|
const form_data_1 = __importDefault(require("form-data"));
|
|
13
14
|
const config_1 = require("./config");
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
const DEFAULT_API_ORIGIN = 'https://api.gyazo.com';
|
|
16
|
+
const DEFAULT_UPLOAD_ORIGIN = 'https://upload.gyazo.com';
|
|
17
|
+
const DEFAULT_WEB_ORIGIN = 'https://gyazo.com';
|
|
18
|
+
function stripTrailingSlash(origin) {
|
|
19
|
+
return origin.replace(/\/+$/, '');
|
|
20
|
+
}
|
|
21
|
+
function apiOrigin() {
|
|
22
|
+
return stripTrailingSlash(config_1.config.GYAZO_API_ORIGIN || DEFAULT_API_ORIGIN);
|
|
23
|
+
}
|
|
24
|
+
function uploadOrigin() {
|
|
25
|
+
return stripTrailingSlash(config_1.config.GYAZO_UPLOAD_ORIGIN || DEFAULT_UPLOAD_ORIGIN);
|
|
26
|
+
}
|
|
27
|
+
function webOrigin() {
|
|
28
|
+
return stripTrailingSlash(config_1.config.GYAZO_WEB_ORIGIN || DEFAULT_WEB_ORIGIN);
|
|
29
|
+
}
|
|
30
|
+
const apiBaseUrl = () => `${apiOrigin()}/api/images`;
|
|
31
|
+
const apiSearchUrl = () => `${apiOrigin()}/api/search`;
|
|
32
|
+
const apiUsersMeUrl = () => `${apiOrigin()}/api/users/me`;
|
|
33
|
+
const apiUploadUrl = () => `${uploadOrigin()}/api/upload`;
|
|
34
|
+
const webCollectionUrl = (id) => `${webOrigin()}/collections/${id}.json`;
|
|
35
|
+
async function requestWithRetry(url, params = {}, headers) {
|
|
36
|
+
const requestHeaders = headers ?? { Authorization: `Bearer ${config_1.config.GYAZO_ACCESS_TOKEN}` };
|
|
20
37
|
try {
|
|
21
|
-
const response = await axios_1.default.get(url, { headers, params });
|
|
38
|
+
const response = await axios_1.default.get(url, { headers: requestHeaders, params });
|
|
22
39
|
return response.data;
|
|
23
40
|
}
|
|
24
41
|
catch (error) {
|
|
@@ -26,22 +43,35 @@ async function requestWithRetry(url, params = {}) {
|
|
|
26
43
|
const retryAfter = parseInt(error.response.headers['retry-after'] || '5', 10);
|
|
27
44
|
console.warn(`Rate limited. Retrying after ${retryAfter} seconds...`);
|
|
28
45
|
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
|
|
29
|
-
return requestWithRetry(url, params);
|
|
46
|
+
return requestWithRetry(url, params, headers);
|
|
30
47
|
}
|
|
31
48
|
throw error;
|
|
32
49
|
}
|
|
33
50
|
}
|
|
34
51
|
async function listImages(page = 1, perPage = 20) {
|
|
35
|
-
return requestWithRetry(
|
|
52
|
+
return requestWithRetry(apiBaseUrl(), { page, per_page: perPage });
|
|
36
53
|
}
|
|
37
54
|
async function getImageDetail(imageId) {
|
|
38
|
-
return requestWithRetry(`${
|
|
55
|
+
return requestWithRetry(`${apiBaseUrl()}/${imageId}`);
|
|
39
56
|
}
|
|
40
57
|
async function searchImages(query, page = 1, perPage = 20) {
|
|
41
|
-
return requestWithRetry(
|
|
58
|
+
return requestWithRetry(apiSearchUrl(), { query, page, per: perPage });
|
|
42
59
|
}
|
|
43
60
|
async function getCurrentUser() {
|
|
44
|
-
return requestWithRetry(
|
|
61
|
+
return requestWithRetry(apiUsersMeUrl());
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Collections are read through the public web endpoint. It returns the
|
|
65
|
+
* collection metadata plus the first 100 images with their full detail in a
|
|
66
|
+
* single request, and it works without a token for public collections, which
|
|
67
|
+
* is what lets an agent run read-only with no credentials at all.
|
|
68
|
+
*/
|
|
69
|
+
async function getCollection(collectionId, options = {}) {
|
|
70
|
+
const headers = {};
|
|
71
|
+
if (!options.anonymous && config_1.config.GYAZO_ACCESS_TOKEN) {
|
|
72
|
+
headers.Authorization = `Bearer ${config_1.config.GYAZO_ACCESS_TOKEN}`;
|
|
73
|
+
}
|
|
74
|
+
return requestWithRetry(webCollectionUrl(collectionId), {}, headers);
|
|
45
75
|
}
|
|
46
76
|
async function uploadImage(options) {
|
|
47
77
|
const form = new form_data_1.default();
|
|
@@ -60,7 +90,7 @@ async function uploadImage(options) {
|
|
|
60
90
|
if (typeof options.timestamp === 'number') {
|
|
61
91
|
form.append('created_at', String(options.timestamp));
|
|
62
92
|
}
|
|
63
|
-
const response = await axios_1.default.post(
|
|
93
|
+
const response = await axios_1.default.post(apiUploadUrl(), form, {
|
|
64
94
|
headers: form.getHeaders(),
|
|
65
95
|
});
|
|
66
96
|
return response.data;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerAppsCommand = registerAppsCommand;
|
|
4
|
+
const credentials_1 = require("../credentials");
|
|
5
|
+
const dates_1 = require("../dates");
|
|
6
|
+
const options_1 = require("../options");
|
|
7
|
+
const memory_1 = require("../services/memory");
|
|
8
|
+
const analytics_1 = require("../services/analytics");
|
|
9
|
+
function registerAppsCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('apps')
|
|
12
|
+
.description('Rank metadata app names for a specific date')
|
|
13
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
14
|
+
.option('--today', 'target today only (overrides default weekly range)')
|
|
15
|
+
.option('-l, --limit <number>', 'maximum ranking rows (max: 10)', '10')
|
|
16
|
+
.option('--max-pages <number>', 'max pages to scan before stopping', '10')
|
|
17
|
+
.option('-j, --json', 'output as JSON')
|
|
18
|
+
.option('--no-cache', 'force fetch from API')
|
|
19
|
+
.action(async (options) => {
|
|
20
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
21
|
+
try {
|
|
22
|
+
const targetDate = (0, dates_1.resolveRankingRangeOption)(options);
|
|
23
|
+
const requestedLimit = (0, options_1.parsePositiveIntegerOption)(options.limit, '--limit');
|
|
24
|
+
const limit = Math.min(requestedLimit, 10);
|
|
25
|
+
const maxPages = (0, options_1.parsePositiveIntegerOption)(options.maxPages, '--max-pages');
|
|
26
|
+
const useCache = options.cache !== false;
|
|
27
|
+
let ranking = [];
|
|
28
|
+
let totalWithApp = 0;
|
|
29
|
+
let totalImages = 0;
|
|
30
|
+
if (useCache) {
|
|
31
|
+
let cacheSummary = (0, analytics_1.buildAppsRankingFromHourlyCache)(targetDate);
|
|
32
|
+
if (cacheSummary.totalImages === 0) {
|
|
33
|
+
await (0, memory_1.warmDateCacheForApps)(targetDate, maxPages, true);
|
|
34
|
+
cacheSummary = (0, analytics_1.buildAppsRankingFromHourlyCache)(targetDate);
|
|
35
|
+
}
|
|
36
|
+
ranking = cacheSummary.ranking;
|
|
37
|
+
totalWithApp = cacheSummary.imageCountWithApps;
|
|
38
|
+
totalImages = cacheSummary.totalImages;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const imageIds = await (0, memory_1.warmDateCacheForApps)(targetDate, maxPages, false);
|
|
42
|
+
ranking = (0, analytics_1.buildAppsRankingFromCache)(imageIds);
|
|
43
|
+
totalWithApp = ranking.reduce((sum, item) => sum + item.count, 0);
|
|
44
|
+
totalImages = imageIds.length;
|
|
45
|
+
}
|
|
46
|
+
const displayedRanking = ranking.slice(0, limit);
|
|
47
|
+
if (options.json) {
|
|
48
|
+
console.log(JSON.stringify({
|
|
49
|
+
date: targetDate.dateKey,
|
|
50
|
+
image_count: totalImages,
|
|
51
|
+
app_image_count: totalWithApp,
|
|
52
|
+
total_apps: ranking.length,
|
|
53
|
+
ranking: displayedRanking,
|
|
54
|
+
}, null, 2));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (ranking.length === 0) {
|
|
58
|
+
console.log(`No app metadata found for ${targetDate.dateKey}.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.log(`Apps on ${targetDate.dateKey}`);
|
|
62
|
+
displayedRanking.forEach((item, index) => {
|
|
63
|
+
console.log(`${index + 1}. ${item.app}: ${item.count}`);
|
|
64
|
+
});
|
|
65
|
+
console.log(`Total images with app metadata: ${totalWithApp}`);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error('Error ranking apps:', error.message);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerCollectionCommand = registerCollectionCommand;
|
|
4
|
+
const credentials_1 = require("../credentials");
|
|
5
|
+
const collections_1 = require("../services/collections");
|
|
6
|
+
function registerCollectionCommand(program) {
|
|
7
|
+
program
|
|
8
|
+
.command('collection <collection_id>')
|
|
9
|
+
.aliases(['col', 'cols', 'collections'])
|
|
10
|
+
.description('Show a collection and the images in it')
|
|
11
|
+
.option('-j, --json', 'output as JSON')
|
|
12
|
+
.option('-A, --anonymous', 'read without an access token, even when one is configured')
|
|
13
|
+
.option('--sort <added|created|captured>', 'image order (default: added)')
|
|
14
|
+
.action(async (collectionIdInput, options) => {
|
|
15
|
+
const collectionId = (0, collections_1.requireCollectionId)(collectionIdInput);
|
|
16
|
+
const sort = (0, collections_1.parseCollectionSort)(options.sort);
|
|
17
|
+
// No token is not an error here: public collections read fine anonymously.
|
|
18
|
+
if (!options.anonymous) {
|
|
19
|
+
(0, credentials_1.resolveAccessToken)();
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const { collection, images } = await (0, collections_1.readCollection)(collectionId, {
|
|
23
|
+
anonymous: Boolean(options.anonymous),
|
|
24
|
+
sort,
|
|
25
|
+
});
|
|
26
|
+
if (options.json) {
|
|
27
|
+
console.log(JSON.stringify(collection, null, 2));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
(0, collections_1.printCollectionMarkdown)(collection, images);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error?.response?.status === 404) {
|
|
34
|
+
console.error(`Error: collection ${collectionId} was not found or not public.`);
|
|
35
|
+
console.error('Hint: a private collection returns the same 404 as one that does not exist.');
|
|
36
|
+
if (options.anonymous) {
|
|
37
|
+
console.error('Hint: you are running with --anonymous. Drop it to use your access token.');
|
|
38
|
+
}
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
console.error('Error getting collection:', error.message);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerConfigCommand = registerConfigCommand;
|
|
4
|
+
const api_1 = require("../api");
|
|
5
|
+
const credentials_1 = require("../credentials");
|
|
6
|
+
function registerConfigCommand(program) {
|
|
7
|
+
const configCmd = program.command('config').description('Manage configuration');
|
|
8
|
+
configCmd
|
|
9
|
+
.command('set <key> <value>')
|
|
10
|
+
.description('Set a configuration value')
|
|
11
|
+
.action((key, value) => {
|
|
12
|
+
(0, credentials_1.setStoredConfig)(key, value);
|
|
13
|
+
});
|
|
14
|
+
configCmd
|
|
15
|
+
.command('get <key>')
|
|
16
|
+
.description('Get a configuration value')
|
|
17
|
+
.option('-j, --json', 'output as JSON')
|
|
18
|
+
.action(async (key, options) => {
|
|
19
|
+
if (key === 'me') {
|
|
20
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
21
|
+
try {
|
|
22
|
+
const me = await (0, api_1.getCurrentUser)();
|
|
23
|
+
if (options.json) {
|
|
24
|
+
console.log(JSON.stringify(me, null, 2));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const user = me?.user || {};
|
|
28
|
+
if (user.uid)
|
|
29
|
+
console.log(`UID: ${user.uid}`);
|
|
30
|
+
if (user.name)
|
|
31
|
+
console.log(`Name: ${user.name}`);
|
|
32
|
+
if (user.email)
|
|
33
|
+
console.log(`Email: ${user.email}`);
|
|
34
|
+
if (typeof user.is_pro === 'boolean')
|
|
35
|
+
console.log(`Plan: ${user.is_pro ? 'Pro' : 'Free'}`);
|
|
36
|
+
if (typeof user.is_team === 'boolean')
|
|
37
|
+
console.log(`Team: ${user.is_team ? 'Yes' : 'No'}`);
|
|
38
|
+
if (user.profile_image)
|
|
39
|
+
console.log(`Profile image: ${user.profile_image}`);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
console.error('Error getting current user:', error.message);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const value = (0, credentials_1.getStoredConfig)(key);
|
|
48
|
+
if (value) {
|
|
49
|
+
if (key === 'token') {
|
|
50
|
+
const masked = value.length > 8
|
|
51
|
+
? `${value.substring(0, 4)}...${value.substring(value.length - 4)}`
|
|
52
|
+
: '********';
|
|
53
|
+
console.log(masked);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
console.log(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.error(`Config key '${key}' not found.`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerDomainsCommand = registerDomainsCommand;
|
|
4
|
+
const credentials_1 = require("../credentials");
|
|
5
|
+
const dates_1 = require("../dates");
|
|
6
|
+
const options_1 = require("../options");
|
|
7
|
+
const memory_1 = require("../services/memory");
|
|
8
|
+
const analytics_1 = require("../services/analytics");
|
|
9
|
+
function registerDomainsCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('domains')
|
|
12
|
+
.description('Rank metadata URL domains for a specific date')
|
|
13
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
14
|
+
.option('--today', 'target today only (overrides default weekly range)')
|
|
15
|
+
.option('-l, --limit <number>', 'maximum ranking rows (max: 10)', '10')
|
|
16
|
+
.option('--max-pages <number>', 'max pages to scan before stopping', '10')
|
|
17
|
+
.option('-j, --json', 'output as JSON')
|
|
18
|
+
.option('--no-cache', 'force fetch from API')
|
|
19
|
+
.action(async (options) => {
|
|
20
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
21
|
+
try {
|
|
22
|
+
const targetDate = (0, dates_1.resolveRankingRangeOption)(options);
|
|
23
|
+
const requestedLimit = (0, options_1.parsePositiveIntegerOption)(options.limit, '--limit');
|
|
24
|
+
const limit = Math.min(requestedLimit, 10);
|
|
25
|
+
const maxPages = (0, options_1.parsePositiveIntegerOption)(options.maxPages, '--max-pages');
|
|
26
|
+
const useCache = options.cache !== false;
|
|
27
|
+
let ranking = [];
|
|
28
|
+
let totalWithDomain = 0;
|
|
29
|
+
let totalImages = 0;
|
|
30
|
+
if (useCache) {
|
|
31
|
+
let cacheSummary = (0, analytics_1.buildDomainsRankingFromHourlyCache)(targetDate);
|
|
32
|
+
if (cacheSummary.totalImages === 0) {
|
|
33
|
+
await (0, memory_1.warmDateCacheForDomains)(targetDate, maxPages, true);
|
|
34
|
+
cacheSummary = (0, analytics_1.buildDomainsRankingFromHourlyCache)(targetDate);
|
|
35
|
+
}
|
|
36
|
+
ranking = cacheSummary.ranking;
|
|
37
|
+
totalWithDomain = cacheSummary.imageCountWithDomains;
|
|
38
|
+
totalImages = cacheSummary.totalImages;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
const imageIds = await (0, memory_1.warmDateCacheForDomains)(targetDate, maxPages, false);
|
|
42
|
+
ranking = (0, analytics_1.buildDomainsRankingFromCache)(imageIds);
|
|
43
|
+
totalWithDomain = ranking.reduce((sum, item) => sum + item.count, 0);
|
|
44
|
+
totalImages = imageIds.length;
|
|
45
|
+
}
|
|
46
|
+
const displayedRanking = ranking.slice(0, limit);
|
|
47
|
+
if (options.json) {
|
|
48
|
+
console.log(JSON.stringify({
|
|
49
|
+
date: targetDate.dateKey,
|
|
50
|
+
image_count: totalImages,
|
|
51
|
+
domain_image_count: totalWithDomain,
|
|
52
|
+
total_domains: ranking.length,
|
|
53
|
+
ranking: displayedRanking,
|
|
54
|
+
}, null, 2));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (ranking.length === 0) {
|
|
58
|
+
console.log(`No domain metadata found for ${targetDate.dateKey}.`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.log(`Domains on ${targetDate.dateKey}`);
|
|
62
|
+
displayedRanking.forEach((item, index) => {
|
|
63
|
+
console.log(`${index + 1}. ${item.domain}: ${item.count}`);
|
|
64
|
+
});
|
|
65
|
+
console.log(`Total images with domain metadata: ${totalWithDomain}`);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error('Error ranking domains:', error.message);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerGetCommand = registerGetCommand;
|
|
4
|
+
const api_1 = require("../api");
|
|
5
|
+
const storage_1 = require("../storage");
|
|
6
|
+
const credentials_1 = require("../credentials");
|
|
7
|
+
const format_1 = require("../format");
|
|
8
|
+
const memory_1 = require("../services/memory");
|
|
9
|
+
const images_1 = require("../services/images");
|
|
10
|
+
function registerGetCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('get <image_id>')
|
|
13
|
+
.description('Get detailed metadata for an image')
|
|
14
|
+
.option('-j, --json', 'output as JSON')
|
|
15
|
+
.option('--ocr', 'output OCR text only')
|
|
16
|
+
.option('--objects', 'output object annotations only')
|
|
17
|
+
.option('--no-cache', 'force fetch from API')
|
|
18
|
+
.action(async (imageId, options) => {
|
|
19
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
20
|
+
try {
|
|
21
|
+
if (options.json && (options.ocr || options.objects)) {
|
|
22
|
+
console.error('Error: --json cannot be used with --ocr or --objects.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
if (options.ocr && options.objects) {
|
|
26
|
+
console.error('Error: --ocr and --objects cannot be used together.');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
imageId = (0, images_1.requireImageId)(imageId);
|
|
30
|
+
let image = options.cache !== false ? (0, storage_1.loadImageCache)(imageId) : null;
|
|
31
|
+
if (!image) {
|
|
32
|
+
image = await (0, api_1.getImageDetail)(imageId);
|
|
33
|
+
(0, storage_1.saveImageCache)(imageId, image);
|
|
34
|
+
}
|
|
35
|
+
const supplemented = (0, memory_1.supplementAltTextFromSearchCache)(image);
|
|
36
|
+
image = supplemented.image;
|
|
37
|
+
if (supplemented.supplemented) {
|
|
38
|
+
(0, storage_1.saveImageCache)(imageId, image);
|
|
39
|
+
}
|
|
40
|
+
const ocrDescription = (0, format_1.extractOcrDescription)(image);
|
|
41
|
+
const objects = (0, format_1.extractObjectAnnotations)(image);
|
|
42
|
+
if (options.ocr) {
|
|
43
|
+
if (!ocrDescription) {
|
|
44
|
+
console.error('OCR not found for this image.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
console.log(ocrDescription);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (options.objects) {
|
|
51
|
+
if (objects.length === 0) {
|
|
52
|
+
console.error('Object annotations not found for this image.');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
console.log(objects.map(format_1.formatObjectAnnotationLine).join('\n'));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (options.json) {
|
|
59
|
+
console.log(JSON.stringify(image, null, 2));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
(0, images_1.printGetMarkdown)(image, ocrDescription, objects);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error('Error getting image:', error.message);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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.registerImportCommand = registerImportCommand;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const storage_1 = require("../storage");
|
|
10
|
+
function registerImportCommand(program) {
|
|
11
|
+
program
|
|
12
|
+
.command('import <type> <dir>')
|
|
13
|
+
.description('Import legacy data (type: json|hourly)')
|
|
14
|
+
.action(async (type, dir) => {
|
|
15
|
+
const sourceDir = path_1.default.resolve(dir);
|
|
16
|
+
if (!fs_1.default.existsSync(sourceDir)) {
|
|
17
|
+
console.error(`Error: Source directory ${sourceDir} does not exist.`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
if (type === 'json') {
|
|
21
|
+
const targetDir = path_1.default.join((0, storage_1.getCacheDir)(), 'images');
|
|
22
|
+
console.log(`Importing legacy Gyazo JSON from ${sourceDir}...`);
|
|
23
|
+
let total = 0;
|
|
24
|
+
const walk = (d) => {
|
|
25
|
+
fs_1.default.readdirSync(d, { withFileTypes: true }).forEach(e => {
|
|
26
|
+
const p = path_1.default.join(d, e.name);
|
|
27
|
+
if (e.isDirectory())
|
|
28
|
+
walk(p);
|
|
29
|
+
else if (e.name.endsWith('.json')) {
|
|
30
|
+
const id = e.name.replace('.json', '');
|
|
31
|
+
const p1 = id[0] || '_', p2 = id[1] || '_';
|
|
32
|
+
const dest = path_1.default.join(targetDir, p1, p2);
|
|
33
|
+
if (!fs_1.default.existsSync(dest))
|
|
34
|
+
fs_1.default.mkdirSync(dest, { recursive: true });
|
|
35
|
+
fs_1.default.copyFileSync(p, path_1.default.join(dest, e.name));
|
|
36
|
+
total++;
|
|
37
|
+
if (total % 100 === 0)
|
|
38
|
+
process.stdout.write('.');
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
walk(sourceDir);
|
|
43
|
+
console.log(`\nImport complete. Copied ${total} files.`);
|
|
44
|
+
}
|
|
45
|
+
else if (type === 'hourly') {
|
|
46
|
+
console.log(`Importing legacy Gyazo hourly data from ${sourceDir}...`);
|
|
47
|
+
let total = 0;
|
|
48
|
+
const years = fs_1.default.readdirSync(sourceDir).filter(f => /^[0-9]{4}$/.test(f));
|
|
49
|
+
for (const y of years) {
|
|
50
|
+
const months = fs_1.default.readdirSync(path_1.default.join(sourceDir, y)).filter(f => /^[0-9]{2}$/.test(f));
|
|
51
|
+
for (const m of months) {
|
|
52
|
+
const days = fs_1.default.readdirSync(path_1.default.join(sourceDir, y, m)).filter(f => /^[0-9]{2}$/.test(f));
|
|
53
|
+
for (const d of days) {
|
|
54
|
+
const hours = fs_1.default.readdirSync(path_1.default.join(sourceDir, y, m, d)).filter(f => /^[0-9]{2}$/.test(f));
|
|
55
|
+
for (const h of hours) {
|
|
56
|
+
const txt = path_1.default.join(sourceDir, y, m, d, h, 'image_ids.txt');
|
|
57
|
+
if (fs_1.default.existsSync(txt)) {
|
|
58
|
+
const ids = fs_1.default.readFileSync(txt, 'utf-8').split('\n').map(id => id.trim()).filter(id => id.length > 0);
|
|
59
|
+
(0, storage_1.saveHourlyCache)(y, m, d, h, ids);
|
|
60
|
+
total++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write('.');
|
|
66
|
+
}
|
|
67
|
+
console.log(`\nImport complete. Copied ${total} hourly index files.`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
console.error('Error: type must be "json" or "hourly"');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|