Skip to content

Commit d6e4ac7

Browse files
authored
Merge pull request #265 from CenterForDigitalHumanities/create-new-project-from-one-image
Create New Project from One Image
2 parents 36ed128 + b1dd2ba commit d6e4ac7

4 files changed

Lines changed: 183 additions & 4 deletions

File tree

classes/Project/ProjectFactory.js

Lines changed: 147 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import User from "../User/User.js"
44
import Layer from "../Layer/Layer.js"
55
import dbDriver from "../../database/driver.js"
66
import vault from "../../utilities/vault.js"
7+
import imageSize from 'image-size';
8+
import mime from 'mime-types';
79

810
const database = new dbDriver("mongo")
911

@@ -150,6 +152,139 @@ export default class ProjectFactory {
150152
})
151153
}
152154

155+
/**
156+
* Creates a new manifest from given image url and project label.
157+
* @param {string} imageUrl - URL of the image to be used in the project.
158+
* @param {string} label - Label for the project.
159+
* @returns {Object} - Returns the created project object.
160+
*/
161+
162+
static async getImageDimensions(imgUrl) {
163+
try {
164+
const response = await fetch(imgUrl)
165+
if (!response.ok) {
166+
throw {
167+
status: response.status,
168+
message: `Failed to fetch image: ${response.statusText}`
169+
}
170+
}
171+
const arrayBuffer = await response.arrayBuffer()
172+
const buffer = Buffer.from(arrayBuffer)
173+
const dimensions = imageSize(buffer)
174+
return {
175+
width: dimensions.width,
176+
height: dimensions.height
177+
}
178+
} catch (err) {
179+
console.error("Error fetching image dimensions:", err.message)
180+
return
181+
}
182+
}
183+
184+
static async DBObjectFromImage(manifest) {
185+
if (!manifest) {
186+
throw {
187+
status: 404,
188+
message: err.message ?? "No manifest found. Cannot process empty object"
189+
}
190+
}
191+
const _id = manifest.id.split('/').slice(-2, -1)[0]
192+
const now = Date.now().toString().slice(-6)
193+
const label = ProjectFactory.getLabelAsString(manifest.label)
194+
const metadata = manifest.metadata ?? []
195+
const layer = Layer.build( _id, `First Layer - ${label}`, manifest.items )
196+
197+
const firstPage = layer.pages[0]?.id.split('/').pop() ?? true
198+
199+
return {
200+
_id,
201+
label,
202+
metadata,
203+
manifest: [ manifest.id ],
204+
layers: [ layer.asProjectLayer() ],
205+
tools: this.tools,
206+
_createdAt: now,
207+
_modifiedAt: -1,
208+
_lastModified: firstPage,
209+
}
210+
}
211+
212+
static async createManifestFromImage(imageURL, projectLabel, creator) {
213+
if (!imageURL) {
214+
throw {
215+
status: 404,
216+
message: "No image found. Cannot process further."
217+
}
218+
}
219+
const _id = database.reserveId()
220+
const now = Date.now().toString().slice(-6)
221+
const label = projectLabel ?? now
222+
const dimensions = await this.getImageDimensions(imageURL)
223+
224+
const canvasLayout = {
225+
id: `${process.env.TPENSTATIC}/${_id}/canvas-1.json`,
226+
type: "Canvas",
227+
label: { "none": [`${label} Page 1`] },
228+
width: dimensions.width,
229+
height: dimensions.height,
230+
items: [
231+
{
232+
id: `${process.env.TPENSTATIC}/${_id}/contentPage.json`,
233+
type: "AnnotationPage",
234+
items: [
235+
{
236+
id: `${process.env.TPENSTATIC}/${_id}/content.json`,
237+
type: "Annotation",
238+
motivation: "painting",
239+
body: {
240+
id: imageURL,
241+
type: "Image",
242+
format: mime.lookup(imageURL) || "image/jpeg",
243+
width: dimensions.width,
244+
height: dimensions.height
245+
},
246+
target: `${process.env.TPENSTATIC}/${_id}/canvas-1.json`
247+
}
248+
]
249+
}
250+
]
251+
}
252+
253+
const projectManifest = {
254+
"@context": "http://iiif.io/api/presentation/3/context.json",
255+
id: `${process.env.TPENSTATIC}/${_id}/manifest.json`,
256+
type: "Manifest",
257+
label: { "none": [label] },
258+
items: [ canvasLayout ]
259+
}
260+
261+
const projectCanvas = {
262+
"@context": "http://iiif.io/api/presentation/3/context.json",
263+
...canvasLayout
264+
}
265+
266+
await this.uploadFileToGitHub(projectManifest, _id)
267+
await this.uploadFileToGitHub(projectCanvas, _id)
268+
269+
return await ProjectFactory.DBObjectFromImage(projectManifest)
270+
.then(async (project) => {
271+
const projectObj = new Project()
272+
const group = await Group.createNewGroup(creator,
273+
{
274+
label: project.label ?? project.title ?? `Project ${new Date().toLocaleDateString()}`,
275+
members: { [creator]: { roles: [] } }
276+
})
277+
.then((group) => group._id)
278+
return await projectObj.create({ ...project, creator, group })
279+
})
280+
.catch((err) => {
281+
throw {
282+
status: err.status ?? 500,
283+
message: err.message ?? "Internal Server Error"
284+
}
285+
})
286+
}
287+
153288
/**
154289
* Convert the Project.data into an Object ready for consumption by a TPEN interface,
155290
* especially the GET /project/:id endpoint.
@@ -321,7 +456,8 @@ export default class ProjectFactory {
321456
* - Uploads the file using the GitHub API, including the correct commit message and SHA for updates.
322457
*/
323458
static async uploadFileToGitHub(manifest, projectId) {
324-
const manifestUrl = `https://api.github.com/repos/${process.env.REPO_OWNER}/${process.env.REPO_NAME}/contents/${projectId}/manifest.json`
459+
const fileName = manifest?.id?.split('/').pop() ?? 'manifest.json'
460+
const manifestUrl = `https://api.github.com/repos/${process.env.REPO_OWNER}/${process.env.REPO_NAME}/contents/${projectId}/${fileName}`
325461
const token = process.env.GITHUB_TOKEN
326462

327463
try {
@@ -339,23 +475,30 @@ export default class ProjectFactory {
339475
sha = fileData.sha
340476
}
341477

342-
await fetch(manifestUrl, {
478+
const putResponse = await fetch(manifestUrl, {
343479
method: 'PUT',
344480
headers: {
345481
'Authorization': `token ${token}`,
346482
'Accept': 'application/vnd.github.v3+json',
347483
'Content-Type': 'application/json',
348484
},
349485
body: JSON.stringify({
350-
message: sha ? `Updated ${projectId}/manifest.json` : `Created ${projectId}/manifest.json`,
486+
message: sha ? `Updated ${projectId}/${fileName}` : `Created ${projectId}/${fileName}`,
351487
content: Buffer.from(JSON.stringify(manifest)).toString('base64'),
352488
branch: process.env.BRANCH,
353489
...(sha && { sha }),
354490
})
355491
})
356492

493+
if (!putResponse.ok) {
494+
const errText = await putResponse.text()
495+
throw new Error(`GitHub upload failed: ${putResponse.status} - ${errText}`)
496+
}
497+
498+
return await putResponse.json()
499+
357500
} catch (error) {
358-
console.error(`Failed to upload ${projectId}/manifest.json:`, error)
501+
console.error(`Failed to upload ${projectId}/${fileName}:`, error)
359502
}
360503
}
361504

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"express-oauth2-jwt-bearer": "^1.6.0",
4646
"express-urlrewrite": "^2.0.3",
4747
"http-errors": "^2.0.0",
48+
"image-size": "^2.0.2",
4849
"jsdom": "^26.0.0",
4950
"manifesto.js": "^4.2.21",
5051
"mariadb": "^3.4.0",

project/projectCreateRouter.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,26 @@ router.route("/import").post(auth0Middleware(), async (req, res) => {
6767
respondWithError(res, 405, "Improper request method. Use POST instead")
6868
})
6969

70+
router.route("/import-image").post(auth0Middleware(), async (req, res) => {
71+
const user = req.user
72+
if (!user?.agent) return respondWithError(res, 401, "Unauthenticated user")
73+
try {
74+
const { imageUrl, projectLabel } = req.body
75+
if (!imageUrl || !projectLabel) {
76+
return respondWithError(res, 400, "Image URL and project label are required")
77+
}
78+
const project = await ProjectFactory.createManifestFromImage(imageUrl, projectLabel, user._id)
79+
res.status(201).json(project)
80+
} catch (error) {
81+
respondWithError(
82+
res,
83+
error.status ?? error.code ?? 500,
84+
error.message ?? "Unknown server error"
85+
)
86+
}
87+
}
88+
).all((_, res) => {
89+
respondWithError(res, 405, "Improper request method. Use POST instead")
90+
})
91+
7092
export default router

0 commit comments

Comments
 (0)