artsonia-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +95 -26
- package/dist/tools/download.js +121 -31
- package/dist/version.js +1 -1
- package/manifest.json +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "MCP server for Artsonia — natural-language access to student-art portfolios, comments, and fans",
|
|
10
|
-
"version": "0.
|
|
10
|
+
"version": "0.4.0"
|
|
11
11
|
},
|
|
12
12
|
"plugins": [
|
|
13
13
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"displayName": "Artsonia",
|
|
16
16
|
"source": "./",
|
|
17
17
|
"description": "MCP server for Artsonia — access student portfolios, post comments, and manage fans via natural language",
|
|
18
|
-
"version": "0.
|
|
18
|
+
"version": "0.4.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Chris Hall"
|
|
21
21
|
},
|
package/dist/bundle.js
CHANGED
|
@@ -32247,7 +32247,7 @@ var VERSION;
|
|
|
32247
32247
|
var init_version = __esm({
|
|
32248
32248
|
"src/version.ts"() {
|
|
32249
32249
|
"use strict";
|
|
32250
|
-
VERSION = "0.
|
|
32250
|
+
VERSION = "0.4.0";
|
|
32251
32251
|
}
|
|
32252
32252
|
});
|
|
32253
32253
|
|
|
@@ -38863,18 +38863,58 @@ function registerAccountTools(server, client2) {
|
|
|
38863
38863
|
// src/tools/download.ts
|
|
38864
38864
|
init_zod();
|
|
38865
38865
|
init_dist();
|
|
38866
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
38866
|
+
import { mkdir, writeFile, utimes } from "node:fs/promises";
|
|
38867
|
+
import { existsSync } from "node:fs";
|
|
38867
38868
|
import { join as join3 } from "node:path";
|
|
38868
38869
|
var NumericId3 = external_exports.string().regex(/^\d+$/, "must be a numeric id");
|
|
38870
|
+
var DEFAULT_TEMPLATE = "{grade} - {project} - {title}";
|
|
38871
|
+
var FETCH_CONCURRENCY = 6;
|
|
38872
|
+
var MAX_NAME_LEN = 150;
|
|
38869
38873
|
function normalizeGrade(g) {
|
|
38870
38874
|
return (g ?? "").replace(/grade/i, "").trim().toLowerCase();
|
|
38871
38875
|
}
|
|
38876
|
+
function buildFilename(template, fields, artworkId) {
|
|
38877
|
+
const tokens = {
|
|
38878
|
+
title: fields.title?.trim() ?? "",
|
|
38879
|
+
project: fields.project?.trim() ?? "",
|
|
38880
|
+
grade: fields.grade ? `Grade ${fields.grade}` : "",
|
|
38881
|
+
date: fields.date ?? "",
|
|
38882
|
+
artwork_id: artworkId
|
|
38883
|
+
};
|
|
38884
|
+
let name = template.replace(/\{(title|project|grade|date|artwork_id)\}/g, (_, k) => tokens[k] ?? "");
|
|
38885
|
+
let prev;
|
|
38886
|
+
do {
|
|
38887
|
+
prev = name;
|
|
38888
|
+
name = name.replace(/\s*-\s*-\s*/g, " - ");
|
|
38889
|
+
} while (name !== prev);
|
|
38890
|
+
name = name.replace(/^[\s\-_]+|[\s\-_]+$/g, "");
|
|
38891
|
+
name = name.replace(/[\/\\:*?"<>|\x00-\x1f]/g, "").replace(/^\.+/, "").replace(/\s+/g, " ").trim();
|
|
38892
|
+
if (name.length > MAX_NAME_LEN) name = name.slice(0, MAX_NAME_LEN).trim();
|
|
38893
|
+
if (!template.includes("{artwork_id}") && !name.includes(`(${artworkId})`)) {
|
|
38894
|
+
name = name ? `${name} (${artworkId})` : artworkId;
|
|
38895
|
+
}
|
|
38896
|
+
if (!name) name = artworkId;
|
|
38897
|
+
return `${name}.jpg`;
|
|
38898
|
+
}
|
|
38899
|
+
async function mapLimit(items, limit, fn) {
|
|
38900
|
+
const results = new Array(items.length);
|
|
38901
|
+
let next = 0;
|
|
38902
|
+
await Promise.all(
|
|
38903
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
38904
|
+
while (next < items.length) {
|
|
38905
|
+
const i = next++;
|
|
38906
|
+
results[i] = await fn(items[i], i);
|
|
38907
|
+
}
|
|
38908
|
+
})
|
|
38909
|
+
);
|
|
38910
|
+
return results;
|
|
38911
|
+
}
|
|
38872
38912
|
function registerDownloadTools(server, client2) {
|
|
38873
38913
|
server.registerTool(
|
|
38874
38914
|
"artsonia_download_artwork",
|
|
38875
38915
|
{
|
|
38876
38916
|
title: "Download a student's artwork images",
|
|
38877
|
-
description:
|
|
38917
|
+
description: `Download full-resolution images of a student's artwork to a local folder, named from the artwork title/project/grade and time-stamped to the image's source date. Optionally filter by class/project (substring), grade, and/or keep only the most-recent N (the portfolio is reliably newest-first). Re-runs are idempotent (skip_existing). Without confirm:true this is a DRY RUN that lists the resolved filenames and writes nothing. Note: descriptive filenames need each artwork's detail page (slower) \u2014 use filename_template "{artwork_id}" for the fast id-only path.`,
|
|
38878
38918
|
annotations: toolAnnotations({ title: "Download a student's artwork images", readOnly: false, openWorld: true }),
|
|
38879
38919
|
inputSchema: {
|
|
38880
38920
|
artist_id: NumericId3.describe("Student artist_id (from artsonia_list_students)."),
|
|
@@ -38883,22 +38923,28 @@ function registerDownloadTools(server, client2) {
|
|
|
38883
38923
|
grade: external_exports.string().min(1).optional().describe('Only artworks created in this grade, e.g. "6" or "Grade 6".'),
|
|
38884
38924
|
limit: external_exports.number().int().positive().optional().describe("Keep only the N most recent matching artworks (portfolio is newest-first)."),
|
|
38885
38925
|
resolution: external_exports.enum(["full", "xlarge", "large", "medium", "small"]).default("full").describe('Image resolution. "full" is the original (~0.7 MB each).'),
|
|
38926
|
+
filename_template: external_exports.string().min(1).default(DEFAULT_TEMPLATE).describe('Filename pattern. Tokens: {title} {project} {grade} {date} {artwork_id}. The artwork_id is auto-appended for uniqueness if absent. Use "{artwork_id}" for the fast id-only path (no detail fetch).'),
|
|
38927
|
+
set_mtime_from_source: external_exports.boolean().default(true).describe("Set each file's modified time from the image's Last-Modified header (its Artsonia upload date) instead of the download moment."),
|
|
38928
|
+
skip_existing: external_exports.boolean().default(true).describe("Skip artworks whose target file already exists (idempotent re-runs). Set false to overwrite."),
|
|
38886
38929
|
confirm: schemaConfirm
|
|
38887
38930
|
}
|
|
38888
38931
|
},
|
|
38889
|
-
async ({ artist_id, dest, project, grade, limit, resolution, confirm }) => {
|
|
38932
|
+
async ({ artist_id, dest, project, grade, limit, resolution, filename_template, set_mtime_from_source, skip_existing, confirm }) => {
|
|
38933
|
+
const template = filename_template;
|
|
38934
|
+
const templateUsesDate = /\{date\}/.test(template);
|
|
38935
|
+
const needDetail = project !== void 0 || grade !== void 0 || /\{(title|project|grade)\}/.test(template);
|
|
38890
38936
|
const tiles = parsePortfolio(await client2.fetchHtml(`/artists/portfolio.asp?id=${artist_id}`));
|
|
38891
|
-
let items = tiles.map((t) => ({ artwork_id: t.artwork_id }));
|
|
38892
|
-
if (
|
|
38937
|
+
let items = tiles.map((t) => ({ artwork_id: t.artwork_id, is_private: t.is_private }));
|
|
38938
|
+
if (needDetail) {
|
|
38893
38939
|
const wantGrade = normalizeGrade(grade);
|
|
38894
|
-
const
|
|
38895
|
-
|
|
38940
|
+
const tilesToDetail = limit !== void 0 && project === void 0 && grade === void 0 ? tiles.slice(0, limit) : tiles;
|
|
38941
|
+
const detailed = await mapLimit(tilesToDetail, FETCH_CONCURRENCY, async (t) => {
|
|
38896
38942
|
const d = parseArtwork(await client2.fetchHtml(`/museum/art.asp?id=${t.artwork_id}`));
|
|
38897
|
-
|
|
38898
|
-
|
|
38899
|
-
|
|
38900
|
-
|
|
38901
|
-
|
|
38943
|
+
return { artwork_id: t.artwork_id, is_private: t.is_private, title: d.title, project: d.project, grade: d.grade };
|
|
38944
|
+
});
|
|
38945
|
+
items = detailed.filter(
|
|
38946
|
+
(it) => (project === void 0 || (it.project ?? "").toLowerCase().includes(project.toLowerCase())) && (grade === void 0 || normalizeGrade(it.grade) === wantGrade)
|
|
38947
|
+
);
|
|
38902
38948
|
}
|
|
38903
38949
|
if (limit !== void 0) items = items.slice(0, limit);
|
|
38904
38950
|
const destDir = expandPath(dest);
|
|
@@ -38910,34 +38956,57 @@ function registerDownloadTools(server, client2) {
|
|
|
38910
38956
|
count: items.length,
|
|
38911
38957
|
dest: destDir,
|
|
38912
38958
|
resolution,
|
|
38913
|
-
|
|
38959
|
+
filename_template: template,
|
|
38960
|
+
artworks: items.slice(0, 200).map((it) => ({
|
|
38961
|
+
artwork_id: it.artwork_id,
|
|
38962
|
+
...it.title !== void 0 ? { title: it.title } : {},
|
|
38963
|
+
filename: templateUsesDate ? "(finalized with {date} at download)" : buildFilename(template, it, it.artwork_id)
|
|
38964
|
+
}))
|
|
38914
38965
|
});
|
|
38915
38966
|
}
|
|
38916
38967
|
await mkdir(destDir, { recursive: true });
|
|
38917
|
-
const
|
|
38918
|
-
const failed = [];
|
|
38919
|
-
for (const it of items) {
|
|
38968
|
+
const outcomes = await mapLimit(items, FETCH_CONCURRENCY, async (it) => {
|
|
38920
38969
|
try {
|
|
38970
|
+
let file2 = templateUsesDate ? null : join3(destDir, buildFilename(template, it, it.artwork_id));
|
|
38971
|
+
if (file2 && skip_existing && existsSync(file2)) return { kind: "skipped", artwork_id: it.artwork_id, file: file2 };
|
|
38921
38972
|
const res = await fetch(artworkImageUrl(it.artwork_id, resolution));
|
|
38922
|
-
if (!res.ok) {
|
|
38923
|
-
|
|
38924
|
-
|
|
38973
|
+
if (!res.ok) return { kind: "failed", artwork_id: it.artwork_id, reason: `HTTP ${res.status}` };
|
|
38974
|
+
const lastMod = res.headers.get("last-modified");
|
|
38975
|
+
const parsed = lastMod ? new Date(lastMod) : null;
|
|
38976
|
+
const sourceDate = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null;
|
|
38977
|
+
const date5 = sourceDate ? sourceDate.toISOString().slice(0, 10) : "";
|
|
38978
|
+
if (!file2) {
|
|
38979
|
+
file2 = join3(destDir, buildFilename(template, { ...it, date: date5 }, it.artwork_id));
|
|
38980
|
+
if (skip_existing && existsSync(file2)) return { kind: "skipped", artwork_id: it.artwork_id, file: file2 };
|
|
38925
38981
|
}
|
|
38926
38982
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
38927
|
-
const file2 = join3(destDir, `${it.artwork_id}.jpg`);
|
|
38928
38983
|
await writeFile(file2, buf);
|
|
38929
|
-
|
|
38984
|
+
const fromSource = set_mtime_from_source && sourceDate !== null;
|
|
38985
|
+
if (fromSource) await utimes(file2, sourceDate, sourceDate);
|
|
38986
|
+
return {
|
|
38987
|
+
kind: "downloaded",
|
|
38988
|
+
artwork_id: it.artwork_id,
|
|
38989
|
+
file: file2,
|
|
38990
|
+
bytes: buf.length,
|
|
38991
|
+
date_source: fromSource ? "last-modified" : "download-time",
|
|
38992
|
+
timestamp: (fromSource ? sourceDate : /* @__PURE__ */ new Date()).toISOString()
|
|
38993
|
+
};
|
|
38930
38994
|
} catch (e) {
|
|
38931
|
-
|
|
38995
|
+
return { kind: "failed", artwork_id: it.artwork_id, reason: messageOf(e) };
|
|
38932
38996
|
}
|
|
38933
|
-
}
|
|
38997
|
+
});
|
|
38998
|
+
const downloaded = outcomes.filter((o) => o.kind === "downloaded");
|
|
38999
|
+
const skipped = outcomes.filter((o) => o.kind === "skipped");
|
|
39000
|
+
const failed = outcomes.filter((o) => o.kind === "failed");
|
|
38934
39001
|
return textResult({
|
|
38935
39002
|
downloaded_count: downloaded.length,
|
|
39003
|
+
skipped_count: skipped.length,
|
|
38936
39004
|
failed_count: failed.length,
|
|
38937
39005
|
dest: destDir,
|
|
38938
39006
|
resolution,
|
|
38939
|
-
downloaded,
|
|
38940
|
-
...
|
|
39007
|
+
downloaded: downloaded.map(({ kind, ...rest }) => rest),
|
|
39008
|
+
...skipped.length ? { skipped: skipped.map(({ kind, ...rest }) => rest) } : {},
|
|
39009
|
+
...failed.length ? { failed: failed.map(({ kind, ...rest }) => rest) } : {}
|
|
38941
39010
|
});
|
|
38942
39011
|
}
|
|
38943
39012
|
);
|
package/dist/tools/download.js
CHANGED
|
@@ -1,17 +1,70 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, writeFile, utimes } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { textResult, toolAnnotations, schemaConfirm, expandPath, messageOf } from '@chrischall/mcp-utils';
|
|
5
6
|
import { parsePortfolio, parseArtwork, artworkImageUrl } from '../parse.js';
|
|
6
7
|
const NumericId = z.string().regex(/^\d+$/, 'must be a numeric id');
|
|
8
|
+
const DEFAULT_TEMPLATE = '{grade} - {project} - {title}';
|
|
9
|
+
const FETCH_CONCURRENCY = 6;
|
|
10
|
+
const MAX_NAME_LEN = 150;
|
|
7
11
|
/** "Grade 6" / "grade 6" / "6" → "6"; "Grade K" → "k". */
|
|
8
12
|
function normalizeGrade(g) {
|
|
9
13
|
return (g ?? '').replace(/grade/i, '').trim().toLowerCase();
|
|
10
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a filename template to a safe, unique `.jpg` name.
|
|
17
|
+
* Tokens: {title} {project} {grade}(→"Grade N") {date} {artwork_id}. Empty tokens
|
|
18
|
+
* and their surrounding " - " separators are dropped. The result is slugified for
|
|
19
|
+
* filesystem safety, and the artwork_id is always appended (unless the template
|
|
20
|
+
* already includes it) so names are guaranteed unique AND a pure function of the
|
|
21
|
+
* artwork (→ idempotent re-runs). Untitled/empty → just the artwork_id.
|
|
22
|
+
*/
|
|
23
|
+
export function buildFilename(template, fields, artworkId) {
|
|
24
|
+
const tokens = {
|
|
25
|
+
title: fields.title?.trim() ?? '',
|
|
26
|
+
project: fields.project?.trim() ?? '',
|
|
27
|
+
grade: fields.grade ? `Grade ${fields.grade}` : '',
|
|
28
|
+
date: fields.date ?? '',
|
|
29
|
+
artwork_id: artworkId,
|
|
30
|
+
};
|
|
31
|
+
let name = template.replace(/\{(title|project|grade|date|artwork_id)\}/g, (_, k) => tokens[k] ?? '');
|
|
32
|
+
// Collapse separators left by empty tokens, e.g. "Grade 6 - - Title" → "Grade 6 - Title".
|
|
33
|
+
let prev;
|
|
34
|
+
do {
|
|
35
|
+
prev = name;
|
|
36
|
+
name = name.replace(/\s*-\s*-\s*/g, ' - ');
|
|
37
|
+
} while (name !== prev);
|
|
38
|
+
name = name.replace(/^[\s\-_]+|[\s\-_]+$/g, '');
|
|
39
|
+
// Slugify: drop filesystem-unsafe chars + control chars + leading dots; collapse whitespace.
|
|
40
|
+
name = name.replace(/[\/\\:*?"<>|\x00-\x1f]/g, '').replace(/^\.+/, '').replace(/\s+/g, ' ').trim();
|
|
41
|
+
if (name.length > MAX_NAME_LEN)
|
|
42
|
+
name = name.slice(0, MAX_NAME_LEN).trim();
|
|
43
|
+
// Guarantee uniqueness + idempotency: every name carries the artwork_id in its
|
|
44
|
+
// canonical "(id)" form unless the template already places the id explicitly.
|
|
45
|
+
if (!template.includes('{artwork_id}') && !name.includes(`(${artworkId})`)) {
|
|
46
|
+
name = name ? `${name} (${artworkId})` : artworkId;
|
|
47
|
+
}
|
|
48
|
+
if (!name)
|
|
49
|
+
name = artworkId;
|
|
50
|
+
return `${name}.jpg`;
|
|
51
|
+
}
|
|
52
|
+
/** Bounded-concurrency map that preserves input order in the result. */
|
|
53
|
+
async function mapLimit(items, limit, fn) {
|
|
54
|
+
const results = new Array(items.length);
|
|
55
|
+
let next = 0;
|
|
56
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
57
|
+
while (next < items.length) {
|
|
58
|
+
const i = next++;
|
|
59
|
+
results[i] = await fn(items[i], i);
|
|
60
|
+
}
|
|
61
|
+
}));
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
11
64
|
export function registerDownloadTools(server, client) {
|
|
12
65
|
server.registerTool('artsonia_download_artwork', {
|
|
13
66
|
title: "Download a student's artwork images",
|
|
14
|
-
description: "Download full-resolution images of a student's artwork to a local folder. Optionally filter by class/project
|
|
67
|
+
description: "Download full-resolution images of a student's artwork to a local folder, named from the artwork title/project/grade and time-stamped to the image's source date. Optionally filter by class/project (substring), grade, and/or keep only the most-recent N (the portfolio is reliably newest-first). Re-runs are idempotent (skip_existing). Without confirm:true this is a DRY RUN that lists the resolved filenames and writes nothing. Note: descriptive filenames need each artwork's detail page (slower) — use filename_template \"{artwork_id}\" for the fast id-only path.",
|
|
15
68
|
annotations: toolAnnotations({ title: "Download a student's artwork images", readOnly: false, openWorld: true }),
|
|
16
69
|
inputSchema: {
|
|
17
70
|
artist_id: NumericId.describe('Student artist_id (from artsonia_list_students).'),
|
|
@@ -20,30 +73,36 @@ export function registerDownloadTools(server, client) {
|
|
|
20
73
|
grade: z.string().min(1).optional().describe('Only artworks created in this grade, e.g. "6" or "Grade 6".'),
|
|
21
74
|
limit: z.number().int().positive().optional().describe('Keep only the N most recent matching artworks (portfolio is newest-first).'),
|
|
22
75
|
resolution: z.enum(['full', 'xlarge', 'large', 'medium', 'small']).default('full').describe('Image resolution. "full" is the original (~0.7 MB each).'),
|
|
76
|
+
filename_template: z.string().min(1).default(DEFAULT_TEMPLATE).describe('Filename pattern. Tokens: {title} {project} {grade} {date} {artwork_id}. The artwork_id is auto-appended for uniqueness if absent. Use "{artwork_id}" for the fast id-only path (no detail fetch).'),
|
|
77
|
+
set_mtime_from_source: z.boolean().default(true).describe("Set each file's modified time from the image's Last-Modified header (its Artsonia upload date) instead of the download moment."),
|
|
78
|
+
skip_existing: z.boolean().default(true).describe('Skip artworks whose target file already exists (idempotent re-runs). Set false to overwrite.'),
|
|
23
79
|
confirm: schemaConfirm,
|
|
24
80
|
},
|
|
25
|
-
}, async ({ artist_id, dest, project, grade, limit, resolution, confirm }) => {
|
|
26
|
-
|
|
81
|
+
}, async ({ artist_id, dest, project, grade, limit, resolution, filename_template, set_mtime_from_source, skip_existing, confirm }) => {
|
|
82
|
+
const template = filename_template;
|
|
83
|
+
const templateUsesDate = /\{date\}/.test(template);
|
|
84
|
+
const needDetail = project !== undefined || grade !== undefined || /\{(title|project|grade)\}/.test(template);
|
|
85
|
+
// 1. Portfolio → artwork ids (newest-first) + private flags.
|
|
27
86
|
const tiles = parsePortfolio(await client.fetchHtml(`/artists/portfolio.asp?id=${artist_id}`));
|
|
28
|
-
let items = tiles.map((t) => ({ artwork_id: t.artwork_id }));
|
|
29
|
-
// 2.
|
|
30
|
-
if (
|
|
87
|
+
let items = tiles.map((t) => ({ artwork_id: t.artwork_id, is_private: t.is_private }));
|
|
88
|
+
// 2. Detail fetch (for naming and/or project/grade filtering).
|
|
89
|
+
if (needDetail) {
|
|
31
90
|
const wantGrade = normalizeGrade(grade);
|
|
32
|
-
|
|
33
|
-
|
|
91
|
+
// With a limit and no filter, only the newest N need a detail page — don't
|
|
92
|
+
// fetch the whole portfolio just to throw most of it away.
|
|
93
|
+
const tilesToDetail = limit !== undefined && project === undefined && grade === undefined ? tiles.slice(0, limit) : tiles;
|
|
94
|
+
const detailed = await mapLimit(tilesToDetail, FETCH_CONCURRENCY, async (t) => {
|
|
34
95
|
const d = parseArtwork(await client.fetchHtml(`/museum/art.asp?id=${t.artwork_id}`));
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
items = matched;
|
|
96
|
+
return { artwork_id: t.artwork_id, is_private: t.is_private, title: d.title, project: d.project, grade: d.grade };
|
|
97
|
+
});
|
|
98
|
+
items = detailed.filter((it) => (project === undefined || (it.project ?? '').toLowerCase().includes(project.toLowerCase())) &&
|
|
99
|
+
(grade === undefined || normalizeGrade(it.grade) === wantGrade));
|
|
41
100
|
}
|
|
42
|
-
// 3. Most-recent-N.
|
|
101
|
+
// 3. Most-recent-N (no-op when already capped above; still needed for the filtered path).
|
|
43
102
|
if (limit !== undefined)
|
|
44
103
|
items = items.slice(0, limit);
|
|
45
104
|
const destDir = expandPath(dest);
|
|
46
|
-
// 4. Dry run.
|
|
105
|
+
// 4. Dry run — show resolved filenames (date-token names finalize at download time).
|
|
47
106
|
if (confirm !== true) {
|
|
48
107
|
return textResult({
|
|
49
108
|
preview: true,
|
|
@@ -52,36 +111,67 @@ export function registerDownloadTools(server, client) {
|
|
|
52
111
|
count: items.length,
|
|
53
112
|
dest: destDir,
|
|
54
113
|
resolution,
|
|
55
|
-
|
|
114
|
+
filename_template: template,
|
|
115
|
+
artworks: items.slice(0, 200).map((it) => ({
|
|
116
|
+
artwork_id: it.artwork_id,
|
|
117
|
+
...(it.title !== undefined ? { title: it.title } : {}),
|
|
118
|
+
filename: templateUsesDate ? '(finalized with {date} at download)' : buildFilename(template, it, it.artwork_id),
|
|
119
|
+
})),
|
|
56
120
|
});
|
|
57
121
|
}
|
|
58
|
-
// 5. Download.
|
|
122
|
+
// 5. Download (bounded concurrency).
|
|
59
123
|
await mkdir(destDir, { recursive: true });
|
|
60
|
-
const
|
|
61
|
-
const failed = [];
|
|
62
|
-
for (const it of items) {
|
|
124
|
+
const outcomes = await mapLimit(items, FETCH_CONCURRENCY, async (it) => {
|
|
63
125
|
try {
|
|
126
|
+
// Filenames without {date} are known up-front → skip before fetching.
|
|
127
|
+
let file = templateUsesDate ? null : join(destDir, buildFilename(template, it, it.artwork_id));
|
|
128
|
+
if (file && skip_existing && existsSync(file))
|
|
129
|
+
return { kind: 'skipped', artwork_id: it.artwork_id, file };
|
|
64
130
|
const res = await fetch(artworkImageUrl(it.artwork_id, resolution));
|
|
65
|
-
if (!res.ok)
|
|
66
|
-
|
|
67
|
-
|
|
131
|
+
if (!res.ok)
|
|
132
|
+
return { kind: 'failed', artwork_id: it.artwork_id, reason: `HTTP ${res.status}` };
|
|
133
|
+
// Parse Last-Modified defensively — a malformed header must NOT fail the
|
|
134
|
+
// download; it just falls back to download-time.
|
|
135
|
+
const lastMod = res.headers.get('last-modified');
|
|
136
|
+
const parsed = lastMod ? new Date(lastMod) : null;
|
|
137
|
+
const sourceDate = parsed && !Number.isNaN(parsed.getTime()) ? parsed : null;
|
|
138
|
+
const date = sourceDate ? sourceDate.toISOString().slice(0, 10) : '';
|
|
139
|
+
if (!file) {
|
|
140
|
+
file = join(destDir, buildFilename(template, { ...it, date }, it.artwork_id));
|
|
141
|
+
if (skip_existing && existsSync(file))
|
|
142
|
+
return { kind: 'skipped', artwork_id: it.artwork_id, file };
|
|
68
143
|
}
|
|
69
144
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
70
|
-
const file = join(destDir, `${it.artwork_id}.jpg`);
|
|
71
145
|
await writeFile(file, buf);
|
|
72
|
-
|
|
146
|
+
// date_source / timestamp reflect the file's ACTUAL mtime.
|
|
147
|
+
const fromSource = set_mtime_from_source && sourceDate !== null;
|
|
148
|
+
if (fromSource)
|
|
149
|
+
await utimes(file, sourceDate, sourceDate);
|
|
150
|
+
return {
|
|
151
|
+
kind: 'downloaded',
|
|
152
|
+
artwork_id: it.artwork_id,
|
|
153
|
+
file,
|
|
154
|
+
bytes: buf.length,
|
|
155
|
+
date_source: fromSource ? 'last-modified' : 'download-time',
|
|
156
|
+
timestamp: (fromSource ? sourceDate : new Date()).toISOString(),
|
|
157
|
+
};
|
|
73
158
|
}
|
|
74
159
|
catch (e) {
|
|
75
|
-
|
|
160
|
+
return { kind: 'failed', artwork_id: it.artwork_id, reason: messageOf(e) };
|
|
76
161
|
}
|
|
77
|
-
}
|
|
162
|
+
});
|
|
163
|
+
const downloaded = outcomes.filter((o) => o.kind === 'downloaded');
|
|
164
|
+
const skipped = outcomes.filter((o) => o.kind === 'skipped');
|
|
165
|
+
const failed = outcomes.filter((o) => o.kind === 'failed');
|
|
78
166
|
return textResult({
|
|
79
167
|
downloaded_count: downloaded.length,
|
|
168
|
+
skipped_count: skipped.length,
|
|
80
169
|
failed_count: failed.length,
|
|
81
170
|
dest: destDir,
|
|
82
171
|
resolution,
|
|
83
|
-
downloaded,
|
|
84
|
-
...(
|
|
172
|
+
downloaded: downloaded.map(({ kind, ...rest }) => rest),
|
|
173
|
+
...(skipped.length ? { skipped: skipped.map(({ kind, ...rest }) => rest) } : {}),
|
|
174
|
+
...(failed.length ? { failed: failed.map(({ kind, ...rest }) => rest) } : {}),
|
|
85
175
|
});
|
|
86
176
|
});
|
|
87
177
|
}
|
package/dist/version.js
CHANGED
package/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"manifest_version": "0.3",
|
|
4
4
|
"name": "artsonia-mcp",
|
|
5
5
|
"display_name": "Artsonia",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.4.0",
|
|
7
7
|
"description": "Artsonia student-art portfolios, comments, and fans for Claude — access via natural language",
|
|
8
8
|
"author": {
|
|
9
9
|
"name": "Chris Chall",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "artsonia-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"mcpName": "io.github.chrischall/artsonia-mcp",
|
|
5
5
|
"description": "Artsonia MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/artsonia-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "artsonia-mcp",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|