@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 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 API_BASE_URL = 'https://api.gyazo.com/api/images';
15
- const API_SEARCH_URL = 'https://api.gyazo.com/api/search';
16
- const API_USERS_ME_URL = 'https://api.gyazo.com/api/users/me';
17
- const API_UPLOAD_URL = 'https://upload.gyazo.com/api/upload';
18
- async function requestWithRetry(url, params = {}) {
19
- const headers = { Authorization: `Bearer ${config_1.config.GYAZO_ACCESS_TOKEN}` };
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(API_BASE_URL, { page, per_page: perPage });
52
+ return requestWithRetry(apiBaseUrl(), { page, per_page: perPage });
36
53
  }
37
54
  async function getImageDetail(imageId) {
38
- return requestWithRetry(`${API_BASE_URL}/${imageId}`);
55
+ return requestWithRetry(`${apiBaseUrl()}/${imageId}`);
39
56
  }
40
57
  async function searchImages(query, page = 1, perPage = 20) {
41
- return requestWithRetry(API_SEARCH_URL, { query, page, per: perPage });
58
+ return requestWithRetry(apiSearchUrl(), { query, page, per: perPage });
42
59
  }
43
60
  async function getCurrentUser() {
44
- return requestWithRetry(API_USERS_ME_URL);
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(API_UPLOAD_URL, form, {
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;
@@ -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
- async function ensureAccessToken() {
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.0.1');
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
- const images = await (0, api_1.searchImages)(aliasQuery, parseInt(options.page, 10), parseInt(options.limit, 10));
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)(parseInt(options.page, 10), parseInt(options.limit, 10));
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
- console.log(`URL: ${uploaded.permalink_url}`);
1743
- console.log(`ID: ${uploaded.image_id}`);
1744
- if (uploaded.created_at) {
1745
- console.log(`Created at: ${formatCreatedAt(uploaded.created_at)}`);
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
- program.parseAsync(process.argv);
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.0.0`
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
- - `--photos` (alias of `search has:location`)
32
- - `--uploaded` (alias of `search gyazocli_uploads`)
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 `locations`.
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.0.1",
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": {