diff --git a/classes/Page/__tests__/exists.test.js b/classes/Page/__tests__/exists.test.js index 6496573c..b0eef945 100644 --- a/classes/Page/__tests__/exists.test.js +++ b/classes/Page/__tests__/exists.test.js @@ -26,3 +26,38 @@ describe('Page Class looks how we expect it to. #Page_exists_unit', () => { expect(() => new Page("layerID", { id: "canvasID", label: "Canvas Label", target: null })).toThrow() // Invalid canvas target }) }) + +describe('Page Resolution Support #resolution', () => { + it('should support pages with resolved items', () => { + const pageWithResolvedItems = { + id: "test-page-id", + label: "Test Page", + items: [ + { + id: "test-item-1", + body: [{ type: "TextualBody", value: "Resolved content" }], + creator: "https://example.org/user/1" + } + ] + } + const page = new Page("test-layer", pageWithResolvedItems) + expect(page).toHaveProperty('items') + expect(Array.isArray(page.items)).toBe(true) + expect(page.items.length).toBe(1) + }) + + it('should handle pages with both resolved and unresolved items', () => { + const mixedPage = { + id: "test-page-mixed", + label: "Mixed Page", + items: [ + { id: "unresolved-item" }, + { id: "resolved-item", body: [{ type: "TextualBody", value: "Content" }] } + ] + } + const page = new Page("test-layer", mixedPage) + expect(page.items.length).toBe(2) + expect(page.items[0]).toHaveProperty('id') + expect(page.items[1]).toHaveProperty('body') + }) +}) diff --git a/page/index.js b/page/index.js index f83ff5e0..2ec44b04 100644 --- a/page/index.js +++ b/page/index.js @@ -8,6 +8,7 @@ let router = express.Router({ mergeParams: true }) import Project from '../classes/Project/Project.js' import Line from '../classes/Line/Line.js' import { findPageById, respondWithError, getLayerContainingPage, updatePageAndProject, handleVersionConflict } from '../utilities/shared.js' +import { buildResolvedPage } from '../utilities/resolutionService.js' router.use( cors(common_cors) @@ -19,14 +20,29 @@ router.use( router.route('/:pageId') .get(async (req, res) => { const { projectId, pageId } = req.params + const { resolved } = req.query // Capture resolved parameter try { const page = await findPageById(pageId, projectId, true) if (!page) { respondWithError(res, 404, 'No page found with that ID.') return } + + // NEW: Handle resolved request + if (resolved === 'true') { + try { + const resolvedPage = await buildResolvedPage(page, projectId) + res.status(200).json(resolvedPage) + return + } catch (resolutionError) { + console.error('Page resolution failed:', resolutionError) + respondWithError(res, 500, 'Failed to resolve page content') + return + } + } + + // EXISTING: Original behavior preserved for backward compatibility if (page.id?.startsWith(process.env.RERUMIDPREFIX)) { - // If the page is a RERUM document, we need to fetch it from the server res.status(200).json(page) return } diff --git a/utilities/resolutionService.js b/utilities/resolutionService.js new file mode 100644 index 00000000..28cb17a2 --- /dev/null +++ b/utilities/resolutionService.js @@ -0,0 +1,191 @@ +/** + * Resolution Service for fully resolving referenced objects in TPEN pages + * Integrates with TinyPEN/RERUM for object resolution + * + * @author AI Assistant + */ + +import dbDriver from '../database/driver.js' + +/** + * Resolve an annotation item from RERUM/TinyPEN + * @param {string} itemId - The ID of the annotation item to resolve + * @returns {Promise} The resolved annotation item + */ +export async function resolveAnnotationItem(itemId) { + try { + // Use TinyPEN controller for resolution + const tinyController = new dbDriver("tiny") + + // Normalize ID format + const normalizedId = normalizeId(itemId) + + // Use fetch to get the item directly from RERUM/TinyPEN + const resolvedItem = await fetch(normalizedId, { + method: 'GET', + headers: { + 'Accept': 'application/json; charset=utf-8' + } + }) + .then(resp => { + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`) + } + return resp.json() + }) + .catch(err => { + console.error(`Fetch error for ${normalizedId}:`, err) + return null + }) + + // Return resolved item or fallback to original with error + if (resolvedItem && Object.keys(resolvedItem).length > 1) { + return resolvedItem + } else { + // Graceful degradation - return original with error flag + return { + id: itemId, + error: 'Resolution failed - item not found or empty response', + original: itemId + } + } + } catch (error) { + console.error(`Failed to resolve annotation item: ${itemId}`, error) + // Graceful degradation + return { + id: itemId, + error: 'Resolution failed', + original: itemId, + details: error.message + } + } +} + +/** + * Resolve an annotation collection from RERUM/TinyPEN + * @param {string} collectionId - The ID of the annotation collection to resolve + * @returns {Promise} The resolved annotation collection + */ +export async function resolveAnnotationCollection(collectionId) { + try { + // Use direct fetch to resolve the collection + const normalizedId = normalizeId(collectionId) + const resolvedCollection = await fetch(normalizedId, { + method: 'GET', + headers: { + 'Accept': 'application/json; charset=utf-8' + } + }) + .then(resp => { + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`) + } + return resp.json() + }) + .catch(err => { + console.error(`Fetch error for collection ${normalizedId}:`, err) + return null + }) + + if (resolvedCollection && Object.keys(resolvedCollection).length > 1) { + return resolvedCollection + } else { + return { + id: collectionId, + type: "AnnotationCollection", + error: 'Resolution failed - collection not found', + original: collectionId + } + } + } catch (error) { + console.error(`Failed to resolve annotation collection: ${collectionId}`, error) + return { + id: collectionId, + type: "AnnotationCollection", + error: 'Resolution failed', + original: collectionId, + details: error.message + } + } +} + +/** + * Build a fully resolved page with all references embedded + * @param {Object} page - The original page object + * @param {string} projectId - The project ID for context + * @returns {Promise} The fully resolved page + */ +export async function buildResolvedPage(page, projectId) { + const resolvedPage = { + '@context': page['@context'] || 'http://www.w3.org/ns/anno.jsonld', + id: page.id, + type: page.type || 'AnnotationPage', + label: page.label ? { none: [page.label] } : { none: [] }, + target: page.target, + prev: page.prev ?? null, + next: page.next ?? null, + creator: page.creator ?? null + } + + // Resolve partOf collections if they exist + if (page.partOf) { + try { + // Handle both string and object formats + const collectionId = typeof page.partOf === 'string' ? page.partOf : page.partOf.id + resolvedPage.partOf = [await resolveAnnotationCollection(collectionId)] + } catch (error) { + resolvedPage.partOf = [{ + id: page.partOf.id || page.partOf, + type: "AnnotationCollection", + error: "Failed to resolve collection" + }] + } + } + + // Resolve items in parallel for performance + if (page.items && page.items.length > 0) { + const resolvedItems = await Promise.allSettled( + page.items.map(async (item) => { + const itemId = item.id || item + return await resolveAnnotationItem(itemId) + }) + ) + + resolvedPage.items = resolvedItems.map(result => + result.status === 'fulfilled' ? result.value : { + error: 'Resolution failed', + original: result.reason + } + ) + } else { + resolvedPage.items = [] + } + + return resolvedPage +} + +/** + * Normalize ID format for RERUM/TinyPEN + * @param {string} id - The ID to normalize + * @returns {string} The normalized ID + */ +function normalizeId(id) { + if (!id) return id + + // Handle RERUM ID prefix - use the actual RERUM dev store + const RERUM_PREFIX = 'https://devstore.rerum.io/v1/id/' + + if (id.startsWith('http')) { + // If it's already a full URL, check if it needs to be converted to RERUM + if (id.includes('localhost:3001')) { + // Convert localhost URLs to RERUM URLs + const idPart = id.split('/').pop() + return RERUM_PREFIX + idPart + } + return id + } else if (id.startsWith(RERUM_PREFIX)) { + return id + } else { + return RERUM_PREFIX + id.split('/').pop() + } +} \ No newline at end of file