Skip to content

Commit 8f64829

Browse files
authored
Create Project with Multiple Images (#373)
1 parent 4e0b682 commit 8f64829

2 files changed

Lines changed: 127 additions & 95 deletions

File tree

classes/Project/ProjectFactory.js

Lines changed: 120 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -586,18 +586,14 @@ export default class ProjectFactory {
586586
}
587587
}
588588

589-
static async createManifestFromImage(imageURL, projectLabel, creator) {
590-
if (!imageURL) {
589+
static async createManifestFromImage(imageURLs, projectLabel, creator) {
590+
if (!imageURLs || imageURLs.length === 0) {
591591
throw {
592592
status: 404,
593593
message: "No image found. Cannot process further."
594594
}
595595
}
596596

597-
let isIIFImage = false
598-
let IIIFServiceParts = imageURL.split('/').reverse()
599-
let IIIFServiceJson = null
600-
601597
function isValidIIIFRegion(region) {
602598
return (
603599
region === "full" ||
@@ -627,7 +623,7 @@ export default class ProjectFactory {
627623
function isValidIIIFRotation(rotation) {
628624
return (
629625
/^\d+(\.\d+)?$/.test(rotation) ||
630-
size.startsWith("!") && /^\d+(\.\d+)?$/.test(rotation)
626+
rotation.startsWith("!") && /^\d+(\.\d+)?$/.test(rotation)
631627
)
632628
}
633629

@@ -640,89 +636,97 @@ export default class ProjectFactory {
640636
)
641637
}
642638

643-
let IIIFServiceURL = IIIFServiceParts.slice(4).reverse().join("/")
644-
645-
if (isValidIIIFQuality(IIIFServiceParts[0].split(".")[0]) && isValidIIIFRotation(IIIFServiceParts[1]) && isValidIIIFSize(IIIFServiceParts[2]) && isValidIIIFRegion(IIIFServiceParts[3])) {
646-
await fetch(`${IIIFServiceURL}/info.json`)
647-
.then(response => {
648-
if (!response.ok) {
649-
throw new Error(`Failed to fetch IIIF info: ${response.statusText}`)
650-
}
651-
return response.json()
652-
})
653-
.then(info => {
654-
if (info?.protocol === "http://iiif.io/api/image") {
655-
isIIFImage = true
656-
IIIFServiceJson = info
657-
}
658-
})
659-
.catch(err => {
660-
console.error("Error fetching IIIF info:", err.message)
661-
throw {
662-
status: 500,
663-
message: "Failed to fetch IIIF info"
664-
}
665-
})
666-
}
667-
668-
const _id = database.reserveId()
669639
const now = Date.now().toString().slice(-6)
670640
const label = projectLabel ?? now
671-
const dimensions = await this.getImageDimensions(imageURL)
672-
673-
const canvasLayout = {
674-
id: `${process.env.TPENSTATIC}/${_id}/canvas-1.json`,
675-
type: "Canvas",
676-
label: { "none": [`${label} Page 1`] },
677-
width: dimensions.width,
678-
height: dimensions.height,
679-
items: [
680-
{
681-
id: `${process.env.TPENSTATIC}/${_id}/contentPage.json`,
682-
type: "AnnotationPage",
683-
items: [
684-
{
685-
id: `${process.env.TPENSTATIC}/${_id}/content.json`,
686-
type: "Annotation",
687-
motivation: "painting",
688-
body: {
689-
id: imageURL,
690-
type: "Image",
691-
format: mime.lookup(imageURL) || "image/jpeg",
692-
width: dimensions.width,
693-
height: dimensions.height,
694-
...(isIIFImage && {
695-
service: [{
696-
id: IIIFServiceURL,
697-
type: IIIFServiceJson?.type,
698-
profile: IIIFServiceJson?.profile,
699-
}]
700-
})
701-
},
702-
target: `${process.env.TPENSTATIC}/${_id}/canvas-1.json`
703-
}
704-
]
705-
}
706-
],
707-
creator: await fetchUserAgent(creator),
708-
}
709-
641+
const _id = database.reserveId()
642+
710643
const projectManifest = {
711644
"@context": "http://iiif.io/api/presentation/3/context.json",
712645
id: `${process.env.TPENSTATIC}/${_id}/manifest.json`,
713646
type: "Manifest",
714647
label: { "none": [label] },
715-
items: [canvasLayout],
648+
items: [],
716649
creator: await fetchUserAgent(creator),
717650
}
718651

719-
const projectCanvas = {
720-
"@context": "http://iiif.io/api/presentation/3/context.json",
721-
...canvasLayout
652+
for (let index = 0; index < imageURLs.length; index++) {
653+
const imageURL = imageURLs[index]
654+
let isIIFImage = false
655+
let IIIFServiceParts = imageURL.split('/').reverse()
656+
let IIIFServiceJson = null
657+
let IIIFServiceURL = IIIFServiceParts.slice(4).reverse().join("/")
658+
659+
if (
660+
isValidIIIFQuality(IIIFServiceParts[0].split(".")[0]) &&
661+
isValidIIIFRotation(IIIFServiceParts[1]) &&
662+
isValidIIIFSize(IIIFServiceParts[2]) &&
663+
isValidIIIFRegion(IIIFServiceParts[3])
664+
) {
665+
try {
666+
const response = await fetch(`${IIIFServiceURL}/info.json`)
667+
if (response.ok) {
668+
const info = await response.json()
669+
if (info?.protocol === "http://iiif.io/api/image") {
670+
isIIFImage = true
671+
IIIFServiceJson = info
672+
}
673+
} else {
674+
console.warn(`Failed to fetch IIIF info for image ${index + 1}`)
675+
}
676+
} catch (err) {
677+
console.error("Error fetching IIIF info:", err.message)
678+
}
679+
}
680+
681+
const dimensions = await this.getImageDimensions(imageURL)
682+
683+
const canvasLayout = {
684+
id: `${process.env.TPENSTATIC}/${_id}/canvas-${index + 1}.json`,
685+
type: "Canvas",
686+
label: { "none": [`${label} Page ${index + 1}`] },
687+
width: dimensions.width,
688+
height: dimensions.height,
689+
items: [
690+
{
691+
id: `${process.env.TPENSTATIC}/${_id}/contentPage.json`,
692+
type: "AnnotationPage",
693+
items: [
694+
{
695+
id: `${process.env.TPENSTATIC}/${_id}/content.json`,
696+
type: "Annotation",
697+
motivation: "painting",
698+
body: {
699+
id: imageURL,
700+
type: "Image",
701+
format: mime.lookup(imageURL) || "image/jpeg",
702+
width: dimensions.width,
703+
height: dimensions.height,
704+
...(isIIFImage && {
705+
service: [{
706+
id: IIIFServiceURL,
707+
type: IIIFServiceJson?.type,
708+
profile: IIIFServiceJson?.profile,
709+
}]
710+
})
711+
},
712+
target: `${process.env.TPENSTATIC}/${_id}/canvas-${index + 1}.json`
713+
}
714+
]
715+
}
716+
],
717+
creator: await fetchUserAgent(creator),
718+
}
719+
720+
projectManifest.items.push(canvasLayout)
721+
const projectCanvas = {
722+
"@context": "http://iiif.io/api/presentation/3/context.json",
723+
...canvasLayout
724+
}
725+
726+
await this.uploadFileToGitHub(projectCanvas, _id)
722727
}
723728

724729
await this.uploadFileToGitHub(projectManifest, _id)
725-
await this.uploadFileToGitHub(projectCanvas, _id)
726730

727731
return await ProjectFactory.DBObjectFromImage(projectManifest, creator)
728732
.then(async (project) => {
@@ -937,22 +941,22 @@ export default class ProjectFactory {
937941
const manifestUrl = `https://api.github.com/repos/${process.env.REPO_OWNER}/${process.env.REPO_NAME}/contents/${projectId}/${fileName}`
938942
const token = process.env.GITHUB_TOKEN
939943

940-
try {
941-
let sha = null
942-
943-
const getResponse = await fetch(manifestUrl, {
944+
async function getFileSha() {
945+
const res = await fetch(manifestUrl, {
944946
headers: {
945947
'Authorization': `token ${token}`,
946948
'Accept': 'application/vnd.github.v3+json',
947949
},
948950
})
949-
950-
if (getResponse.ok) {
951-
const fileData = await getResponse.json()
952-
sha = fileData.sha
951+
if (res.ok) {
952+
const data = await res.json()
953+
return data.sha
953954
}
955+
return null
956+
}
954957

955-
const putResponse = await fetch(manifestUrl, {
958+
async function uploadWithSha(sha = null) {
959+
const res = await fetch(manifestUrl, {
956960
method: 'PUT',
957961
headers: {
958962
'Authorization': `token ${token}`,
@@ -961,19 +965,44 @@ export default class ProjectFactory {
961965
},
962966
body: JSON.stringify({
963967
message: sha ? `Updated ${projectId}/${fileName}` : `Created ${projectId}/${fileName}`,
964-
content: Buffer.from(JSON.stringify(manifest)).toString('base64'),
968+
content: Buffer.from(JSON.stringify(manifest, null, 2)).toString('base64'),
965969
branch: process.env.BRANCH,
966970
...(sha && { sha }),
967971
})
968972
})
973+
return res
974+
}
975+
976+
async function delay(ms) {
977+
return new Promise(resolve => setTimeout(resolve, ms))
978+
}
979+
980+
try {
981+
let sha = await getFileSha()
982+
let response
983+
const maxRetries = 3
969984

970-
if (!putResponse.ok) {
971-
const errText = await putResponse.text()
972-
throw new Error(`GitHub upload failed: ${putResponse.status} - ${errText}`)
985+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
986+
response = await uploadWithSha(sha)
987+
988+
if (response.ok) break
989+
990+
if (response.status === 409) {
991+
await delay(500 * attempt)
992+
sha = await getFileSha()
993+
continue
994+
}
995+
996+
const errText = await response.text()
997+
throw new Error(`GitHub upload failed: ${response.status} - ${errText}`)
973998
}
974999

975-
return await putResponse.json()
1000+
if (!response.ok) {
1001+
const errText = await response.text()
1002+
throw new Error(`GitHub upload failed after ${maxRetries} attempts: ${errText}`)
1003+
}
9761004

1005+
return await response.json()
9771006
} catch (error) {
9781007
console.error(`Failed to upload ${projectId}/${fileName}:`, error)
9791008
}

project/projectCreateRouter.js

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,15 @@ router.route("/import-image").post(auth0Middleware(), async (req, res) => {
111111
const user = req.user
112112
if (!user?.agent) return respondWithError(res, 401, "Unauthenticated user")
113113
try {
114-
const { imageUrl, projectLabel } = req.body
115-
if (!imageUrl || !projectLabel) {
116-
return respondWithError(res, 400, "Image URL and project label are required")
114+
const { imageUrls, projectLabel } = req.body
115+
if (!imageUrls || !projectLabel) {
116+
return respondWithError(res, 400, "Image URL/URLs and project label are required")
117+
}
118+
if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
119+
return respondWithError(res, 400, "Image URLs must be a non-empty array")
117120
}
118121
if (isSuspiciousValueString(projectLabel, true)) return respondWithError(res, 400, "Suspicious project label will not be processed.")
119-
const project = await ProjectFactory.createManifestFromImage(imageUrl, projectLabel, user.agent.split('/').pop())
122+
const project = await ProjectFactory.createManifestFromImage(imageUrls, projectLabel, user.agent.split('/').pop())
120123
res.status(201).json(project)
121124
} catch (error) {
122125
console.log("Create project from image error")

0 commit comments

Comments
 (0)