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.
@@ -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.3.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.3.0",
18
+ "version": "0.4.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "artsonia-mcp",
3
3
  "displayName": "Artsonia",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "description": "MCP server for Artsonia — natural-language access to student-art portfolios, comments, and fans",
6
6
  "author": {
7
7
  "name": "Chris Hall",
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.3.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: "Download full-resolution images of a student's artwork to a local folder. Optionally filter by class/project name (substring), by grade, and/or keep only the most-recent N (Artsonia exposes no real dates, but the portfolio is reliably newest-first). Without confirm:true this is a DRY RUN that lists what WOULD be downloaded and writes nothing. Note: project/grade filters require fetching each artwork's detail page (slower).",
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 (project !== void 0 || grade !== void 0) {
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 matched = [];
38895
- for (const t of tiles) {
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
- const okProject = project === void 0 || (d.project ?? "").toLowerCase().includes(project.toLowerCase());
38898
- const okGrade = grade === void 0 || normalizeGrade(d.grade) === wantGrade;
38899
- if (okProject && okGrade) matched.push({ artwork_id: t.artwork_id, title: d.title, project: d.project, grade: d.grade });
38900
- }
38901
- items = matched;
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
- artworks: items.slice(0, 100)
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 downloaded = [];
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
- failed.push({ artwork_id: it.artwork_id, reason: `HTTP ${res.status}` });
38924
- continue;
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
- downloaded.push({ artwork_id: it.artwork_id, file: file2, bytes: buf.length });
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
- failed.push({ artwork_id: it.artwork_id, reason: messageOf(e) });
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
- ...failed.length ? { failed } : {}
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
  );
@@ -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 name (substring), by grade, and/or keep only the most-recent N (Artsonia exposes no real dates, but the portfolio is reliably newest-first). Without confirm:true this is a DRY RUN that lists what WOULD be downloaded and writes nothing. Note: project/grade filters require fetching each artwork's detail page (slower).",
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
- // 1. Portfolio → artwork ids, newest-first.
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. project/grade filters need each artwork's detail page.
30
- if (project !== undefined || grade !== undefined) {
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
- const matched = [];
33
- for (const t of tiles) {
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
- const okProject = project === undefined || (d.project ?? '').toLowerCase().includes(project.toLowerCase());
36
- const okGrade = grade === undefined || normalizeGrade(d.grade) === wantGrade;
37
- if (okProject && okGrade)
38
- matched.push({ artwork_id: t.artwork_id, title: d.title, project: d.project, grade: d.grade });
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
- artworks: items.slice(0, 100),
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 downloaded = [];
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
- failed.push({ artwork_id: it.artwork_id, reason: `HTTP ${res.status}` });
67
- continue;
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
- downloaded.push({ artwork_id: it.artwork_id, file, bytes: buf.length });
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
- failed.push({ artwork_id: it.artwork_id, reason: messageOf(e) });
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
- ...(failed.length ? { failed } : {}),
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
@@ -1,3 +1,3 @@
1
1
  // Single source of truth for the server version. The marker is what
2
2
  // release-please bumps; versionSyncTest asserts it equals package.json.
3
- export const VERSION = '0.3.0'; // x-release-please-version
3
+ export const VERSION = '0.4.0'; // x-release-please-version
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.3.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.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.3.0",
9
+ "version": "0.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "artsonia-mcp",
14
- "version": "0.3.0",
14
+ "version": "0.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },