diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9f3912b8..1751bb4d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -100,7 +100,6 @@ The `db-controller.js` is a facade that imports from specialized controller modu - `controllers/release.js`: Object release (immutability) - `controllers/bulk.js`: Bulk create and update operations - `controllers/search.js`: MongoDB text search (searchAsWords, searchAsPhrase) -- `controllers/gog.js`: Gallery of Glosses specific operations (fragments, glosses, expand) - `controllers/utils.js`: Shared utilities (ID generation, slug handling, agent claims) **4. Authentication & Authorization:** @@ -113,7 +112,6 @@ The `db-controller.js` is a facade that imports from specialized controller modu **5. Special Features:** - **Slug IDs:** Optional human-readable IDs via Slug header (e.g., "my-annotation") - **PATCH Override:** X-HTTP-Method-Override header allows POST to emulate PATCH for clients without PATCH support -- **GOG Routes:** Specialized endpoints for Gallery of Glosses project (`/gog/fragmentsInManuscript`, `/gog/glossesInManuscript`) - **Content Negotiation:** Handles both `@id`/`@context` (JSON-LD) and `id` (plain JSON) patterns ### Directory Structure diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d8512052..6af5cd49 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -125,7 +125,7 @@ After making changes, ALWAYS validate these scenarios: ### Key Directories - `/routes/` - Route handlers and API endpoints (Express routes) -- `/controllers/` - Business logic controllers (CRUD operations, GOG-specific controllers) +- `/controllers/` - Business logic controllers (CRUD operations, history, release, bulk operations) - `/database/` - Database connection and utilities (MongoDB integration) - `/auth/` - Authentication middleware (Auth0 JWT handling) - `/public/` - Static files (API.html, context.json, etc.) @@ -145,7 +145,6 @@ After making changes, ALWAYS validate these scenarios: - **Database**: MongoDB for persistent storage, versioned objects - **Static Files**: Served directly from `/public` directory - **CORS**: Fully open ("*") for cross-origin requests -- **Specialized Routes**: Gallery of Glosses (GOG) specific endpoints in `_gog_*.js` files ### Coding Style Guidelines - **Semicolons**: Avoid unnecessary semicolons (e.g., at the end of most lines) diff --git a/app.js b/app.js index 548f9092..45161ac3 100644 --- a/app.js +++ b/app.js @@ -9,8 +9,6 @@ import cors from 'cors' import indexRouter from './routes/index.js' import apiRouter from './routes/api-routes.js' import clientRouter from './routes/client.js' -import _gog_fragmentsRouter from './routes/_gog_fragments_from_manuscript.js'; -import _gog_glossesRouter from './routes/_gog_glosses_from_manuscript.js'; import rest from './rest.js' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) @@ -84,8 +82,6 @@ app.use('/v1', apiRouter) app.use('/client', clientRouter) -app.use('/gog/fragmentsInManuscript', _gog_fragmentsRouter) -app.use('/gog/glossesInManuscript', _gog_glossesRouter) /** * Handle API errors and warnings RESTfully. All routes that don't end in res.send() will end up here. diff --git a/controllers/gog.js b/controllers/gog.js deleted file mode 100644 index 6a6cb11b..00000000 --- a/controllers/gog.js +++ /dev/null @@ -1,405 +0,0 @@ -#!/usr/bin/env node - -/** - * Gallery of Glosses (GOG) controller for RERUM operations - * Handles specialized operations for the Gallery of Glosses application - * @author Claude Sonnet 4, cubap, thehabes - */ - -import { newID, isValidID, db } from '../database/client.js' -import { configureLDHeadersFor } from '../headers.js' -import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' - -/** - * THIS IS SPECIFICALLY FOR 'Gallery of Glosses' - * Starting from a ManuscriptWitness URI get all WitnessFragment entities that are a part of the Manuscript. - * The inbound request is a POST request with an Authorization header - * The Bearer Token in the header must be from TinyMatt. - * The body must be formatted correctly - {"ManuscriptWitness":"witness_uri_here"} - * - * TODO? Some sort of limit and skip for large responses? - * - * @return The set of {'@id':'123', '@type':'WitnessFragment'} objects that match this criteria, as an Array - * */ -const _gog_fragments_from_manuscript = async function (req, res, next) { - res.set("Content-Type", "application/json; charset=utf-8") - const agent = getAgentClaim(req, next) - const agentID = agent.split("/").pop() - const manID = req.body["ManuscriptWitness"] - const limit = parseInt(req.query.limit ?? 50) - const skip = parseInt(req.query.skip ?? 0) - let err = { message: `` } - // This request can only be made my Gallery of Glosses production apps. - if (agentID !== "61043ad4ffce846a83e700dd") { - err = Object.assign(err, { - message: `Only the Gallery of Glosses can make this request.`, - status: 403 - }) - } - // Must have a properly formed body with a usable value - else if(!manID || !manID.startsWith("http")){ - err = Object.assign(err, { - message: `The body must be JSON like {"ManuscriptWitness":"witness_uri_here"}.`, - status: 400 - }) - } - if (err.status) { - next(createExpressError(err)) - return - } - try { - let matches = [] - const partOfConditions = [ - {"body.partOf.value": manID.replace(/^https?/, "http")}, - {"body.partOf.value": manID.replace(/^https?/, "https")}, - {"body.partOf": manID.replace(/^https?/, "http")}, - {"body.partOf": manID.replace(/^https?/, "https")} - ] - const generatorConditions = [ - {"__rerum.generatedBy": agent.replace(/^https?/, "http")}, - {"__rerum.generatedBy": agent.replace(/^https?/, "https")} - ] - const fragmentTypeConditions = [ - {"witnessFragment.type": "WitnessFragment"}, - {"witnessFragment.@type": "WitnessFragment"} - ] - const annoTypeConditions = [ - {"type": "Annotation"}, - {"@type": "Annotation"}, - {"@type": "oa:Annotation"} - ] - let witnessFragmentPipeline = [ - // Step 1: Detect Annotations bodies noting their 'target' is 'partOf' this Manuscript - { - $match: { - "__rerum.history.next": { "$exists": true, "$size": 0 }, - "$and":[ - {"$or": annoTypeConditions}, - {"$or": partOfConditions}, - {"$or": generatorConditions} - ] - } - }, - // Step 1.1 through 1.3 for limit and skip functionality. - { $sort : { _id: 1 } }, - { $skip : skip }, - { $limit : limit }, - // Step 2: Using the target of those Annotations lookup the Entity they represent and store them in a witnessFragment property on the Annotation - // Note that $match had filtered down the alpha collection, so we use $lookup to look through the whole collection again. - // FIXME? a target that is http will not match an @id that is https - { - $lookup: { - from: "alpha", - localField: "target", // Field in `Annotation` referencing `@id` in `alpha` corresponding to a WitnessFragment @id - foreignField: "@id", - as: "witnessFragment" - } - }, - // Step 3: Filter out anything that is not a WitnessFragment entity (and a leaf) - { - $match: { - "witnessFragment.__rerum.history.next": { "$exists": true, "$size": 0 }, - "$or": fragmentTypeConditions - } - }, - // Step 4: Unwrap the Annotation and just return its corresponding WitnessFragment entity - { - $project: { - "_id": 0, - "@id": "$witnessFragment.@id", - "@type": "WitnessFragment" - } - }, - // Step 5: @id values are an Array of 1 and need to be a string instead - { - $unwind: { "path": "$@id" } - } - // Step 6: Cache it? - ] - - // console.log("Start GoG WitnessFragment Aggregator") - const start = Date.now() - let witnessFragments = await db.aggregate(witnessFragmentPipeline).toArray() - .then((fragments) => { - if (fragments instanceof Error) { - throw fragments - } - return fragments - }) - const fragmentSet = new Set(witnessFragments) - witnessFragments = Array.from(fragmentSet.values()) - // Note that a server side expand() is available and could be used to expand these fragments here. - // console.log("End GoG WitnessFragment Aggregator") - // console.log(witnessFragments.length+" fragments found for this Manuscript") - // const end = Date.now() - // console.log(`Total Execution time: ${end - start} ms`) - res.set(configureLDHeadersFor(witnessFragments)) - res.json(witnessFragments) - } - catch (error) { - console.error(error) - next(createExpressError(error)) - } -} - -/** - * THIS IS SPECIFICALLY FOR 'Gallery of Glosses' - * Starting from a ManuscriptWitness URI get all Gloss entities that are a part of the Manuscript. - * The inbound request is a POST request with an Authorization header. - * The Bearer Token in the header must be from TinyMatt. - * The body must be formatted correctly - {"ManuscriptWitness":"witness_uri_here"} - * - * TODO? Some sort of limit and skip for large responses? - * - * @return The set of {'@id':'123', '@type':'Gloss'} objects that match this criteria, as an Array - * */ -const _gog_glosses_from_manuscript = async function (req, res, next) { - res.set("Content-Type", "application/json; charset=utf-8") - const agent = getAgentClaim(req, next) - const agentID = agent.split("/").pop() - const manID = req.body["ManuscriptWitness"] - const limit = parseInt(req.query.limit ?? 50) - const skip = parseInt(req.query.skip ?? 0) - let err = { message: `` } - // This request can only be made my Gallery of Glosses production apps. - if (!agentID === "61043ad4ffce846a83e700dd") { - err = Object.assign(err, { - message: `Only the Gallery of Glosses can make this request.`, - status: 403 - }) - } - // Must have a properly formed body with a usable value - else if(!manID || !manID.startsWith("http")){ - err = Object.assign(err, { - message: `The body must be JSON like {"ManuscriptWitness":"witness_uri_here"}.`, - status: 400 - }) - } - if (err.status) { - next(createExpressError(err)) - return - } - try { - let matches = [] - const partOfConditions = [ - {"body.partOf.value": manID.replace(/^https?/, "http")}, - {"body.partOf.value": manID.replace(/^https?/, "https")}, - {"body.partOf": manID.replace(/^https?/, "http")}, - {"body.partOf": manID.replace(/^https?/, "https")} - ] - const generatorConditions = [ - {"__rerum.generatedBy": agent.replace(/^https?/, "http")}, - {"__rerum.generatedBy": agent.replace(/^https?/, "https")} - ] - const fragmentTypeConditions = [ - {"witnessFragment.type": "WitnessFragment"}, - {"witnessFragment.@type": "WitnessFragment"} - ] - const annoTypeConditions = [ - {"type": "Annotation"}, - {"@type": "Annotation"}, - {"@type": "oa:Annotation"} - ] - let glossPipeline = [ - // Step 1: Detect Annotations bodies noting their 'target' is 'partOf' this Manuscript - { - $match: { - "__rerum.history.next": { $exists: true, $size: 0 }, - "$and":[ - {"$or": annoTypeConditions}, - {"$or": partOfConditions}, - {"$or": generatorConditions} - ] - } - }, - // Step 1.1 through 1.3 for limit and skip functionality. - { $sort : { _id: 1 } }, - { $skip : skip }, - { $limit : limit }, - // Step 2: Using the target of those Annotations lookup the Entity they represent and store them in a witnessFragment property on the Annotation - // Note that $match had filtered down the alpha collection, so we use $lookup to look through the whole collection again. - // FIXME? a target that is http will not match an @id that is https - { - $lookup: { - from: "alpha", - localField: "target", // Field in `Annotation` referencing `@id` in `alpha` corresponding to a WitnessFragment @id - foreignField: "@id", - as: "witnessFragment" - } - }, - // Step 3: Filter Annotations to be only those which are for a WitnessFragment Entity - { - $match: { - "$or": fragmentTypeConditions - } - }, - // Step 4: Unwrap the Annotation and just return its corresponding WitnessFragment entity - { - $project: { - "_id": 0, - "@id": "$witnessFragment.@id", - "@type": "WitnessFragment" - } - }, - // Step 5: @id values are an Array of 1 and need to be a string instead - { - $unwind: { "path": "$@id" } - }, - // Step 6: Using the WitnessFragment ids lookup their references Annotations - // Note that $match had filtered down the alpha collection, so we use $lookup to look through the whole collection again. - { - $lookup: { - from: "alpha", - localField: "@id", // Field in `WitnessFragment` referencing `target` in `alpha` corresponding to a Gloss @id - foreignField: "target", - as: "anno" - } - }, - // Step 7: Filter Annos down to those that are the 'references' Annotations - { - $match: { - "anno.body.references":{ "$exists": true } - } - }, - // Step 7: Collect together the body.references.value[] of those Annotations. Those are the relevant Gloss URIs. - { - $project: { - "_id": 0, - "@id": "$anno.body.references.value", - "@type": "Gloss" - } - }, - // Step 8: @id values are an Array of and Array 1 because references.value is an Array - { - $unwind: { "path": "$@id" } - }, - // Step 9: @id values are now an Array of 1 and need to be a string instead - { - $unwind: { "path": "$@id" } - } - ] - - // console.log("Start GoG Gloss Aggregator") - // const start = Date.now() - let glosses = await db.aggregate(glossPipeline).toArray() - .then((fragments) => { - if (fragments instanceof Error) { - throw fragments - } - return fragments - }) - const glossSet = new Set(glosses) - glosses = Array.from(glossSet.values()) - // Note that a server side expand() is available and could be used to expand these fragments here. - // console.log("End GoG Gloss Aggregator") - // console.log(glosses.length+" Glosses found for this Manuscript") - // const end = Date.now() - // console.log(`Total Execution time: ${end - start} ms`) - res.set(configureLDHeadersFor(glosses)) - res.json(glosses) - } - catch (error) { - console.error(error) - next(createExpressError(error)) - } -} - -/** -* Find relevant Annotations targeting a primitive RERUM entity. This is a 'full' expand. -* Add the descriptive information in the Annotation bodies to the primitive object. -* -* Anticipate likely Annotation body formats -* - anno.body -* - anno.body.value -* -* Anticipate likely Annotation target formats -* - target: 'uri' -* - target: {'id':'uri'} -* - target: {'@id':'uri'} -* -* Anticipate likely Annotation type formats -* - {"type": "Annotation"} -* - {"@type": "Annotation"} -* - {"@type": "oa:Annotation"} -* -* @param primitiveEntity - An existing RERUM object -* @param GENERATOR - A registered RERUM app's User Agent -* @param CREATOR - Some kind of string representing a specific user. Often combined with GENERATOR. -* @return the expanded entity object -* -*/ -const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=undefined){ - if(!primitiveEntity?.["@id"] || primitiveEntity?.id) return primitiveEntity - const targetId = primitiveEntity["@id"] ?? primitiveEntity.id ?? "unknown" - let queryObj = { - "__rerum.history.next": { $exists: true, $size: 0 } - } - let targetPatterns = ["target", "target.@id", "target.id"] - let targetConditions = [] - let annoTypeConditions = [{"type": "Annotation"}, {"@type":"Annotation"}, {"@type":"oa:Annotation"}] - - if (targetId.startsWith("http")) { - for(const targetKey of targetPatterns){ - targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "http") }) - targetConditions.push({ [targetKey]: targetId.replace(/^https?/, "https") }) - } - queryObj["$and"] = [{"$or": targetConditions}, {"$or": annoTypeConditions}] - } - else{ - queryObj["$or"] = annoTypeConditions - queryObj.target = targetId - } - - // Only expand with data from a specific app - if(GENERATOR) { - // Need to check http:// and https:// - const generatorConditions = [ - {"__rerum.generatedBy": GENERATOR.replace(/^https?/, "http")}, - {"__rerum.generatedBy": GENERATOR.replace(/^https?/, "https")} - ] - if (GENERATOR.startsWith("http")) { - queryObj["$and"].push({"$or": generatorConditions }) - } - else{ - // It should be a URI, but this can be a fallback. - queryObj["__rerum.generatedBy"] = GENERATOR - } - } - // Only expand with data from a specific creator - if(CREATOR) { - // Need to check http:// and https:// - const creatorConditions = [ - {"creator": CREATOR.replace(/^https?/, "http")}, - {"creator": CREATOR.replace(/^https?/, "https")} - ] - if (CREATOR.startsWith("http")) { - queryObj["$and"].push({"$or": creatorConditions }) - } - else{ - // It should be a URI, but this can be a fallback. - queryObj["creator"] = CREATOR - } - } - - // Get the Annotations targeting this Entity from the db. Remove _id property. - let matches = await db.find(queryObj).toArray() - matches = matches.map(o => { - delete o._id - return o - }) - - // Combine the Annotation bodies with the primitive object - let expandedEntity = JSON.parse(JSON.stringify(primitiveEntity)) - for(const anno of matches){ - const body = anno.body - let keys = Object.keys(body) - if(!keys || keys.length !== 1) return - let key = keys[0] - let val = body[key].value ?? body[key] - expandedEntity[key] = val - } - - return expandedEntity -} - -export { _gog_fragments_from_manuscript, _gog_glosses_from_manuscript, expand } diff --git a/db-controller.js b/db-controller.js index 07aa6f65..ad50925b 100644 --- a/db-controller.js +++ b/db-controller.js @@ -15,7 +15,6 @@ import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from './contr import { bulkCreate, bulkUpdate } from './controllers/bulk.js' import { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } from './controllers/history.js' import { release } from './controllers/release.js' -import { _gog_fragments_from_manuscript, _gog_glosses_from_manuscript, expand } from './controllers/gog.js' export default { index, @@ -41,8 +40,5 @@ export default { sinceHeadRequest, historyHeadRequest, remove, - _gog_glosses_from_manuscript, - _gog_fragments_from_manuscript, idNegotiation, - expand } diff --git a/routes/_gog_fragments_from_manuscript.js b/routes/_gog_fragments_from_manuscript.js deleted file mode 100644 index d1f30193..00000000 --- a/routes/_gog_fragments_from_manuscript.js +++ /dev/null @@ -1,15 +0,0 @@ -import express from 'express' -const router = express.Router() -//This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' -import auth from '../auth/index.js' - -router.route('/') - .post(auth.checkJwt, controller._gog_fragments_from_manuscript) - .all((req, res, next) => { - res.statusMessage = 'Improper request method. Please use POST.' - res.status(405) - next(res) - }) - -export default router diff --git a/routes/_gog_glosses_from_manuscript.js b/routes/_gog_glosses_from_manuscript.js deleted file mode 100644 index e5c57659..00000000 --- a/routes/_gog_glosses_from_manuscript.js +++ /dev/null @@ -1,15 +0,0 @@ -import express from 'express' -const router = express.Router() -//This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' -import auth from '../auth/index.js' - -router.route('/') - .post(auth.checkJwt, controller._gog_glosses_from_manuscript) - .all((req, res, next) => { - res.statusMessage = 'Improper request method. Please use POST.' - res.status(405) - next(res) - }) - -export default router \ No newline at end of file