Permalink
Browse files

added web iPhoto importer (bzr revno 117)

  • Loading branch information...
1 parent b596a6c commit 886d213a14ac08fa13c2bff9aec5c95d94acc6ec @natevw committed Oct 31, 2010
View
1 .hgignore
@@ -1,2 +1,3 @@
.DS_Store
.bzr
+.couchapprc
View
1 files-basics/.couchapprc
@@ -1 +0,0 @@
-{}
View
5 files-basics/views/by_photo/map.js
@@ -0,0 +1,5 @@
+function(doc) {
+ if (doc.photo) {
+ emit(doc.photo);
+ }
+};
View
1 photos-basics/.couchapprc
@@ -1 +0,0 @@
-{ "env": { "default": { "db": "http://127.0.0.1:5984/photos" } } }
View
1 webviewer/.couchapprc
@@ -1 +0,0 @@
-{ "env": { "default": { "db": "http://127.0.0.1:5984/photos" } } }
View
16 webviewer/_attachments/index.html
@@ -11,7 +11,7 @@
<h2>Recent photos <img id="sparkline"/></h2>
<div id="thumbnails">(Loading 25 recent photos)</div>
- <p style="clear:both"><a href="_list/thumbnails/by_date?reduce=false&limit=150&descending=true">see all...</a></p>
+ <p style="clear:both" id="see_all"><a href="_list/thumbnails/by_date?reduce=false&limit=150&descending=true">see all...</a></p>
</body>
<script src="vendor/couchapp/loader.js"></script>
<script type="text/javascript" charset="utf-8">
@@ -23,11 +23,15 @@
function renderThumbnails(response) {
var list;
- // TODO: links to image view
- list = $.mustache("<ul>{{#photos}}<li>{{>thumbnail}}</li>{{/photos}}</ul>",
- {photos:response.rows},
- {thumbnail: "<a href='_show/photo_info/{{id}}'><img src='" + app.db.uri + "{{id}}/small.jpg'/></a>"});
- $("#thumbnails").attr('innerHTML', list);
+ if (response.rows.length) {
+ list = $.mustache("<ul>{{#photos}}<li>{{>thumbnail}}</li>{{/photos}}</ul>",
+ {photos:response.rows},
+ {thumbnail: "<a href='_show/photo_info/{{id}}'><img src='" + app.db.uri + "{{id}}/small.jpg'/></a>"});
+ $("#thumbnails").attr('innerHTML', list);
+ } else {
+ $("#thumbnails").attr('innerHTML', "No photos! Perhaps you'd like to <a href='_show/get_importer'>download the iPhoto importer</a> and give it a whirl?");
+ $("#see_all").hide();
+ }
}
//app.view("by_date?reduce=false&descending=true&limit=25", {success: renderThumbnails});
app.view("by_date?reduce=false&descending=true&limit=25", {success: renderThumbnails});
View
15 webviewer/shows/get_importer.js
@@ -0,0 +1,15 @@
+function (doc, req) {
+ var mustache, templates, db_url;
+
+ mustache = require("vendor/couchapp/lib/mustache");
+ // !json templates.iphoto_import
+
+ db_url = ["http:/", req.headers['Host'], req.info.db_name].join('/');
+ return {
+ 'body': mustache.to_html(templates.iphoto_import, {database:db_url}),
+ 'headers': {
+ "Content-Type" : "text/html",
+ "Content-Disposition" : "attachment; filename=ShutterStem iPhoto importer.html",
+ }
+ };
+};
View
435 webviewer/templates/iphoto_import.html
@@ -0,0 +1,435 @@
+<html>
+<head>
+<title>Drop in AlbumData.xml</title>
+</head>
+<body>
+<div id="dropbox_frame" style="display: none;">
+ Your iPhoto library could not be found automatically.
+ Please use Finder to "Show Package Contents" of your iPhoto Library,
+ then drag the AlbumData.xml file into the box below.
+ <div id="dropbox" style="width: 100px; height: 100px; border: 1px dashed blue;"></div>
+</div>
+
+<div id="import_frame" style="display: none;">
+ Your iPhoto library is loaded and ready for import.
+ <button type="button" id="do_import">Do it!</button>
+</div>
+<script>
+
+
+var PHOTO_DB, LOCAL, SMALL_SIZE, MED_SIZE, DATE_OFFSET, library;
+
+PHOTO_DB = '{{ database }}';
+
+LOCAL = "file://localhost";
+SMALL_SIZE = 64;
+MED_SIZE = 512;
+DATE_OFFSET = 978307200; // difference between iPhoto's Cocoa and JavaScript's Unix epoch
+
+function likelyLibraryLocation() {
+ var pathParts;
+
+ pathParts = window.location.pathname.split('/');
+
+ // NOTE: this assumes English localization
+ if (pathParts[1] !== 'Users') {
+ return;
+ }
+ pathParts.length = 3;
+ pathParts.push("Pictures");
+ pathParts.push("iPhoto Library");
+ pathParts.push("AlbumData.xml");
+ return LOCAL + pathParts.join('/');
+}
+
+function loadLibrary(libraryURL) {
+ library = undefined;
+ if (!libraryURL) {
+ loadLibrary.onfailure();
+ return;
+ }
+
+ var req = new XMLHttpRequest();
+ req.open("GET", libraryURL);
+ try {
+ req.send();
+ } catch (e) {
+ loadLibrary.onfailure();
+ }
+
+ req.onreadystatechange = function () {
+ if (this.readyState !== this.DONE) {
+ return;
+ }
+
+ if (this.responseXML) {
+ library = parsePropertyList(this.responseXML);
+ }
+ library ? loadLibrary.onsuccess() : loadLibrary.onfailure();
+ };
+}
+loadLibrary.onsuccess = enableImport;
+loadLibrary.onfailure = enableDropbox;
+loadLibrary(likelyLibraryLocation());
+
+function enableImport() {
+ var dropbox_frame, import_frame, do_import;
+ dropbox_frame = document.getElementById('dropbox_frame');
+ dropbox_frame.style.display = 'none';
+
+ import_frame = document.getElementById('import_frame');
+ import_frame.style.display = 'block';
+
+ do_import = document.getElementById('do_import');
+ do_import.onclick = function () {
+ do_import.style.display = 'none';
+ importAll();
+ }
+}
+
+function enableDropbox() {
+ var dropbox_frame, dropbox;
+
+ dropbox_frame = document.getElementById('dropbox_frame');
+ dropbox_frame.style.display = 'block';
+
+ // dropbox stuff based off of https://developer.mozilla.org/en/using_files_from_web_applications
+ function stopEvent(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+
+ dropbox = document.getElementById("dropbox");
+ dropbox.addEventListener("dragenter", stopEvent, false);
+ dropbox.addEventListener("dragover", stopEvent, false);
+ dropbox.addEventListener("drop", function (e) {
+ stopEvent(e);
+
+ var file = e.dataTransfer.files[0];
+ if (!file || file.type !== 'text/xml' || file.name !== 'AlbumData.xml') {
+ alert("Please drop in AlbumData.xml!");
+ return;
+ }
+ loadLibraryFile(file);
+ }, false);
+}
+
+function loadLibraryFile(file) {
+ var url, r;
+
+ if (window.createObjectURL) {
+ url = window.createObjectURL(file);
+ loadLibrary(url);
+ } else if (window.createBlobURL) { // earlier draft?
+ url = window.createBlobURL(file);
+ loadLibrary(url);
+ } else {
+ r = new FileReader();
+ r.readAsDataURL(file);
+ r.onloadend = function () {
+ url = this.result;
+ loadLibrary(url);
+ }
+ }
+}
+
+function importAll() {
+ var photos, rollsById, albumsByPhoto, flaggedPhotos;
+
+ photos = library['Master Image List'];
+
+ rollsById = library['shutterstem-rollsById'] = {};
+ library['List of Rolls'].forEach(function (roll) {
+ rollsById[roll['RollID']] = roll['RollName'];
+ }, this);
+
+ albumsByPhoto = library['shutterstem-albumsByPhoto'] = {};
+ flaggedPhotos = library['shutterstem-flaggedPhotos'] = {};
+ library['List of Albums'].forEach(function (album) {
+ if (album['Album Type'] === 'Shelf' && album['AlbumName'] === 'Flagged') {
+ // apparently the only way to tell if a photo is flagged is this special album
+ album['KeyList'].forEach(function (photoId) {
+ flaggedPhotos[photoId] = true;
+ }, this);
+ return;
+ } else if (album['Album Type'] !== 'Regular') {
+ return;
+ }
+ album['KeyList'].forEach(function (photoId) {
+ if (!albumsByPhoto[photoId]) {
+ albumsByPhoto[photoId] = {};
+ }
+ albumsByPhoto[photoId][album['AlbumName']] = true;
+ }, this);
+ }, this);
+
+ function serialImport() {
+ var image, canvas, ctx, photoIds, i, len;
+
+ image = new Image();
+ document.body.appendChild(image);
+ canvas = document.createElement('canvas');
+ ctx = canvas.getContext('2d');
+
+
+ photoIds = Object.keys(photos);
+ i = 0;
+ len = photoIds.length;
+
+ function importCurrent() {
+ if (i >= len) {
+ return;
+ }
+
+ var photoId, photo;
+ photoId = photoIds[i];
+ photo = photos[photoId];
+ photo['shutterstem-imageListKey'] = photoId;
+
+ /* NOTE: WebKit currently has some nasty memory leaks with images,
+ apparently never releasing the data once set. See:
+ https://bugs.webkit.org/show_bug.cgi?id=23372
+ https://bugs.webkit.org/show_bug.cgi?id=31253
+ http://waldheinz.de/2010/06/webkit-leaks-data-uris/ (nice test case) */
+ image.src = LOCAL + photo['ThumbPath']; // just load thumbnail to leak memory slower (and import faster!)
+ image.onerror = next;
+ image.onload = function () {
+ importPhoto(photo, image, {canvas:canvas, ctx:ctx});
+ next();
+ }
+ }
+ function next() {
+ if (i < len) {
+ importCurrent();
+ i += 1;
+ } else {
+ image.style.display = 'none';
+ }
+ }
+ next();
+ }
+ serialImport();
+}
+
+function photoMetadata(photo) {
+ var metadata, photoId, rollsById, albumsByPhoto, flaggedPhotos, value;
+
+ metadata = {};
+ photoId = photo['shutterstem-imageListKey'];
+ rollsById = library['shutterstem-rollsById'];
+ albumsByPhoto = library['shutterstem-albumsByPhoto'];
+ flaggedPhotos = library['shutterstem-flaggedPhotos'];
+
+ value = DATE_OFFSET + photo['DateAsTimerInterval'];
+ value = new Date(value * 1000);
+ metadata.timestamp = value.toISOString();
+
+ if (photo['Rating']) {
+ metadata.rating = photo['Rating'];
+ }
+
+ value = photo['ImagePath'].split('/').slice(-1)[0]; // full filename (e.g. "IMG_0146.JPG.jpeg")
+ metadata.image = {};
+ metadata.image.original_filename = value;
+ metadata.image.aspect = photo['Aspect Ratio'];
+
+ value = value.split('.').slice(0, -1).join('.'); // iPhoto's default filename caption (filename without extension)
+ if (photo['Caption'] && photo['Caption'] !== value) {
+ metadata.title = photo['Caption'];
+ }
+
+ if (photo['Comment']) {
+ metadata.description = photo['Comment'];
+ }
+
+ if (photo['latitude'] !== undefined && photo['longitude'] !== undefined) {
+ metadata.location = {};
+ metadata.location.latitude = photo['latitude'];
+ metadata.location.longitude = photo['longitude'];
+ }
+
+ // TODO: document the following new fields (iPhoto, starred, folder, sets, tags, face/notes)
+
+ metadata.iPhoto = {};
+ metadata.iPhoto.GUID = photo['GUID'];
+ //metadata.iPhoto.library = library['Archive Path'];
+ metadata.iPhoto.photoID = photoId;
+
+ if (flaggedPhotos[photoId]) {
+ metadata.starred = true;
+ }
+
+ // NOTE: this collapses distinct rolls with the same name
+ metadata.folder = rollsById[photo['Roll']];
+
+ if (albumsByPhoto[photoId]) {
+ metadata.sets = Object.keys(albumsByPhoto[photoId]);
+ }
+
+ if (photo['Keywords']) {
+ value = {};
+ photo['Keywords'].forEach(function (keywordId) {
+ var keyword = library['List of Keywords'][keywordId];
+ value[keyword] = true;
+ }, this);
+ metadata.tags = Object.keys(value);
+ }
+
+ if (photo['Faces']) {
+ metadata.annotations = [];
+ photo['Faces'].forEach(function (faceNote) {
+ var face = library['List of Faces'][faceNote['face key']];
+ if (!face) {
+ // apparently iPhoto always doesn't write these quite as expected
+ return;
+ }
+
+ // iPhoto's bounds are {{x, y}, {w, h}} with origin at lower-left
+ value = faceNote['rectangle'].replace(/\{/g, '[').replace(/\}/g, ']');
+ value = JSON.parse(value);
+ value = {x:value[0][0], y:value[0][1], w:value[1][0], h:value[1][1]};
+ value.y = 1 - (value.y + value.h); // flip y-origin
+
+ metadata.annotations.push({rect:value, name:face['name']});
+ }, this);
+ }
+
+ return metadata;
+}
+
+function thumbnailAttachments(image, canvas, ctx, displayImage) {
+ var attachments, imageSize, aspectRatio, smallData, mediumData, req;
+
+ attachments = {};
+ imageSize = Math.max(image.naturalWidth, image.naturalHeight);
+ aspectRatio = image.naturalWidth / image.naturalHeight;
+ function thumbWithSize(size) {
+ canvas.width = Math.min(size, size * aspectRatio);
+ canvas.height = Math.min(size, size / aspectRatio);
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
+ return canvas.toDataURL("image/jpeg", 0.9);
+ }
+ function attachmentFromDataURL(url) {
+ var parts, type, data;
+
+ parts = url.split(',');
+ data = parts[1];
+
+ parts = parts[0].split(';');
+ if (parts[1] !== 'base64') {
+ return;
+ }
+ type = parts[0];
+
+ return {content_type:type, data:data};
+ }
+
+ if (imageSize > SMALL_SIZE) {
+ smallData = thumbWithSize(SMALL_SIZE);
+ if (imageSize >= MED_SIZE) {
+ mediumData = thumbWithSize(MED_SIZE);
+ } else {
+ // NOTE: leaving this means medium.jpg not always 512 pixels!
+ mediumData = thumbWithSize(imageSize);
+ }
+ } else {
+ smallData = thumbWithSize(imageSize);
+ }
+
+ attachments['small.jpg'] = attachmentFromDataURL(smallData);
+ if (mediumData) {
+ attachments['medium.jpg'] = attachmentFromDataURL(mediumData);
+ }
+
+ if (displayImage) {
+ displayImage.src = mediumData || smallData;
+ }
+
+ return attachments;
+};
+
+function importPhoto(photo, image, scope) {
+ if (photo['MediaType'] !== 'Image') {
+ return;
+ }
+
+ var doc, req;
+
+ doc = photoMetadata(photo);
+ doc['_attachments'] = thumbnailAttachments(image, scope.canvas, scope.ctx, scope.displayImage);
+
+ req = new XMLHttpRequest();
+ // NOTE: using POST method is deprecated because proxies may resend, but for localhost it's likely fine...
+ req.open('POST', PHOTO_DB + '/');
+ req.setRequestHeader("Content-Type", "application/json");
+ req.send(JSON.stringify(doc));
+}
+
+
+
+function parsePropertyList(doc) {
+ if (!doc.firstChild || doc.firstChild.nodeName !== 'plist') {
+ return null;
+ }
+
+ function internalParse(elem) {
+ var result;
+
+ // see http://en.wikipedia.org/w/index.php?title=Property_list&oldid=391219006#Mac_OS_X
+ if (elem.nodeName === 'dict') {
+ result = {};
+ var key, value;
+ Array.prototype.forEach.call(elem.childNodes, function (elem) {
+ if (elem.nodeName === 'key') {
+ key = elem.firstChild.nodeValue;
+ } else {
+ value = internalParse(elem);
+ if (value !== undefined) {
+ result[key] = value;
+ key = value = undefined;
+ }
+ }
+ }, this);
+ } else if (elem.nodeName === 'array') {
+ result = [];
+ Array.prototype.forEach.call(elem.childNodes, function (child) {
+ var value = internalParse(child);
+ if (value !== undefined) {
+ result.push(value);
+ }
+ }, this);
+ } else if (elem.nodeName === 'date') {
+ // NOTE: requires ES5 date parsing support
+ result = new Date(elem.firstChild.nodeValue);
+ } else if (elem.nodeName === 'data') {
+ result = elem.firstChild ? elem.firstChild.nodeValue : "";
+ result = "data:application/octet-stream;base64," + result;
+ } else if (elem.nodeName === 'string') {
+ result = elem.firstChild ? elem.firstChild.nodeValue : "";
+ } else if (elem.nodeName === 'integer') {
+ result = parseInt(elem.firstChild.nodeValue);
+ } else if (elem.nodeName === 'real') {
+ result = parseFloat(elem.firstChild.nodeValue);
+ } else if (elem.nodeName === 'true') {
+ result = true;
+ } else if (elem.nodeName === 'false') {
+ result = false;
+ }
+
+ return result;
+ }
+
+ var elem, result;
+ elem = doc.firstChild.firstChild;
+ while (elem) {
+ result = internalParse(elem);
+ if (result !== undefined) {
+ return result;
+ }
+ elem = elem.nextSibling;
+ }
+}
+
+</script>
+</body>
+</html>
View
4 webviewer/templates/photo_info.html
@@ -52,8 +52,8 @@
{{#thumbnail}}<img src="{{medium}}"/><br>{{/thumbnail}}
{{name}} @ {{timestamp}}
- <p style="display: none">
- Debugging garbage:<br/>
+ <p style="display: block">
+ Metadata document:<br/>
{{source}}
</p>
</body>

0 comments on commit 886d213

Please sign in to comment.