@yuiseki/gyazocli 0.0.1 → 0.1.1
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 +76 -0
- package/dist/api.js +43 -13
- package/dist/config.js +6 -0
- package/dist/credentials.js +13 -1
- package/dist/index.js +610 -11
- package/docs/ADR/002-caching-strategy.md +8 -0
- package/docs/ADR/003-cli-structure.md +20 -4
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -16,6 +16,76 @@ 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
|
+
|
|
38
|
+
### Detail
|
|
39
|
+
|
|
40
|
+
- `gyazo config set token <token>`: Save your access token
|
|
41
|
+
- `gyazo config get token|me`: Show saved token (masked) or `me` profile info
|
|
42
|
+
- `gyazo ls` (`gyazo list`): List images (`--date`/`--today`, `--photos`, `--uploaded`, `-H` available; `--photos/--uploaded` can be combined with `--date`/`--today`)
|
|
43
|
+
- `gyazo search <query>`: Search images
|
|
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
|
|
46
|
+
- `gyazo apps|domains|tags|locations`: Show rankings
|
|
47
|
+
- `gyazo summary`: Show day-by-day weekly summary in Markdown (`##`/`###` headings, image count, apps, domains, tags, locations per day)
|
|
48
|
+
- `gyazo stats`: Show weekly summary
|
|
49
|
+
- `gyazo upload [path]`: Upload an image (uses stdin when path is omitted). Prints the permalink URL alone; use `-j` for the full response
|
|
50
|
+
- `gyazo sync`: Sync cache
|
|
51
|
+
|
|
52
|
+
Date range notes:
|
|
53
|
+
- Default range for `apps|domains|tags|locations|stats` is from 8 days ago to yesterday
|
|
54
|
+
- Use `--today` for today only, or `--date <yyyy|yyyy-mm|yyyy-mm-dd>` for a custom range
|
|
55
|
+
|
|
56
|
+
JSON output:
|
|
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
|
+
|
|
19
89
|
## Development
|
|
20
90
|
|
|
21
91
|
### Build
|
|
@@ -25,6 +95,12 @@ npm install
|
|
|
25
95
|
npm run build
|
|
26
96
|
```
|
|
27
97
|
|
|
98
|
+
### Test
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm test
|
|
102
|
+
```
|
|
103
|
+
|
|
28
104
|
### Link local CLI with npm link
|
|
29
105
|
|
|
30
106
|
```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;
|
package/dist/config.js
CHANGED
|
@@ -13,12 +13,18 @@ const configSchema = zod_1.z.object({
|
|
|
13
13
|
GYAZO_CLIENT_ID: zod_1.z.string().optional(),
|
|
14
14
|
GYAZO_CLIENT_SECRET: zod_1.z.string().optional(),
|
|
15
15
|
GYAZO_CACHE_DIR: zod_1.z.string().optional(),
|
|
16
|
+
GYAZO_API_ORIGIN: zod_1.z.string().optional(),
|
|
17
|
+
GYAZO_UPLOAD_ORIGIN: zod_1.z.string().optional(),
|
|
18
|
+
GYAZO_WEB_ORIGIN: zod_1.z.string().optional(),
|
|
16
19
|
});
|
|
17
20
|
exports.config = configSchema.parse({
|
|
18
21
|
GYAZO_ACCESS_TOKEN: process.env.GYAZO_ACCESS_TOKEN,
|
|
19
22
|
GYAZO_CLIENT_ID: process.env.GYAZO_CLIENT_ID,
|
|
20
23
|
GYAZO_CLIENT_SECRET: process.env.GYAZO_CLIENT_SECRET,
|
|
21
24
|
GYAZO_CACHE_DIR: process.env.GYAZO_CACHE_DIR,
|
|
25
|
+
GYAZO_API_ORIGIN: process.env.GYAZO_API_ORIGIN,
|
|
26
|
+
GYAZO_UPLOAD_ORIGIN: process.env.GYAZO_UPLOAD_ORIGIN,
|
|
27
|
+
GYAZO_WEB_ORIGIN: process.env.GYAZO_WEB_ORIGIN,
|
|
22
28
|
});
|
|
23
29
|
function setAccessToken(token) {
|
|
24
30
|
exports.config.GYAZO_ACCESS_TOKEN = token;
|
package/dist/credentials.js
CHANGED
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.getStoredConfig = getStoredConfig;
|
|
7
7
|
exports.setStoredConfig = setStoredConfig;
|
|
8
|
+
exports.resolveAccessToken = resolveAccessToken;
|
|
8
9
|
exports.ensureAccessToken = ensureAccessToken;
|
|
9
10
|
const fs_1 = __importDefault(require("fs"));
|
|
10
11
|
const path_1 = __importDefault(require("path"));
|
|
@@ -44,7 +45,11 @@ function setStoredConfig(key, value) {
|
|
|
44
45
|
fs_1.default.writeFileSync(CREDENTIALS_FILE, JSON.stringify(stored, null, 2));
|
|
45
46
|
console.error(`Config set: ${key}=${value}`);
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Load the access token if there is one, without failing when there is not.
|
|
50
|
+
* Read-only commands fall back to anonymous access instead of exiting.
|
|
51
|
+
*/
|
|
52
|
+
function resolveAccessToken() {
|
|
48
53
|
if (config_1.config.GYAZO_ACCESS_TOKEN) {
|
|
49
54
|
return config_1.config.GYAZO_ACCESS_TOKEN;
|
|
50
55
|
}
|
|
@@ -53,6 +58,13 @@ async function ensureAccessToken() {
|
|
|
53
58
|
(0, config_1.setAccessToken)(storedToken);
|
|
54
59
|
return storedToken;
|
|
55
60
|
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
async function ensureAccessToken() {
|
|
64
|
+
const token = resolveAccessToken();
|
|
65
|
+
if (token) {
|
|
66
|
+
return token;
|
|
67
|
+
}
|
|
56
68
|
console.error('Error: Gyazo Access Token is not set.');
|
|
57
69
|
console.error('Please run the following command to set your access token:');
|
|
58
70
|
console.error(' gyazo config set token <your_access_token>');
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.normalizeImageId = normalizeImageId;
|
|
8
|
+
exports.normalizeCollectionId = normalizeCollectionId;
|
|
7
9
|
const commander_1 = require("commander");
|
|
8
10
|
const fs_1 = __importDefault(require("fs"));
|
|
9
11
|
const path_1 = __importDefault(require("path"));
|
|
@@ -13,10 +15,123 @@ const credentials_1 = require("./credentials");
|
|
|
13
15
|
const program = new commander_1.Command();
|
|
14
16
|
const UPLOAD_DESC_TAG = '#gyazocli_uploads';
|
|
15
17
|
const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
18
|
+
const IMAGE_ID_PATTERN = /^[0-9a-f]{32}$/i;
|
|
19
|
+
const GYAZO_HOST_PATTERN = /(^|\.)gyazo\.com$/i;
|
|
20
|
+
/**
|
|
21
|
+
* Accept either a bare Gyazo image id (32 hex characters) or any Gyazo URL that
|
|
22
|
+
* carries one, and return the canonical lowercase id. Returns null otherwise.
|
|
23
|
+
*/
|
|
24
|
+
function normalizeImageId(input) {
|
|
25
|
+
const trimmed = (input || '').trim();
|
|
26
|
+
if (!trimmed)
|
|
27
|
+
return null;
|
|
28
|
+
if (IMAGE_ID_PATTERN.test(trimmed)) {
|
|
29
|
+
return trimmed.toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
let url;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(trimmed);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (!GYAZO_HOST_PATTERN.test(url.hostname)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
45
|
+
const lastSegment = segments[segments.length - 1];
|
|
46
|
+
if (!lastSegment)
|
|
47
|
+
return null;
|
|
48
|
+
// /collections/<id> is a collection, not an image.
|
|
49
|
+
if (segments[segments.length - 2] === 'collections')
|
|
50
|
+
return null;
|
|
51
|
+
const withoutExtension = lastSegment.replace(/\.[a-z0-9]+$/i, '');
|
|
52
|
+
return IMAGE_ID_PATTERN.test(withoutExtension) ? withoutExtension.toLowerCase() : null;
|
|
53
|
+
}
|
|
54
|
+
const COLLECTION_SORTS = ['added', 'created', 'captured'];
|
|
55
|
+
/**
|
|
56
|
+
* A collection ID looks exactly like an image ID (32 hex characters), so only
|
|
57
|
+
* the URL form tells the two apart. `/collections/<id>` is a collection;
|
|
58
|
+
* `/<id>` is an image.
|
|
59
|
+
*/
|
|
60
|
+
function normalizeCollectionId(input) {
|
|
61
|
+
const trimmed = (input || '').trim();
|
|
62
|
+
if (!trimmed)
|
|
63
|
+
return null;
|
|
64
|
+
if (IMAGE_ID_PATTERN.test(trimmed)) {
|
|
65
|
+
return trimmed.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
if (!/^https?:\/\//i.test(trimmed)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
let url;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(trimmed);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (!GYAZO_HOST_PATTERN.test(url.hostname)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
81
|
+
if (segments.length < 2 || segments[segments.length - 2] !== 'collections') {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const withoutExtension = segments[segments.length - 1].replace(/\.[a-z0-9]+$/i, '');
|
|
85
|
+
return IMAGE_ID_PATTERN.test(withoutExtension) ? withoutExtension.toLowerCase() : null;
|
|
86
|
+
}
|
|
87
|
+
function requireCollectionId(input) {
|
|
88
|
+
const collectionId = normalizeCollectionId(input);
|
|
89
|
+
if (!collectionId) {
|
|
90
|
+
console.error(`Error: '${input}' is not a Gyazo collection ID or URL.`);
|
|
91
|
+
console.error('Hint: pass a 32-character collection ID or a https://gyazo.com/collections/<id> URL.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
return collectionId;
|
|
95
|
+
}
|
|
96
|
+
function parseCollectionSort(value) {
|
|
97
|
+
if (value === undefined || value === null)
|
|
98
|
+
return 'added';
|
|
99
|
+
if (COLLECTION_SORTS.includes(String(value))) {
|
|
100
|
+
return String(value);
|
|
101
|
+
}
|
|
102
|
+
console.error(`Error: --sort must be one of ${COLLECTION_SORTS.join(', ')}.`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
function collectionSortKey(image, sort) {
|
|
106
|
+
if (sort === 'captured') {
|
|
107
|
+
return image?.exif_captured_at || image?.metadata?.exif_normalized?.time || image?.created_at || '';
|
|
108
|
+
}
|
|
109
|
+
return image?.created_at || '';
|
|
110
|
+
}
|
|
111
|
+
function sortCollectionImages(images, sort) {
|
|
112
|
+
if (sort === 'added')
|
|
113
|
+
return images;
|
|
114
|
+
// Newest first, matching how `list` presents images.
|
|
115
|
+
return [...images].sort((a, b) => collectionSortKey(b, sort).localeCompare(collectionSortKey(a, sort)));
|
|
116
|
+
}
|
|
117
|
+
function requireImageId(input) {
|
|
118
|
+
const imageId = normalizeImageId(input);
|
|
119
|
+
if (!imageId) {
|
|
120
|
+
console.error(`Error: '${input}' is not a Gyazo image ID or URL.`);
|
|
121
|
+
if (normalizeCollectionId(input)) {
|
|
122
|
+
console.error(`Hint: that looks like a collection. Try \`gyazo collection ${input}\`.`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
console.error('Hint: pass a 32-character image ID or a https://gyazo.com/<id> URL.');
|
|
126
|
+
}
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
return imageId;
|
|
130
|
+
}
|
|
16
131
|
program
|
|
17
132
|
.name('gyazo')
|
|
18
133
|
.description('Gyazo Memory CLI for AI Secretary')
|
|
19
|
-
.version('0.
|
|
134
|
+
.version('0.1.1');
|
|
20
135
|
// Config Command
|
|
21
136
|
const configCmd = program.command('config').description('Manage configuration');
|
|
22
137
|
configCmd
|
|
@@ -540,6 +655,19 @@ function getDatePartsInRange(start, end) {
|
|
|
540
655
|
}
|
|
541
656
|
return dates;
|
|
542
657
|
}
|
|
658
|
+
function loadImageIdsFromDateRangeCache(targetDate) {
|
|
659
|
+
const imageIds = new Set();
|
|
660
|
+
const dates = getDatePartsInRange(targetDate.start, targetDate.end);
|
|
661
|
+
const hours = getDateHourStrings();
|
|
662
|
+
for (const date of dates) {
|
|
663
|
+
for (const hour of hours) {
|
|
664
|
+
const ids = (0, storage_1.loadHourlyCache)(date.year, date.month, date.day, hour) || [];
|
|
665
|
+
for (const id of ids)
|
|
666
|
+
imageIds.add(id);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return Array.from(imageIds);
|
|
670
|
+
}
|
|
543
671
|
function normalizeRankingValues(values) {
|
|
544
672
|
const uniqueByLower = new Map();
|
|
545
673
|
for (const raw of values) {
|
|
@@ -630,6 +758,59 @@ async function warmDateCacheForTags(targetDate, maxPages, useCache) {
|
|
|
630
758
|
async function warmDateCacheForLocations(targetDate, maxPages, useCache) {
|
|
631
759
|
return warmDateCacheForRanking(targetDate, maxPages, useCache, 'locations', extractImageLocations);
|
|
632
760
|
}
|
|
761
|
+
async function warmDateCacheForList(targetDate, maxPages, useCache) {
|
|
762
|
+
const hourlyIndices = new Map();
|
|
763
|
+
const imageIds = new Set();
|
|
764
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
765
|
+
const images = await (0, api_1.listImages)(page, 100);
|
|
766
|
+
if (images.length === 0)
|
|
767
|
+
break;
|
|
768
|
+
let reachedLimit = false;
|
|
769
|
+
for (const img of images) {
|
|
770
|
+
const createdAt = new Date(img.created_at);
|
|
771
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
772
|
+
continue;
|
|
773
|
+
if (createdAt > targetDate.end)
|
|
774
|
+
continue;
|
|
775
|
+
if (createdAt < targetDate.start) {
|
|
776
|
+
reachedLimit = true;
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
const dateParts = toDateParts(createdAt);
|
|
780
|
+
const bucketKey = buildHourlyBucketKey(dateParts.year, dateParts.month, dateParts.day, dateParts.hour);
|
|
781
|
+
if (!hourlyIndices.has(bucketKey)) {
|
|
782
|
+
hourlyIndices.set(bucketKey, new Set());
|
|
783
|
+
}
|
|
784
|
+
hourlyIndices.get(bucketKey)?.add(img.image_id);
|
|
785
|
+
imageIds.add(img.image_id);
|
|
786
|
+
let merged = img;
|
|
787
|
+
const cached = useCache ? (0, storage_1.loadImageCache)(img.image_id) : null;
|
|
788
|
+
if (cached) {
|
|
789
|
+
merged = mergeImageForDisplay(img, cached);
|
|
790
|
+
}
|
|
791
|
+
(0, storage_1.saveImageCache)(img.image_id, merged);
|
|
792
|
+
}
|
|
793
|
+
if (reachedLimit)
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
for (const [bucketKey, current] of hourlyIndices.entries()) {
|
|
797
|
+
const { year, month, day, hour } = splitHourlyBucketKey(bucketKey);
|
|
798
|
+
if (useCache) {
|
|
799
|
+
const existing = (0, storage_1.loadHourlyCache)(year, month, day, hour) || [];
|
|
800
|
+
for (const id of existing)
|
|
801
|
+
current.add(id);
|
|
802
|
+
}
|
|
803
|
+
(0, storage_1.saveHourlyCache)(year, month, day, hour, Array.from(current));
|
|
804
|
+
for (const id of current)
|
|
805
|
+
imageIds.add(id);
|
|
806
|
+
}
|
|
807
|
+
if (useCache) {
|
|
808
|
+
for (const id of loadImageIdsFromDateRangeCache(targetDate)) {
|
|
809
|
+
imageIds.add(id);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return Array.from(imageIds);
|
|
813
|
+
}
|
|
633
814
|
async function warmDateCacheForRanking(targetDate, maxPages, useCache, metadataKind, extractValues) {
|
|
634
815
|
const hourlyIndices = new Map();
|
|
635
816
|
const hourlyMetadataEntries = new Map();
|
|
@@ -975,6 +1156,122 @@ function buildUploadTimeSummaryFromImageCache(imageIds, targetDate) {
|
|
|
975
1156
|
}),
|
|
976
1157
|
};
|
|
977
1158
|
}
|
|
1159
|
+
function buildDailyUploadCountsFromHourlyCache(targetDate) {
|
|
1160
|
+
const dates = getDatePartsInRange(targetDate.start, targetDate.end);
|
|
1161
|
+
const hours = getDateHourStrings();
|
|
1162
|
+
const byDate = new Map();
|
|
1163
|
+
for (const date of dates) {
|
|
1164
|
+
const dateLabel = `${date.year}-${date.month}-${date.day}`;
|
|
1165
|
+
if (!byDate.has(dateLabel)) {
|
|
1166
|
+
byDate.set(dateLabel, new Set());
|
|
1167
|
+
}
|
|
1168
|
+
const ids = byDate.get(dateLabel);
|
|
1169
|
+
for (const hour of hours) {
|
|
1170
|
+
const imageIds = (0, storage_1.loadHourlyCache)(date.year, date.month, date.day, hour) || [];
|
|
1171
|
+
for (const imageId of imageIds)
|
|
1172
|
+
ids.add(imageId);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
return dates.map(date => {
|
|
1176
|
+
const dateLabel = `${date.year}-${date.month}-${date.day}`;
|
|
1177
|
+
return {
|
|
1178
|
+
date: dateLabel,
|
|
1179
|
+
count: byDate.get(dateLabel)?.size || 0,
|
|
1180
|
+
};
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
function buildDailyUploadCountsFromImageCache(imageIds, targetDate) {
|
|
1184
|
+
const dates = getDatePartsInRange(targetDate.start, targetDate.end);
|
|
1185
|
+
const byDate = new Map();
|
|
1186
|
+
for (const date of dates) {
|
|
1187
|
+
const dateLabel = `${date.year}-${date.month}-${date.day}`;
|
|
1188
|
+
byDate.set(dateLabel, new Set());
|
|
1189
|
+
}
|
|
1190
|
+
for (const imageId of imageIds) {
|
|
1191
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
1192
|
+
const createdAtText = normalizeText(image?.created_at);
|
|
1193
|
+
if (!createdAtText)
|
|
1194
|
+
continue;
|
|
1195
|
+
const createdAt = new Date(createdAtText);
|
|
1196
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
1197
|
+
continue;
|
|
1198
|
+
if (createdAt < targetDate.start || createdAt > targetDate.end)
|
|
1199
|
+
continue;
|
|
1200
|
+
const dateLabel = formatDateYmd(createdAt);
|
|
1201
|
+
const ids = byDate.get(dateLabel);
|
|
1202
|
+
if (!ids)
|
|
1203
|
+
continue;
|
|
1204
|
+
ids.add(imageId);
|
|
1205
|
+
}
|
|
1206
|
+
return dates.map(date => {
|
|
1207
|
+
const dateLabel = `${date.year}-${date.month}-${date.day}`;
|
|
1208
|
+
return {
|
|
1209
|
+
date: dateLabel,
|
|
1210
|
+
count: byDate.get(dateLabel)?.size || 0,
|
|
1211
|
+
};
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
function buildDailySummariesFromImageCache(targetDate) {
|
|
1215
|
+
const dates = getDatePartsInRange(targetDate.start, targetDate.end);
|
|
1216
|
+
const hours = getDateHourStrings();
|
|
1217
|
+
const summaries = [];
|
|
1218
|
+
for (const date of dates) {
|
|
1219
|
+
const dateLabel = `${date.year}-${date.month}-${date.day}`;
|
|
1220
|
+
const imageIds = new Set();
|
|
1221
|
+
for (const hour of hours) {
|
|
1222
|
+
const ids = (0, storage_1.loadHourlyCache)(date.year, date.month, date.day, hour) || [];
|
|
1223
|
+
for (const id of ids)
|
|
1224
|
+
imageIds.add(id);
|
|
1225
|
+
}
|
|
1226
|
+
const appCounts = new Map();
|
|
1227
|
+
const domainCounts = new Map();
|
|
1228
|
+
const tagCounts = new Map();
|
|
1229
|
+
const locationCounts = new Map();
|
|
1230
|
+
for (const imageId of imageIds) {
|
|
1231
|
+
const image = (0, storage_1.loadImageCache)(imageId);
|
|
1232
|
+
if (!image)
|
|
1233
|
+
continue;
|
|
1234
|
+
for (const app of extractImageApps(image)) {
|
|
1235
|
+
appCounts.set(app, (appCounts.get(app) || 0) + 1);
|
|
1236
|
+
}
|
|
1237
|
+
for (const domain of extractImageDomains(image)) {
|
|
1238
|
+
domainCounts.set(domain, (domainCounts.get(domain) || 0) + 1);
|
|
1239
|
+
}
|
|
1240
|
+
for (const tag of extractImageTags(image)) {
|
|
1241
|
+
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
|
1242
|
+
}
|
|
1243
|
+
for (const location of extractImageLocations(image)) {
|
|
1244
|
+
locationCounts.set(location, (locationCounts.get(location) || 0) + 1);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const sortEntries = (a, b) => {
|
|
1248
|
+
if (b[1] !== a[1])
|
|
1249
|
+
return b[1] - a[1];
|
|
1250
|
+
return a[0].localeCompare(b[0]);
|
|
1251
|
+
};
|
|
1252
|
+
const apps = Array.from(appCounts.entries())
|
|
1253
|
+
.sort(sortEntries)
|
|
1254
|
+
.map(([app, count]) => ({ app, count }));
|
|
1255
|
+
const domains = Array.from(domainCounts.entries())
|
|
1256
|
+
.sort(sortEntries)
|
|
1257
|
+
.map(([domain, count]) => ({ domain, count }));
|
|
1258
|
+
const tags = Array.from(tagCounts.entries())
|
|
1259
|
+
.sort(sortEntries)
|
|
1260
|
+
.map(([tag, count]) => ({ tag, count }));
|
|
1261
|
+
const locations = Array.from(locationCounts.entries())
|
|
1262
|
+
.sort(sortEntries)
|
|
1263
|
+
.map(([location, count]) => ({ location, count }));
|
|
1264
|
+
summaries.push({
|
|
1265
|
+
date: dateLabel,
|
|
1266
|
+
imageCount: imageIds.size,
|
|
1267
|
+
apps,
|
|
1268
|
+
domains,
|
|
1269
|
+
tags,
|
|
1270
|
+
locations,
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
return summaries;
|
|
1274
|
+
}
|
|
978
1275
|
function appendStatsRankSection(lines, title, rows, top) {
|
|
979
1276
|
lines.push(`### ${title}`);
|
|
980
1277
|
const filtered = rows.filter(row => row.count > 0).slice(0, top);
|
|
@@ -1008,6 +1305,34 @@ function renderStatsMarkdown(params) {
|
|
|
1008
1305
|
appendStatsRankSection(lines, 'Tags', params.tags.map(item => ({ label: `#${item.tag}`, count: item.count })), params.top);
|
|
1009
1306
|
return lines.join('\n').trimEnd();
|
|
1010
1307
|
}
|
|
1308
|
+
function renderSummaryText(params) {
|
|
1309
|
+
const appendRankSection = (lines, title, rows, limit) => {
|
|
1310
|
+
lines.push(`- ${title}:`);
|
|
1311
|
+
const items = rows.filter(row => row.count > 0).slice(0, limit);
|
|
1312
|
+
if (items.length === 0) {
|
|
1313
|
+
lines.push(' - (none)');
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
for (const row of items) {
|
|
1317
|
+
lines.push(` - ${row.label}${row.count > 1 ? ` (${row.count})` : ''}`);
|
|
1318
|
+
}
|
|
1319
|
+
};
|
|
1320
|
+
const lines = [];
|
|
1321
|
+
lines.push('## Gyazo Summary');
|
|
1322
|
+
lines.push('');
|
|
1323
|
+
lines.push(`- Window: ${params.dateKey}`);
|
|
1324
|
+
lines.push('');
|
|
1325
|
+
for (const day of params.dailySummaries) {
|
|
1326
|
+
lines.push(`### ${day.date}`);
|
|
1327
|
+
lines.push(`- Image count: ${day.imageCount}`);
|
|
1328
|
+
appendRankSection(lines, 'Apps', day.apps.map(item => ({ label: item.app, count: item.count })), params.limit);
|
|
1329
|
+
appendRankSection(lines, 'Domains', day.domains.map(item => ({ label: item.domain, count: item.count })), params.limit);
|
|
1330
|
+
appendRankSection(lines, 'Tags', day.tags.map(item => ({ label: `#${item.tag}`, count: item.count })), params.limit);
|
|
1331
|
+
appendRankSection(lines, 'Locations', day.locations.map(item => ({ label: item.location, count: item.count })), params.limit);
|
|
1332
|
+
lines.push('');
|
|
1333
|
+
}
|
|
1334
|
+
return lines.join('\n').trimEnd();
|
|
1335
|
+
}
|
|
1011
1336
|
async function readStdinBuffer() {
|
|
1012
1337
|
return new Promise((resolve, reject) => {
|
|
1013
1338
|
const chunks = [];
|
|
@@ -1018,6 +1343,42 @@ async function readStdinBuffer() {
|
|
|
1018
1343
|
process.stdin.on('error', reject);
|
|
1019
1344
|
});
|
|
1020
1345
|
}
|
|
1346
|
+
function printCollectionMarkdown(collection, images) {
|
|
1347
|
+
const lines = [];
|
|
1348
|
+
lines.push('## Gyazo Collection');
|
|
1349
|
+
lines.push('');
|
|
1350
|
+
const name = normalizeText(collection?.name);
|
|
1351
|
+
if (name)
|
|
1352
|
+
lines.push(`- Name: ${name}`);
|
|
1353
|
+
const collectionId = collection?.id;
|
|
1354
|
+
const url = collection?.url || (collectionId ? `https://gyazo.com/collections/${collectionId}` : undefined);
|
|
1355
|
+
if (url)
|
|
1356
|
+
lines.push(`- URL: <${url}>`);
|
|
1357
|
+
const description = normalizeText(collection?.description);
|
|
1358
|
+
if (description)
|
|
1359
|
+
lines.push(`- Description: ${description}`);
|
|
1360
|
+
const owner = normalizeText(collection?.user?.name);
|
|
1361
|
+
if (owner)
|
|
1362
|
+
lines.push(`- Owner: ${owner}`);
|
|
1363
|
+
const total = collection?.total_image_count;
|
|
1364
|
+
const shown = images.length;
|
|
1365
|
+
const truncated = typeof total === 'number' && total > shown;
|
|
1366
|
+
lines.push(`- Images: ${truncated ? `${shown} of ${total}` : shown}`);
|
|
1367
|
+
const updatedAt = normalizeText(collection?.list_updated_at);
|
|
1368
|
+
if (updatedAt)
|
|
1369
|
+
lines.push(`- Updated at: ${formatCreatedAt(updatedAt)}`);
|
|
1370
|
+
console.log(lines.join('\n'));
|
|
1371
|
+
if (truncated) {
|
|
1372
|
+
console.log('');
|
|
1373
|
+
console.log('Note: this endpoint returns only the first 100 images of a collection.');
|
|
1374
|
+
}
|
|
1375
|
+
if (shown > 0) {
|
|
1376
|
+
console.log('');
|
|
1377
|
+
console.log('### Images');
|
|
1378
|
+
console.log('');
|
|
1379
|
+
printListImages(images);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1021
1382
|
function printGetMarkdown(image, ocrDescription, objects = []) {
|
|
1022
1383
|
const lines = [];
|
|
1023
1384
|
lines.push('## Gyazo Image');
|
|
@@ -1057,7 +1418,7 @@ function printGetMarkdown(image, ocrDescription, objects = []) {
|
|
|
1057
1418
|
function summarizeImageForList(img) {
|
|
1058
1419
|
const domain = extractDomain(normalizeText(img.metadata?.url));
|
|
1059
1420
|
const cleanedTitle = sanitizeSummaryText(img.metadata?.title, domain);
|
|
1060
|
-
const cleanedDesc = sanitizeSummaryText(img.metadata?.desc, domain);
|
|
1421
|
+
const cleanedDesc = sanitizeSummaryText(img.metadata?.desc ?? img.desc, domain);
|
|
1061
1422
|
const locationLabel = sanitizeSummaryText(extractImageLocationLabel(img));
|
|
1062
1423
|
const cleanedAltText = sanitizeSummaryText(img.alt_text);
|
|
1063
1424
|
let main = '(no title/description)';
|
|
@@ -1197,6 +1558,9 @@ program
|
|
|
1197
1558
|
.option('-l, --limit <number>', 'items per page', '20')
|
|
1198
1559
|
.option('-j, --json', 'output as JSON')
|
|
1199
1560
|
.option('-H, --hour <yyyy-mm-dd-hh>', 'target hour')
|
|
1561
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
1562
|
+
.option('--today', 'target today only')
|
|
1563
|
+
.option('--max-pages <number>', 'max pages to scan for --date/--today mode', '100')
|
|
1200
1564
|
.option('--photos', 'alias of search "has:location"')
|
|
1201
1565
|
.option('--uploaded', 'alias of search "gyazocli_uploads"')
|
|
1202
1566
|
.option('--no-cache', 'force fetch from API')
|
|
@@ -1204,21 +1568,69 @@ program
|
|
|
1204
1568
|
await (0, credentials_1.ensureAccessToken)();
|
|
1205
1569
|
try {
|
|
1206
1570
|
const useCache = options.cache !== false;
|
|
1571
|
+
const page = parsePositiveIntegerOption(options.page, '--page');
|
|
1572
|
+
const limit = parsePositiveIntegerOption(options.limit, '--limit');
|
|
1573
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
1574
|
+
const hasDateRange = Boolean(options.date || options.today);
|
|
1575
|
+
const targetDate = hasDateRange
|
|
1576
|
+
? (options.today ? parseDateOption() : parseDateOption(options.date))
|
|
1577
|
+
: undefined;
|
|
1207
1578
|
if (options.photos && options.uploaded) {
|
|
1208
1579
|
console.error('Error: --photos and --uploaded cannot be used together.');
|
|
1209
1580
|
process.exit(1);
|
|
1210
1581
|
}
|
|
1582
|
+
if (options.today && options.date) {
|
|
1583
|
+
console.error('Error: --today and --date cannot be used together.');
|
|
1584
|
+
process.exit(1);
|
|
1585
|
+
}
|
|
1211
1586
|
if ((options.photos || options.uploaded) && options.hour) {
|
|
1212
1587
|
console.error('Error: --photos/--uploaded and --hour cannot be used together.');
|
|
1213
1588
|
process.exit(1);
|
|
1214
1589
|
}
|
|
1590
|
+
if (options.hour && hasDateRange) {
|
|
1591
|
+
console.error('Error: --hour and --date/--today cannot be used together.');
|
|
1592
|
+
process.exit(1);
|
|
1593
|
+
}
|
|
1215
1594
|
const aliasQuery = options.photos
|
|
1216
1595
|
? 'has:location'
|
|
1217
1596
|
: options.uploaded
|
|
1218
1597
|
? 'gyazocli_uploads'
|
|
1219
1598
|
: undefined;
|
|
1220
1599
|
if (aliasQuery) {
|
|
1221
|
-
|
|
1600
|
+
let images = [];
|
|
1601
|
+
if (targetDate) {
|
|
1602
|
+
const collected = [];
|
|
1603
|
+
for (let searchPage = 1; searchPage <= maxPages; searchPage++) {
|
|
1604
|
+
const pageImages = await (0, api_1.searchImages)(aliasQuery, searchPage, 100);
|
|
1605
|
+
if (pageImages.length === 0)
|
|
1606
|
+
break;
|
|
1607
|
+
let reachedLimit = false;
|
|
1608
|
+
for (const img of pageImages) {
|
|
1609
|
+
const createdAt = new Date(img.created_at);
|
|
1610
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
1611
|
+
continue;
|
|
1612
|
+
if (createdAt > targetDate.end)
|
|
1613
|
+
continue;
|
|
1614
|
+
if (createdAt < targetDate.start) {
|
|
1615
|
+
reachedLimit = true;
|
|
1616
|
+
break;
|
|
1617
|
+
}
|
|
1618
|
+
collected.push(img);
|
|
1619
|
+
}
|
|
1620
|
+
if (reachedLimit)
|
|
1621
|
+
break;
|
|
1622
|
+
}
|
|
1623
|
+
collected.sort((a, b) => {
|
|
1624
|
+
const ta = new Date(a.created_at).getTime();
|
|
1625
|
+
const tb = new Date(b.created_at).getTime();
|
|
1626
|
+
return tb - ta;
|
|
1627
|
+
});
|
|
1628
|
+
const startIndex = (page - 1) * limit;
|
|
1629
|
+
images = collected.slice(startIndex, startIndex + limit);
|
|
1630
|
+
}
|
|
1631
|
+
else {
|
|
1632
|
+
images = await (0, api_1.searchImages)(aliasQuery, page, limit);
|
|
1633
|
+
}
|
|
1222
1634
|
if (options.json) {
|
|
1223
1635
|
console.log(JSON.stringify(images, null, 2));
|
|
1224
1636
|
}
|
|
@@ -1232,6 +1644,50 @@ program
|
|
|
1232
1644
|
}
|
|
1233
1645
|
return;
|
|
1234
1646
|
}
|
|
1647
|
+
if (targetDate) {
|
|
1648
|
+
let imageIds = [];
|
|
1649
|
+
if (useCache) {
|
|
1650
|
+
imageIds = loadImageIdsFromDateRangeCache(targetDate);
|
|
1651
|
+
if (imageIds.length === 0) {
|
|
1652
|
+
await warmDateCacheForList(targetDate, maxPages, true);
|
|
1653
|
+
imageIds = loadImageIdsFromDateRangeCache(targetDate);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
else {
|
|
1657
|
+
imageIds = await warmDateCacheForList(targetDate, maxPages, false);
|
|
1658
|
+
}
|
|
1659
|
+
if (imageIds.length === 0) {
|
|
1660
|
+
console.log(`No images found for ${targetDate.dateKey}.`);
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
let images = imageIds
|
|
1664
|
+
.map(id => (0, storage_1.loadImageCache)(id))
|
|
1665
|
+
.filter((img) => img !== null);
|
|
1666
|
+
images = images.filter((img) => {
|
|
1667
|
+
const createdAt = new Date(img.created_at);
|
|
1668
|
+
if (Number.isNaN(createdAt.getTime()))
|
|
1669
|
+
return false;
|
|
1670
|
+
return createdAt >= targetDate.start && createdAt <= targetDate.end;
|
|
1671
|
+
});
|
|
1672
|
+
images.sort((a, b) => {
|
|
1673
|
+
const ta = new Date(a.created_at).getTime();
|
|
1674
|
+
const tb = new Date(b.created_at).getTime();
|
|
1675
|
+
return tb - ta;
|
|
1676
|
+
});
|
|
1677
|
+
const startIndex = (page - 1) * limit;
|
|
1678
|
+
const pageImages = images.slice(startIndex, startIndex + limit);
|
|
1679
|
+
if (options.json) {
|
|
1680
|
+
console.log(JSON.stringify(pageImages, null, 2));
|
|
1681
|
+
}
|
|
1682
|
+
else {
|
|
1683
|
+
const imagesForDisplay = await prepareImagesForDisplay(pageImages, {
|
|
1684
|
+
enrichLocation: true,
|
|
1685
|
+
useCache,
|
|
1686
|
+
});
|
|
1687
|
+
printListImages(imagesForDisplay);
|
|
1688
|
+
}
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1235
1691
|
if (options.hour) {
|
|
1236
1692
|
const parts = options.hour.split('-');
|
|
1237
1693
|
if (parts.length !== 4) {
|
|
@@ -1272,7 +1728,7 @@ program
|
|
|
1272
1728
|
}
|
|
1273
1729
|
return;
|
|
1274
1730
|
}
|
|
1275
|
-
const images = await (0, api_1.listImages)(
|
|
1731
|
+
const images = await (0, api_1.listImages)(page, limit);
|
|
1276
1732
|
if (options.json) {
|
|
1277
1733
|
console.log(JSON.stringify(images, null, 2));
|
|
1278
1734
|
}
|
|
@@ -1286,6 +1742,7 @@ program
|
|
|
1286
1742
|
}
|
|
1287
1743
|
catch (error) {
|
|
1288
1744
|
console.error('Error listing images:', error.message);
|
|
1745
|
+
process.exit(1);
|
|
1289
1746
|
}
|
|
1290
1747
|
});
|
|
1291
1748
|
program
|
|
@@ -1306,6 +1763,7 @@ program
|
|
|
1306
1763
|
console.error('Error: --ocr and --objects cannot be used together.');
|
|
1307
1764
|
process.exit(1);
|
|
1308
1765
|
}
|
|
1766
|
+
imageId = requireImageId(imageId);
|
|
1309
1767
|
let image = options.cache !== false ? (0, storage_1.loadImageCache)(imageId) : null;
|
|
1310
1768
|
if (!image) {
|
|
1311
1769
|
image = await (0, api_1.getImageDetail)(imageId);
|
|
@@ -1343,6 +1801,43 @@ program
|
|
|
1343
1801
|
}
|
|
1344
1802
|
catch (error) {
|
|
1345
1803
|
console.error('Error getting image:', error.message);
|
|
1804
|
+
process.exit(1);
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
program
|
|
1808
|
+
.command('collection <collection_id>')
|
|
1809
|
+
.aliases(['col', 'cols', 'collections'])
|
|
1810
|
+
.description('Show a collection and the images in it')
|
|
1811
|
+
.option('-j, --json', 'output as JSON')
|
|
1812
|
+
.option('-A, --anonymous', 'read without an access token, even when one is configured')
|
|
1813
|
+
.option('--sort <added|created|captured>', 'image order (default: added)')
|
|
1814
|
+
.action(async (collectionIdInput, options) => {
|
|
1815
|
+
const collectionId = requireCollectionId(collectionIdInput);
|
|
1816
|
+
const sort = parseCollectionSort(options.sort);
|
|
1817
|
+
// No token is not an error here: public collections read fine anonymously.
|
|
1818
|
+
if (!options.anonymous) {
|
|
1819
|
+
(0, credentials_1.resolveAccessToken)();
|
|
1820
|
+
}
|
|
1821
|
+
try {
|
|
1822
|
+
const collection = await (0, api_1.getCollection)(collectionId, { anonymous: Boolean(options.anonymous) });
|
|
1823
|
+
if (options.json) {
|
|
1824
|
+
console.log(JSON.stringify(collection, null, 2));
|
|
1825
|
+
return;
|
|
1826
|
+
}
|
|
1827
|
+
const images = sortCollectionImages(Array.isArray(collection?.images) ? collection.images : [], sort);
|
|
1828
|
+
printCollectionMarkdown(collection, images);
|
|
1829
|
+
}
|
|
1830
|
+
catch (error) {
|
|
1831
|
+
if (error?.response?.status === 404) {
|
|
1832
|
+
console.error(`Error: collection ${collectionId} was not found or not public.`);
|
|
1833
|
+
console.error('Hint: a private collection returns the same 404 as one that does not exist.');
|
|
1834
|
+
if (options.anonymous) {
|
|
1835
|
+
console.error('Hint: you are running with --anonymous. Drop it to use your access token.');
|
|
1836
|
+
}
|
|
1837
|
+
process.exit(1);
|
|
1838
|
+
}
|
|
1839
|
+
console.error('Error getting collection:', error.message);
|
|
1840
|
+
process.exit(1);
|
|
1346
1841
|
}
|
|
1347
1842
|
});
|
|
1348
1843
|
program
|
|
@@ -1375,6 +1870,7 @@ program
|
|
|
1375
1870
|
}
|
|
1376
1871
|
catch (error) {
|
|
1377
1872
|
console.error('Error searching images:', error.message);
|
|
1873
|
+
process.exit(1);
|
|
1378
1874
|
}
|
|
1379
1875
|
});
|
|
1380
1876
|
program
|
|
@@ -1623,6 +2119,71 @@ program
|
|
|
1623
2119
|
process.exit(1);
|
|
1624
2120
|
}
|
|
1625
2121
|
});
|
|
2122
|
+
program
|
|
2123
|
+
.command('summary')
|
|
2124
|
+
.description('Show weekly summary with daily uploads and metadata rankings')
|
|
2125
|
+
.option('--date <yyyy|yyyy-mm|yyyy-mm-dd>', 'target date/range')
|
|
2126
|
+
.option('--today', 'target today only (overrides default weekly range)')
|
|
2127
|
+
.option('-l, --limit <number>', 'maximum ranking rows (max: 10)', '10')
|
|
2128
|
+
.option('--max-pages <number>', 'max pages to scan before stopping', '10')
|
|
2129
|
+
.option('-j, --json', 'output as JSON')
|
|
2130
|
+
.option('--no-cache', 'force fetch from API')
|
|
2131
|
+
.action(async (options) => {
|
|
2132
|
+
await (0, credentials_1.ensureAccessToken)();
|
|
2133
|
+
try {
|
|
2134
|
+
const targetDate = resolveRankingRangeOption(options);
|
|
2135
|
+
const requestedLimit = parsePositiveIntegerOption(options.limit, '--limit');
|
|
2136
|
+
const limit = Math.min(requestedLimit, 10);
|
|
2137
|
+
const maxPages = parsePositiveIntegerOption(options.maxPages, '--max-pages');
|
|
2138
|
+
const useCache = options.cache !== false;
|
|
2139
|
+
let dailySummaries = [];
|
|
2140
|
+
if (useCache) {
|
|
2141
|
+
dailySummaries = buildDailySummariesFromImageCache(targetDate);
|
|
2142
|
+
const totalUploads = dailySummaries.reduce((sum, day) => sum + day.imageCount, 0);
|
|
2143
|
+
if (totalUploads === 0) {
|
|
2144
|
+
await warmDateCacheForTags(targetDate, maxPages, true);
|
|
2145
|
+
await warmDateCacheForLocations(targetDate, maxPages, true);
|
|
2146
|
+
dailySummaries = buildDailySummariesFromImageCache(targetDate);
|
|
2147
|
+
}
|
|
2148
|
+
else {
|
|
2149
|
+
const hasMetadata = dailySummaries.some(day => day.apps.length > 0 || day.domains.length > 0 || day.tags.length > 0 || day.locations.length > 0);
|
|
2150
|
+
if (!hasMetadata) {
|
|
2151
|
+
await warmDateCacheForTags(targetDate, maxPages, true);
|
|
2152
|
+
await warmDateCacheForLocations(targetDate, maxPages, true);
|
|
2153
|
+
dailySummaries = buildDailySummariesFromImageCache(targetDate);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
else {
|
|
2158
|
+
await warmDateCacheForTags(targetDate, maxPages, false);
|
|
2159
|
+
await warmDateCacheForLocations(targetDate, maxPages, false);
|
|
2160
|
+
dailySummaries = buildDailySummariesFromImageCache(targetDate);
|
|
2161
|
+
}
|
|
2162
|
+
if (options.json) {
|
|
2163
|
+
console.log(JSON.stringify({
|
|
2164
|
+
date: targetDate.dateKey,
|
|
2165
|
+
days: dailySummaries.map(day => ({
|
|
2166
|
+
date: day.date,
|
|
2167
|
+
image_count: day.imageCount,
|
|
2168
|
+
apps: day.apps.slice(0, limit),
|
|
2169
|
+
domains: day.domains.slice(0, limit),
|
|
2170
|
+
tags: day.tags.slice(0, limit),
|
|
2171
|
+
locations: day.locations.slice(0, limit),
|
|
2172
|
+
})),
|
|
2173
|
+
}, null, 2));
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
console.log(renderSummaryText({
|
|
2177
|
+
dateKey: targetDate.dateKey,
|
|
2178
|
+
dailySummaries,
|
|
2179
|
+
limit,
|
|
2180
|
+
}));
|
|
2181
|
+
}
|
|
2182
|
+
catch (error) {
|
|
2183
|
+
console.error('Error building summary:', error.message);
|
|
2184
|
+
process.exit(1);
|
|
2185
|
+
}
|
|
2186
|
+
});
|
|
1626
2187
|
program
|
|
1627
2188
|
.command('stats')
|
|
1628
2189
|
.description('Show weekly stats summary in Markdown')
|
|
@@ -1697,6 +2258,7 @@ program
|
|
|
1697
2258
|
program
|
|
1698
2259
|
.command('upload [path]')
|
|
1699
2260
|
.description('Upload an image file (or read image bytes from stdin)')
|
|
2261
|
+
.option('-j, --json', 'output the upload response as JSON')
|
|
1700
2262
|
.option('--title <title>', 'image title')
|
|
1701
2263
|
.option('--app <app>', 'application name', 'gyazocli')
|
|
1702
2264
|
.option('--url <url>', 'source URL (sent as referer_url)')
|
|
@@ -1739,13 +2301,12 @@ program
|
|
|
1739
2301
|
desc,
|
|
1740
2302
|
timestamp,
|
|
1741
2303
|
});
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
2304
|
+
if (options.json) {
|
|
2305
|
+
console.log(JSON.stringify(uploaded, null, 2));
|
|
2306
|
+
}
|
|
2307
|
+
else {
|
|
2308
|
+
console.log(uploaded.permalink_url);
|
|
1746
2309
|
}
|
|
1747
|
-
console.log(`App: ${options.app || 'gyazocli'}`);
|
|
1748
|
-
console.log(`Desc: ${desc}`);
|
|
1749
2310
|
}
|
|
1750
2311
|
catch (error) {
|
|
1751
2312
|
console.error('Error uploading image:', error.message);
|
|
@@ -1900,4 +2461,42 @@ program
|
|
|
1900
2461
|
process.exit(1);
|
|
1901
2462
|
}
|
|
1902
2463
|
});
|
|
1903
|
-
|
|
2464
|
+
/**
|
|
2465
|
+
* Let the first argument stand on its own when it is unambiguous:
|
|
2466
|
+
* a Gyazo image ID or URL means `get`, an existing file means `upload`.
|
|
2467
|
+
* Anything else is left to commander so unknown commands still report as such.
|
|
2468
|
+
*/
|
|
2469
|
+
function expandImplicitCommand(argv) {
|
|
2470
|
+
const args = argv.slice(2);
|
|
2471
|
+
const first = args[0];
|
|
2472
|
+
if (!first || first.startsWith('-')) {
|
|
2473
|
+
return argv;
|
|
2474
|
+
}
|
|
2475
|
+
const knownNames = new Set(['help']);
|
|
2476
|
+
for (const command of program.commands) {
|
|
2477
|
+
knownNames.add(command.name());
|
|
2478
|
+
for (const alias of command.aliases()) {
|
|
2479
|
+
knownNames.add(alias);
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
if (knownNames.has(first)) {
|
|
2483
|
+
return argv;
|
|
2484
|
+
}
|
|
2485
|
+
let implicitCommand = null;
|
|
2486
|
+
if (normalizeImageId(first)) {
|
|
2487
|
+
// A bare 32-hex ID is ambiguous; treat it as an image.
|
|
2488
|
+
implicitCommand = 'get';
|
|
2489
|
+
}
|
|
2490
|
+
else if (normalizeCollectionId(first)) {
|
|
2491
|
+
// Only the /collections/<id> URL form is unambiguous.
|
|
2492
|
+
implicitCommand = 'collection';
|
|
2493
|
+
}
|
|
2494
|
+
else if (fs_1.default.existsSync(first) && fs_1.default.statSync(first).isFile()) {
|
|
2495
|
+
implicitCommand = 'upload';
|
|
2496
|
+
}
|
|
2497
|
+
if (!implicitCommand) {
|
|
2498
|
+
return argv;
|
|
2499
|
+
}
|
|
2500
|
+
return [...argv.slice(0, 2), implicitCommand, ...args];
|
|
2501
|
+
}
|
|
2502
|
+
program.parseAsync(expandImplicitCommand(process.argv));
|
|
@@ -47,6 +47,9 @@ Store cache files under XDG-style user cache location by default, with an enviro
|
|
|
47
47
|
- `gyazo get <image_id>`:
|
|
48
48
|
- Uses cached detail by default.
|
|
49
49
|
- `--no-cache` forces API fetch and rewrites cache.
|
|
50
|
+
- `gyazo list --date <...>`:
|
|
51
|
+
- Reads image IDs from hourly index cache files in the target day/month/year range.
|
|
52
|
+
- If the range has no hourly cache entries, it warms cache from API list pages and then reads from cache.
|
|
50
53
|
- `gyazo sync`:
|
|
51
54
|
- Fetches list pages (`per_page=100`) for a bounded date range.
|
|
52
55
|
- Skips detail fetch when cached record already has `ocr`.
|
|
@@ -69,6 +72,11 @@ Store cache files under XDG-style user cache location by default, with an enviro
|
|
|
69
72
|
- Aggregates upload time bands and rankings of apps/domains/tags from hourly caches.
|
|
70
73
|
- Uses the same hourly metadata extract cache mechanism as ranking commands.
|
|
71
74
|
- If cache data for the target window is absent, warming is triggered once, then summary is built from cache.
|
|
75
|
+
- `gyazo summary`:
|
|
76
|
+
- Default window is from 8 days ago to yesterday (`7` days).
|
|
77
|
+
- Outputs day-by-day Markdown sections with `image count` plus rankings of apps/domains/tags/locations.
|
|
78
|
+
- Uses hourly index cache to discover images per day, then builds rankings from image cache records.
|
|
79
|
+
- If image-cache metadata is insufficient, warming via list/detail fetch is triggered through ranking warmers.
|
|
72
80
|
|
|
73
81
|
## Consequences
|
|
74
82
|
- Cache is portable and independent from the repository working tree.
|
|
@@ -11,7 +11,7 @@ Adopt and document the existing top-level command structure.
|
|
|
11
11
|
|
|
12
12
|
### 1. Program Metadata
|
|
13
13
|
- Binary name: `gyazo`
|
|
14
|
-
- Version: `1.
|
|
14
|
+
- Version: `0.1.1`
|
|
15
15
|
- Description: `Gyazo Memory CLI for AI Secretary`
|
|
16
16
|
|
|
17
17
|
### 2. Commands
|
|
@@ -28,8 +28,11 @@ Adopt and document the existing top-level command structure.
|
|
|
28
28
|
- `-l, --limit <number>` (default: `20`)
|
|
29
29
|
- `-j, --json`
|
|
30
30
|
- `-H, --hour <yyyy-mm-dd-hh>` (reads hourly cache only)
|
|
31
|
-
- `--
|
|
32
|
-
- `--
|
|
31
|
+
- `--date <yyyy|yyyy-mm|yyyy-mm-dd>` (reads date range from hourly cache; warms from API if needed)
|
|
32
|
+
- `--today` (target today only)
|
|
33
|
+
- `--max-pages <number>` (default: `100`, used for `--date`/`--today` warming)
|
|
34
|
+
- `--photos` (alias of `search has:location`, can be combined with `--date`/`--today`)
|
|
35
|
+
- `--uploaded` (alias of `search gyazocli_uploads`, can be combined with `--date`/`--today`)
|
|
33
36
|
- `--no-cache`
|
|
34
37
|
- `gyazo get <image_id>`
|
|
35
38
|
- Options:
|
|
@@ -77,6 +80,18 @@ Adopt and document the existing top-level command structure.
|
|
|
77
80
|
- `--max-pages <number>` (default: `10`)
|
|
78
81
|
- `-j, --json`
|
|
79
82
|
- `--no-cache`
|
|
83
|
+
- `gyazo summary`
|
|
84
|
+
- Default range: from 8 days ago to yesterday
|
|
85
|
+
- Shows day-by-day Markdown sections (`### YYYY-MM-DD`) with:
|
|
86
|
+
- `image count`
|
|
87
|
+
- rankings of apps/domains/tags/locations
|
|
88
|
+
- Options:
|
|
89
|
+
- `--date <yyyy|yyyy-mm|yyyy-mm-dd>`
|
|
90
|
+
- `--today` (target today only)
|
|
91
|
+
- `-l, --limit <number>` (default: `10`, max: `10`)
|
|
92
|
+
- `--max-pages <number>` (default: `10`)
|
|
93
|
+
- `-j, --json`
|
|
94
|
+
- `--no-cache`
|
|
80
95
|
- `gyazo stats`
|
|
81
96
|
- Default range: from 8 days ago to yesterday
|
|
82
97
|
- Default behavior: weekly Markdown summary.
|
|
@@ -103,7 +118,8 @@ Adopt and document the existing top-level command structure.
|
|
|
103
118
|
- Supported types: `json`, `hourly`
|
|
104
119
|
|
|
105
120
|
### 3. Output and Behavior Notes
|
|
106
|
-
- `-j, --json` is available on `config get`, `list`, `get`, `search`, `apps`, `domains`, `tags`, and `
|
|
121
|
+
- `-j, --json` is available on `config get`, `list`, `get`, `search`, `apps`, `domains`, `tags`, `locations`, and `summary`.
|
|
122
|
+
- `summary` default output is Markdown with headings (`## Gyazo Summary`, `### YYYY-MM-DD`) and nested bullet lists.
|
|
107
123
|
- There are no global `--plain` or `--verbose` flags in current implementation.
|
|
108
124
|
- Authenticated commands call token resolution before API access.
|
|
109
125
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yuiseki/gyazocli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Gyazo Memory CLI for AI Secretary",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/yuiseki/gyazocli.git"
|
|
8
|
+
},
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/yuiseki/gyazocli/issues"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/yuiseki/gyazocli#readme",
|
|
5
13
|
"main": "dist/index.js",
|
|
6
14
|
"bin": {
|
|
7
15
|
"gyazo": "dist/index.js"
|
|
@@ -31,6 +39,7 @@
|
|
|
31
39
|
"axios": "^1.13.5",
|
|
32
40
|
"commander": "^14.0.3",
|
|
33
41
|
"dotenv": "^17.3.1",
|
|
42
|
+
"form-data": "^4.0.0",
|
|
34
43
|
"zod": "^4.3.6"
|
|
35
44
|
},
|
|
36
45
|
"devDependencies": {
|