From 4d8c4f11d1a8f806a6b04c405999fd28a9bdaf3c Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:12:51 +0100 Subject: [PATCH 01/10] Update TODOs --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 43c6337..0c65023 100644 --- a/TODO.md +++ b/TODO.md @@ -30,7 +30,7 @@ In Mod-Cyclops, for the moment it would be sufficient to replace `reserve` in th * The `reserve` set has been superseded by the `object` set in each project. * Added the `update` command for changing `object` attributes. -* Added the `show sets in project` command. +* **DONE** Added the `show sets in project` command. * **DONE** The project property `locations` has been removed. * Added the `archive project` command, to be used instead of `drop project`. * The `drop project` command now only drops archived projects. From 42ece8e138cb63b6eb198529bc3a05f05813b7c3 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:23:38 +0100 Subject: [PATCH 02/10] RAML, JSON Schema and JSON example for spectre update --- ramls/Makefile | 2 ++ ramls/cyclops.raml | 15 +++++++++++++++ ramls/examples/spectre-example.json | 4 ++++ ramls/spectre-schema.json | 16 ++++++++++++++++ 4 files changed, 37 insertions(+) create mode 100644 ramls/examples/spectre-example.json create mode 100644 ramls/spectre-schema.json diff --git a/ramls/Makefile b/ramls/Makefile index 8910bc1..b5f806e 100644 --- a/ramls/Makefile +++ b/ramls/Makefile @@ -22,6 +22,7 @@ schemalint: z-schema addobjects-schema.json z-schema removeobjects-schema.json z-schema addremovetag-schema.json + z-schema spectre-schema.json z-schema location-schema.json z-schema project-schema.json z-schema projects-schema.json @@ -40,6 +41,7 @@ examplelint: z-schema removeobjects-schema.json examples/removeobject-example.json z-schema addremovetag-schema.json examples/addtag-example.json z-schema addremovetag-schema.json examples/removetag-example.json + z-schema spectre-schema.json examples/spectre-example.json z-schema location-schema.json examples/location-example.json z-schema project-schema.json examples/project-example.json z-schema projects-schema.json examples/show-projects-example.json diff --git a/ramls/cyclops.raml b/ramls/cyclops.raml index 95a2ef1..65d0085 100644 --- a/ramls/cyclops.raml +++ b/ramls/cyclops.raml @@ -165,6 +165,21 @@ documentation: responses: 204: description: No content is returned. + /{recordId}: + uriParameters: + recordId: + description: The identifier of the record within the set to update + type: string + required: true + post: + description: "Update the fields of a single record within a set" + body: + application/json: + type: !include spectre-schema.json + example: !include examples/spectre-example.json + responses: + 204: + description: No content is returned. /projects: description: "The projects in the system, in which collaborative collection management takes place" get: diff --git a/ramls/examples/spectre-example.json b/ramls/examples/spectre-example.json new file mode 100644 index 0000000..bfc6791 --- /dev/null +++ b/ramls/examples/spectre-example.json @@ -0,0 +1,4 @@ +{ + "decision": true, + "fund": "palci" +} diff --git a/ramls/spectre-schema.json b/ramls/spectre-schema.json new file mode 100644 index 0000000..bbef594 --- /dev/null +++ b/ramls/spectre-schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "A request to update the fields of a single record within a set", + "type": "object", + "properties": { + "decision": { + "type": "boolean", + "description": "Whether the record has been approved" + }, + "fund": { + "type": "string", + "description": "The name of the fund associated with the record" + } + }, + "additionalProperties": false +} From cc15b380d787b3132db28600ea0454dad85663e4 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:24:56 +0100 Subject: [PATCH 03/10] Add spectre update to MD --- descriptors/ModuleDescriptor-template.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/descriptors/ModuleDescriptor-template.json b/descriptors/ModuleDescriptor-template.json index 63d7b96..ccfb289 100644 --- a/descriptors/ModuleDescriptor-template.json +++ b/descriptors/ModuleDescriptor-template.json @@ -116,6 +116,15 @@ "cyclops.sets.remove-tags" ] }, + { + "methods": [ + "POST" + ], + "pathPattern": "/cyclops/sets/{setName}/{recordId}", + "permissionsRequired": [ + "cyclops.sets.spectre.update" + ] + }, { "methods": [ "GET" @@ -247,6 +256,10 @@ "displayName": "CYCLOPS: Remove tags from objects in a set (UNIMPLEMENTED)", "subPermissions": ["cyclops.sets.modify-tags"] }, + { + "permissionName": "cyclops.sets.spectre.update", + "displayName": "CYCLOPS: Update a record (spectre) within a set" + }, { "permissionName": "cyclops.projects.collection.get", "displayName": "CYCLOPS: Read list of projects" From a8b82ef26680eb9c537d3d7ba7fd67ecf8360bc2 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:25:40 +0100 Subject: [PATCH 04/10] Implement spectre update --- cyclops/handlers.go | 30 ++++++++++++++++++++++++++++++ cyclops/server.go | 3 +++ 2 files changed, 33 insertions(+) diff --git a/cyclops/handlers.go b/cyclops/handlers.go index 54cecd1..e420bbc 100644 --- a/cyclops/handlers.go +++ b/cyclops/handlers.go @@ -424,6 +424,36 @@ func (server *ModCyclopsServer) handleRemoveObjects(w http.ResponseWriter, req * // ----------------------------------------------------------------------------- +type UpdateRecord struct { + Decision bool `json:"decision"` + Fund string `json:"fund"` +} + +func (server *ModCyclopsServer) handleUpdateRecord(w http.ResponseWriter, req *http.Request, caption string) error { + setName := chi.URLParam(req, "setName") + recordId := chi.URLParam(req, "recordId") + + var record UpdateRecord + err := unmarshalBody(req, &record) + if err != nil { + return fmt.Errorf("%s: %w", caption, err) + } + + command := fmt.Sprintf("update %s set decision = %v where id = %s; update %s set fund = %s where id = %s", + setName, record.Decision, recordId, setName, record.Fund, recordId) + server.Log("command", command) + + _, err = server.sendToCCMS(caption+" "+setName+"/"+recordId, command) + if err != nil { + return err + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// ----------------------------------------------------------------------------- + func (server *ModCyclopsServer) handleAddRemoveTags(w http.ResponseWriter, req *http.Request, caption string) error { // It seems weird to just shrug and say "fine" for anything posted, but for now it will suffice. w.WriteHeader(http.StatusNoContent) diff --git a/cyclops/server.go b/cyclops/server.go index 5a1f83e..3c12136 100644 --- a/cyclops/server.go +++ b/cyclops/server.go @@ -99,6 +99,9 @@ func MakeModCyclopsServer(logger *catlogger.Logger, ccmsClient CCMSClient, root r.Post("/cyclops/sets/{setName}/tag/{tagName}", func(w http.ResponseWriter, req *http.Request) { server.runWithErrorHandling(w, req, server.handleAddRemoveTags, "add/remove tags") }) + r.Post("/cyclops/sets/{setName}/{recordId}", func(w http.ResponseWriter, req *http.Request) { + server.runWithErrorHandling(w, req, server.handleUpdateRecord, "update record") + }) r.Get("/cyclops/projects", func(w http.ResponseWriter, req *http.Request) { server.runWithErrorHandling(w, req, server.handleShowProjects, "show projects") }) From ac899816c9a0fe7eb2abcfd9017586ea96d079cc Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:26:29 +0100 Subject: [PATCH 05/10] Add unit tests for spectre update --- cyclops/handlers_test.go | 16 ++++++++++++++++ cyclops/server_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/cyclops/handlers_test.go b/cyclops/handlers_test.go index 7599069..651892d 100644 --- a/cyclops/handlers_test.go +++ b/cyclops/handlers_test.go @@ -417,6 +417,22 @@ func TestHandleDefineTag(t *testing.T) { }) } +func TestHandleUpdateRecord(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + params := map[string]string{"setName": "mike", "recordId": "rec1"} + rr := httptest.NewRecorder() + err := server.handleUpdateRecord(rr, jsonRequest(`{"decision":true,"fund":"palci"}`, params), "update record") + if err != nil { + t.Fatalf("handleUpdateRecord returned error: %v", err) + } + + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1") + assertStatus(t, rr, http.StatusNoContent) +} + func TestHandleDefineFilter(t *testing.T) { fake := &fakeCCMS{resp: okResponse()} server := newTestServer(fake) diff --git a/cyclops/server_test.go b/cyclops/server_test.go index 4a984f5..a3b5ac4 100644 --- a/cyclops/server_test.go +++ b/cyclops/server_test.go @@ -6,6 +6,7 @@ import "net/http/httptest" import "os" import "path/filepath" import "reflect" +import "strings" import "testing" import "github.com/MikeTaylor/catlogger" import "github.com/indexdata/ccms" @@ -90,6 +91,35 @@ func TestMakeModCyclopsServerRoutesToHandler(t *testing.T) { } } +// The POST /cyclops/sets/{setName}/{recordId} route is a wildcard sibling of +// the static /add, /remove and /tag sub-routes. Confirm the constructor wires +// it to handleUpdateRecord, and that the static routes still take precedence. +func TestMakeModCyclopsServerUpdateRecordRoute(t *testing.T) { + t.Run("dispatches to the update-record handler", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + body := strings.NewReader(`{"decision":true,"fund":"palci"}`) + rr := serve(server, httptest.NewRequest(http.MethodPost, "/cyclops/sets/mike/rec1", body)) + + assertStatus(t, rr, http.StatusNoContent) + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1") + }) + + t.Run("static /add still wins over {recordId}", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + body := strings.NewReader(`{"from":"src"}`) + rr := serve(server, httptest.NewRequest(http.MethodPost, "/cyclops/sets/mike/add", body)) + + assertStatus(t, rr, http.StatusNoContent) + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "insert into mike select * from src limit 100;") + }) +} + // serverRootedAt builds a server whose static files are served from the given // directory. The htdocs FileServer reads from /htdocs. func serverRootedAt(root string) *ModCyclopsServer { From f723e8321c2d1f04dd21571d73e61aac1886dbe7 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:47:45 +0100 Subject: [PATCH 06/10] Use "object" for qualified set-names --- cyclops/handlers.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cyclops/handlers.go b/cyclops/handlers.go index e420bbc..4896370 100644 --- a/cyclops/handlers.go +++ b/cyclops/handlers.go @@ -439,7 +439,14 @@ func (server *ModCyclopsServer) handleUpdateRecord(w http.ResponseWriter, req *h return fmt.Errorf("%s: %w", caption, err) } - command := fmt.Sprintf("update %s set decision = %v where id = %s; update %s set fund = %s where id = %s", + // A qualified set name such as "foo.bar" refers to the objects within the + // set, which CCMS addresses as "foo.object". + prefix, _, found := strings.Cut(setName, ".") + if found { + setName = prefix + ".object" + } + + command := fmt.Sprintf("update %s set decision = %v where id = %s; update %s set fund = %s where id = %s;", setName, record.Decision, recordId, setName, record.Fund, recordId) server.Log("command", command) From 6cb403ef7498f493482036eeb054f83230e02155 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:48:42 +0100 Subject: [PATCH 07/10] Add test-case for set-name mangling --- cyclops/handlers_test.go | 42 +++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/cyclops/handlers_test.go b/cyclops/handlers_test.go index 651892d..877fec4 100644 --- a/cyclops/handlers_test.go +++ b/cyclops/handlers_test.go @@ -418,19 +418,39 @@ func TestHandleDefineTag(t *testing.T) { } func TestHandleUpdateRecord(t *testing.T) { - fake := &fakeCCMS{resp: okResponse()} - server := newTestServer(fake) + t.Run("simple case with non-faceted set-name", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) - params := map[string]string{"setName": "mike", "recordId": "rec1"} - rr := httptest.NewRecorder() - err := server.handleUpdateRecord(rr, jsonRequest(`{"decision":true,"fund":"palci"}`, params), "update record") - if err != nil { - t.Fatalf("handleUpdateRecord returned error: %v", err) - } + params := map[string]string{"setName": "mike", "recordId": "rec1"} + rr := httptest.NewRecorder() + err := server.handleUpdateRecord(rr, jsonRequest(`{"decision":true,"fund":"palci"}`, params), "update record") + if err != nil { + t.Fatalf("handleUpdateRecord returned error: %v", err) + } - assertEqual(t, "command sent to CCMS", fake.lastCmd, - "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1") - assertStatus(t, rr, http.StatusNoContent) + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1;") + assertStatus(t, rr, http.StatusNoContent) + }) + + // A qualified set name like "foo.bar" has the part after the period replaced + // with "object" before the command is built. + t.Run("complex case with faceted set-name", func(t *testing.T) { + fake := &fakeCCMS{resp: okResponse()} + server := newTestServer(fake) + + params := map[string]string{"setName": "foo.bar", "recordId": "rec1"} + rr := httptest.NewRecorder() + err := server.handleUpdateRecord(rr, jsonRequest(`{"decision":true,"fund":"palci"}`, params), "update record") + if err != nil { + t.Fatalf("handleUpdateRecord returned error: %v", err) + } + + assertEqual(t, "command sent to CCMS", fake.lastCmd, + "update foo.object set decision = true where id = rec1; update foo.object set fund = palci where id = rec1;") + assertStatus(t, rr, http.StatusNoContent) + }) } func TestHandleDefineFilter(t *testing.T) { From 12c12345612c837e286954ff2db6772a091d2741 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:49:08 +0100 Subject: [PATCH 08/10] Update test expectation --- cyclops/server_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cyclops/server_test.go b/cyclops/server_test.go index a3b5ac4..d667873 100644 --- a/cyclops/server_test.go +++ b/cyclops/server_test.go @@ -104,7 +104,7 @@ func TestMakeModCyclopsServerUpdateRecordRoute(t *testing.T) { assertStatus(t, rr, http.StatusNoContent) assertEqual(t, "command sent to CCMS", fake.lastCmd, - "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1") + "update mike set decision = true where id = rec1; update mike set fund = palci where id = rec1;") }) t.Run("static /add still wins over {recordId}", func(t *testing.T) { From d6e8d63db364313f0fd3e1e64cef20f5704fac76 Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:49:28 +0100 Subject: [PATCH 09/10] Add HtML link to exercise update spectre --- htdocs/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/htdocs/index.html b/htdocs/index.html index 5e1386f..85b0bfe 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -75,8 +75,9 @@
  • retrieve from set "korea_lit.mike"
  • retrieve from set "korea_lit.mike" using only supported params
  • retrieve everything from set "korea_lit.mike"
  • -
  • +
  • +
  • From d5c6fb57d34cff9ba79da0a0b1f75740b1fefcde Mon Sep 17 00:00:00 2001 From: Mike Taylor Date: Mon, 22 Jun 2026 21:49:55 +0100 Subject: [PATCH 10/10] Update terminology of item/spectre --- htdocs/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/htdocs/index.html b/htdocs/index.html index 85b0bfe..44b6c33 100644 --- a/htdocs/index.html +++ b/htdocs/index.html @@ -76,8 +76,8 @@
  • retrieve from set "korea_lit.mike" using only supported params
  • retrieve everything from set "korea_lit.mike"
  • -
  • -
  • +
  • +