Skip to content
Closed
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
35 changes: 35 additions & 0 deletions classes/Page/__tests__/exists.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
18 changes: 17 additions & 1 deletion page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
191 changes: 191 additions & 0 deletions utilities/resolutionService.js
Original file line number Diff line number Diff line change
@@ -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<Object>} 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<Object>} 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<Object>} 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()
}
}
Loading