Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion boneset-api/data/bones/axis.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"id": "axis",
"subBones": [
"axis_body",
"axis_body_(dens)",
"axis_transverse_process",
"axis_transverse_foramen",
"axis_pedicle",
Expand Down
1 change: 0 additions & 1 deletion boneset-api/data/subbones/axis_body_(dens).json

This file was deleted.

2 changes: 1 addition & 1 deletion boneset-api/images.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const path = require("path");

const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png"]);
const DESCRIPTION_SUFFIX = /_description\.json$/i;
const BONE_ID_PATTERN = /^[a-z0-9_]+$/i;
const BONE_ID_PATTERN = /^[a-z0-9_-]+$/i; // hyphens allowed for 2nd-5th_metatarsals (Issue #469)
const HEADER_BYTES = 256 * 1024;

function imageUrl(filename) {
Expand Down
2 changes: 1 addition & 1 deletion boneset-api/legacyImport.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const fs = require("fs").promises;
const path = require("path");
const { getImageDimensions, imageUrl } = require("./images");

const BONE_ID_PATTERN = /^[a-z0-9_]+$/i;
const BONE_ID_PATTERN = /^[a-z0-9_-]+$/i; // hyphens allowed for 2nd-5th_metatarsals (Issue #469)
const MAX_IMAGE_SIZE = 480; // longest side of an imported image, in scene units
const IMAGE_GAP = 40; // space between imported images, and between import rows

Expand Down
7 changes: 4 additions & 3 deletions boneset-api/migrationValidation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { buildLegacyAnnotations } = require("./legacyAnnotations");
const { listItems, migrate } = require("./scripts/migration-report");

const DATA_DIR = path.join(__dirname, "data");
const LONG_TEST = 30000; // going through every item can take longer than Jest's default 5 seconds
const items = listItems();

function readJson(file) {
Expand Down Expand Up @@ -37,7 +38,7 @@ describe("Migrated scene validation - Issue 468", () => {
const row = await migrate(item);
if (row.status !== "imported") expect(row.note.length).toBeGreaterThan(0);
}
});
}, LONG_TEST);

describe("every imported item", () => {
let imported;
Expand All @@ -47,7 +48,7 @@ describe("Migrated scene validation - Issue 468", () => {
const result = await importItem(item.id).catch(() => null);
if (result) imported.push({ id: item.id, ...result });
}
});
}, LONG_TEST);

it("keeps the viewer's image order", () => {
for (const { id, images } of imported) {
Expand Down Expand Up @@ -120,4 +121,4 @@ describe("Migrated scene validation - Issue 468", () => {
}
});
});
});
});
186 changes: 186 additions & 0 deletions boneset-api/sceneStatus.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Issues #458 (Define Draft Scenes) and #459 (Publish and Unpublish Scenes),
// part of #457 (Control Scene and Annotation Visibility).
const fs = require("fs");
const os = require("os");
const path = require("path");
const express = require("express");
const request = require("supertest");
const { createScenesRouter, createFileSceneStore } = require("./scenes");

function buildApp(store) {
const app = express();
app.use(express.json({ limit: "6mb" }));
app.use("/api/scenes", createScenesRouter(store));
return app;
}

const content = () => ({
images: [{ id: "img-1", src: "/api/images/ilium_image1.jpg", x: 10, y: 20, width: 235, height: 371, rotation: 15 }],
annotations: [{ id: "ann-1", type: "text", text: "Iliac crest", x: 40, y: 50, fontSize: 16, color: "#1d3557" }],
});

describe("Scene drafts and publishing - Issues 458, 459", () => {
let dir;
let store;
let app;

const create = (body = {}) => request(app).post("/api/scenes").send(body);
const setStatus = (id, status) => request(app).patch(`/api/scenes/${id}/status`).send({ status });
const list = (query = "") => request(app).get(`/api/scenes${query}`);
const names = (response) => response.body.scenes.map((scene) => scene.name);

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "bonebox-scene-status-"));
store = createFileSceneStore(dir);
app = buildApp(store);
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

describe("drafts (#458)", () => {
it("starts every new scene as a draft", async () => {
const created = await create({ name: "Pelvis Study" });
expect(created.statusCode).toBe(201);
expect(created.body.status).toBe("draft");

const listed = await list();
expect(listed.body.scenes[0]).toMatchObject({ name: "Pelvis Study", status: "draft", publishedAt: null });
});

it("ignores a status sent when creating, so nothing is published by accident", async () => {
const created = await create({ name: "Sneaky", status: "published" });
expect(created.body.status).toBe("draft");
});

it("treats scenes saved before statuses existed as drafts", async () => {
await store.save({
id: "11111111-1111-4111-8111-111111111111", name: "Old Scene", images: [], annotations: [],
createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z",
});

const listed = await list();
expect(listed.body.scenes[0]).toMatchObject({ name: "Old Scene", status: "draft" });
expect(names(await list("?status=published"))).toEqual([]);
});

it("leaves drafts out of the published list the general viewer uses", async () => {
const draft = await create({ name: "Draft Scene" });
const done = await create({ name: "Finished Scene" });
await setStatus(done.body.id, "published");

expect(names(await list("?status=published"))).toEqual(["Finished Scene"]);
expect(names(await list("?status=draft"))).toEqual(["Draft Scene"]);
expect(names(await list()).sort()).toEqual(["Draft Scene", "Finished Scene"]); // the editor sees both
expect(draft.statusCode).toBe(201);
});

it("lets editors still open and save a draft", async () => {
const created = await create({ name: "Work in Progress" });

const opened = await request(app).get(`/api/scenes/${created.body.id}`);
expect(opened.statusCode).toBe(200);

const saved = await request(app).put(`/api/scenes/${created.body.id}`)
.send({ ...content(), baseUpdatedAt: created.body.updatedAt });
expect(saved.statusCode).toBe(200);
expect(saved.body.status).toBe("draft");
});

it("makes a duplicate of a published scene a draft", async () => {
const original = await create({ name: "Published Original" });
await setStatus(original.body.id, "published");

const copy = await request(app).post(`/api/scenes/${original.body.id}/duplicate`).send({});
expect(copy.statusCode).toBe(201);
expect(copy.body.status).toBe("draft");
expect(copy.body.publishedAt).toBeUndefined();
expect(names(await list("?status=published"))).toEqual(["Published Original"]);
});

it("rejects an unknown status filter", async () => {
const response = await list("?status=secret");
expect(response.statusCode).toBe(400);
});
});

describe("publish and unpublish (#459)", () => {
it("publishes a scene so it appears in the published list", async () => {
const created = await create({ name: "Ilium Study" });

const response = await setStatus(created.body.id, "published");
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({ id: created.body.id, name: "Ilium Study", status: "published" });
expect(Date.parse(response.body.publishedAt)).not.toBeNaN();

expect(names(await list("?status=published"))).toEqual(["Ilium Study"]);
const opened = await request(app).get(`/api/scenes/${created.body.id}`);
expect(opened.body.status).toBe("published");
});

it("unpublishes a scene so it's no longer listed as published", async () => {
const created = await create({ name: "Ilium Study" });
await setStatus(created.body.id, "published");

const response = await setStatus(created.body.id, "draft");
expect(response.statusCode).toBe(200);
expect(response.body).toMatchObject({ status: "draft", publishedAt: null });

expect(names(await list("?status=published"))).toEqual([]);
const opened = await request(app).get(`/api/scenes/${created.body.id}`);
expect(opened.body.status).toBe("draft");
expect(opened.body.publishedAt).toBeUndefined();
});

it("doesn't change the scene's content or version when publishing", async () => {
const created = await create({ name: "Content Check" });
const saved = await request(app).put(`/api/scenes/${created.body.id}`)
.send({ ...content(), baseUpdatedAt: created.body.updatedAt });

await setStatus(created.body.id, "published");
const opened = await request(app).get(`/api/scenes/${created.body.id}`);

expect(opened.body.status).toBe("published");
for (const field of ["name", "images", "annotations", "createdAt", "updatedAt"]) {
expect(opened.body[field]).toEqual(saved.body[field]);
}
});

it("still lets an open copy be saved after publishing, and it stays published", async () => {
const created = await create({ name: "Keep Editing" });
await setStatus(created.body.id, "published");

// The editor loaded the scene before publishing; its version is still valid.
const saved = await request(app).put(`/api/scenes/${created.body.id}`)
.send({ ...content(), baseUpdatedAt: created.body.updatedAt });
expect(saved.statusCode).toBe(200);
expect(saved.body.status).toBe("published");
expect(names(await list("?status=published"))).toEqual(["Keep Editing"]);
});

it("can publish and unpublish the same scene repeatedly", async () => {
const created = await create({ name: "Back and Forth" });
for (const status of ["published", "draft", "published"]) {
const response = await setStatus(created.body.id, status);
expect(response.body.status).toBe(status);
}
expect(names(await list("?status=published"))).toEqual(["Back and Forth"]);
});

it("rejects a status that isn't draft or published", async () => {
const created = await create({ name: "Bad Status" });
for (const body of [{ status: "archived" }, { status: "" }, {}]) {
const response = await request(app).patch(`/api/scenes/${created.body.id}/status`).send(body);
expect(response.statusCode).toBe(400);
}
const opened = await request(app).get(`/api/scenes/${created.body.id}`);
expect(opened.body.status).toBe("draft");
});

it("returns 404 for a scene that doesn't exist", async () => {
const response = await setStatus("00000000-0000-4000-8000-000000000000", "published");
expect(response.statusCode).toBe(404);
});
});
});
77 changes: 75 additions & 2 deletions boneset-api/scenes.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,16 @@ function applyImagePatch(existingImage, fields) {
return { image: merged };
}

// The bone viewer items a scene's images were imported from (Issue #469), so the
// viewer can link an item to the scenes made from it.
function legacySources(scene) {
const boneIds = (scene.images || [])
.filter((image) => image && image.source && image.source.type === "legacy-image")
.map((image) => image.source.boneId)
.filter((boneId) => typeof boneId === "string");
return [...new Set(boneIds)];
}

function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
Expand Down Expand Up @@ -237,12 +247,29 @@ function nextUpdatedAt(previous) {
return new Date(Number.isFinite(previousMs) && previousMs >= now ? previousMs + 1 : now).toISOString();
}

// Issues #458/#459: a scene is a draft (unfinished, only shown in the Scene
// Editor) until someone publishes it for the general bone viewer.
const SCENE_STATUSES = ["draft", "published"];

/**
* A scene's publication status. Scenes saved before statuses existed count as
* drafts, so nothing unfinished is ever shown as final content by accident.
* @param {object} scene
* @returns {"draft"|"published"}
*/
function sceneStatus(scene) {
return scene && scene.status === "published" ? "published" : "draft";
}

function toSummary(scene) {
return {
id: scene.id,
name: scene.name,
status: sceneStatus(scene),
publishedAt: sceneStatus(scene) === "published" ? scene.publishedAt || null : null,
imageCount: (scene.images || []).length,
annotationCount: (scene.annotations || []).length,
sources: legacySources(scene),
createdAt: scene.createdAt,
updatedAt: scene.updatedAt,
};
Expand Down Expand Up @@ -519,9 +546,14 @@ function createScenesRouter(store = resolveSceneStore(), options = {}) {
/**
* Lists all scenes (summaries only) for the scene picker. Issues #422, #423, #424.
*/
router.get("/", async (_req, res) => {
router.get("/", async (req, res) => {
const { status } = req.query;
if (status !== undefined && !SCENE_STATUSES.includes(status)) {
return res.status(400).json({ error: "status must be \"draft\" or \"published\"" });
}
try {
const scenes = await store.list();
let scenes = await store.list();
if (status) scenes = scenes.filter((scene) => sceneStatus(scene) === status);
scenes.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
res.json({ scenes: scenes.map(toSummary) });
} catch (error) {
Expand Down Expand Up @@ -565,6 +597,8 @@ function createScenesRouter(store = resolveSceneStore(), options = {}) {
images: content.images,
annotations: content.annotations,
...(content.importLog ? { importLog: content.importLog } : {}),
// New scenes always start unpublished (Issue #458).
status: "draft",
createdAt: now,
updatedAt: now,
};
Expand Down Expand Up @@ -806,6 +840,42 @@ function createScenesRouter(store = resolveSceneStore(), options = {}) {
}
});



/**
* Publishes or unpublishes a scene (Issues #458, #459).
* Body: { status: "draft" | "published" }. Responds with the scene's summary.
* Only the status changes: the images, annotations, and `updatedAt` version
* (which Save uses to detect conflicts) stay exactly the same, so publishing
* never changes a scene's content and an open working copy can still be
* saved normally afterwards.
*/
router.patch("/:sceneId/status", async (req, res) => {
const { sceneId } = req.params;
const status = isPlainObject(req.body) ? req.body.status : undefined;
if (!SCENE_STATUSES.includes(status)) {
return res.status(400).json({ error: "status must be \"draft\" or \"published\"" });
}
try {
await store.withLock(sceneId, async () => {
const scene = await store.get(sceneId);
if (!scene) {
res.status(404).json({ error: "Scene not found" });
return;
}
const updated = { ...scene, status };
if (status === "published") {
updated.publishedAt = new Date().toISOString();
} else {
delete updated.publishedAt;
}
await store.save(updated);
res.json(toSummary(updated));
});
} catch (error) {
sendStoreError(res, error, "Failed to change the scene's status");
}
});
/**
* Creates an independent copy of a saved scene with its own id and a
* "<name> copy" name, numbered if taken (Issue #455).
Expand All @@ -824,9 +894,12 @@ function createScenesRouter(store = resolveSceneStore(), options = {}) {
...JSON.parse(JSON.stringify(source)),
id: crypto.randomUUID(),
name: nextCopyName(await store.list(), source.name),
// A copy is new, unfinished work even if the original is published.
status: "draft",
createdAt: now,
updatedAt: now,
};
delete copy.publishedAt;
await store.save(copy);
res.status(201).location(`${req.baseUrl}/${copy.id}`).json(copy);
});
Expand Down
Loading
Loading