From 320634599983baac944f65b0f98e6fbfb65ee29f Mon Sep 17 00:00:00 2001 From: Timothy Farrell Date: Mon, 20 Nov 2017 21:57:37 -0600 Subject: [PATCH] Add back albums Removing an image from an album does not delete it. Deleteing an images does not remove its link from the album. --- packages/gallery/package.json | 2 +- .../gallery/src/context/manageImageTags.js | 20 ----- packages/gallery/src/data/album.js | 53 +++++++++++ packages/gallery/src/data/file.js | 1 - packages/gallery/src/data/indexType.js | 82 ----------------- packages/gallery/src/interface/album.js | 87 ++++++++++++------- .../gallery/src/interface/attachmentImage.js | 32 +++---- packages/gallery/src/interface/gallery.js | 31 +++++-- packages/gallery/src/interface/thumbnail.js | 2 +- packages/gallery/src/services/db.js | 29 +++++-- packages/gallery/src/utils/conversion.js | 9 ++ packages/gallery/src/utils/livearray.js | 4 +- 12 files changed, 184 insertions(+), 168 deletions(-) delete mode 100644 packages/gallery/src/context/manageImageTags.js create mode 100644 packages/gallery/src/data/album.js delete mode 100644 packages/gallery/src/data/indexType.js diff --git a/packages/gallery/package.json b/packages/gallery/package.json index d5246e0..5adddfc 100644 --- a/packages/gallery/package.json +++ b/packages/gallery/package.json @@ -16,7 +16,7 @@ "domvm": "~3.2.1", "exif-parser": "~0.1.9", "extract-text-webpack-plugin": "^3.0.2", - "frptools": "2.0.0", + "frptools": "2.1.0", "pica": "~2.0.8", "pouchdb-adapter-http": "~6.3.4", "pouchdb-adapter-idb": "~6.3.4", diff --git a/packages/gallery/src/context/manageImageTags.js b/packages/gallery/src/context/manageImageTags.js deleted file mode 100644 index 09c5d90..0000000 --- a/packages/gallery/src/context/manageImageTags.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as image from '../data/image.js'; -import * as index from '../data/indexType.js'; - -export async function add(title, imageId, visible = true) { - const trimmedTitle = title.trim(); - await index.add(trimmedTitle, { title: trimmedTitle }, [imageId]); - return image.update(imageId, { - tags: { [trimmedTitle]: visible } - }); -} - -export async function remove(title, imageId) { - const id = index.hashString(title); - await image.update(imageId, { tags: { [title]: undefined } }); - await index.removeMember(title, imageId); -} - -image.removed.subscribe(image => { - Object.keys(image.tags).forEach(t => index.removeMember(t, image._id)); -}); diff --git a/packages/gallery/src/data/album.js b/packages/gallery/src/data/album.js new file mode 100644 index 0000000..872427b --- /dev/null +++ b/packages/gallery/src/data/album.js @@ -0,0 +1,53 @@ +import { PouchDB, TypeSpec } from '../services/db.js'; +import { log } from '../services/console.js'; + +class AlbumSpec extends TypeSpec { + static getUniqueID(doc) { + return doc.title + .trim() + .replace(/[ \-~!@#$%^&]/g, '_') + .toLowerCase(); + } + + async addMember(member, position) { + const currentPosition = this.members.indexOf(member); + const newPosition = position ? position : this.members.length; + if (currentPosition !== -1) { + this.members.splice(currentPosition, 1); + } + this.members.splice(newPosition, 0, member); + await this.save(); + } + + async removeMember(member) { + const currentPosition = this.members.indexOf(member); + + if (currentPosition !== -1) { + this.members.splice(currentPosition, 1); + await this.save(); + } + } + + // + // static validate(doc) { + // // TODO actually validate perhaps against a JSON schema + // + // const schema = { + // title: t.REQUIRED_STRING, + // members: { + // type: "array", + // items: t.STRING + // } + // }; + // } +} + +export const AlbumType = PouchDB.registerType('Album', AlbumSpec); + +// ImageType.watch({_deleted: true}, true) +// .then(la => { +// la.subscribe() ); +// +// image.removed.subscribe(image => { +// Object.keys(image.tags).forEach(t => index.removeMember(t, image._id)); +// }) diff --git a/packages/gallery/src/data/file.js b/packages/gallery/src/data/file.js index 6ce518a..0319556 100644 --- a/packages/gallery/src/data/file.js +++ b/packages/gallery/src/data/file.js @@ -1,5 +1,4 @@ import { PouchDB, TypeSpec } from '../services/db.js'; -import { log } from '../services/console.js'; import { sha256 } from '../utils/crypto.js'; import { blobToArrayBuffer } from '../utils/conversion.js'; diff --git a/packages/gallery/src/data/indexType.js b/packages/gallery/src/data/indexType.js deleted file mode 100644 index 562f815..0000000 --- a/packages/gallery/src/data/indexType.js +++ /dev/null @@ -1,82 +0,0 @@ -import { log, error } from '../services/console.js'; -import { getDatabase, getOrCreate } from '../services/db.js'; -import { Event } from '../utils/event.js'; - -const db = getDatabase(); -const PREFIX = 'index'; -export const SELECTOR = { - _id: { - $gt: `${PREFIX}_`, - $lt: `${PREFIX}_\ufff0` - } -}; - -// Events -export const added = new Event('Index.added'); -export const removed = new Event('Index.removed'); - -// Methods -export const hashString = name => - name - .trim() - .replace(/[ \-~!@#$%^&]/g, '_') - .toLowerCase(); -const getId = id => (id.startsWith(PREFIX) ? id : `${PREFIX}_${hashString(id)}`); - -export async function find(keys, options = {}) { - let opts = { include_docs: true }; - if (Array.isArray(keys)) { - Object.assign(opts, options); - opts.keys = keys.map(getId); - } else { - Object.assign(opts, keys); - opts.startkey = `${PREFIX}_`; - opts.endkey = `${PREFIX}_\ufff0`; - } - return await db.allDocs(opts); -} - -export async function add(id, props = {}, members = []) { - const _id = getId(id); - const [results, created] = await getOrCreate({ - _id, - props, - members: [] - }); - - if (members.length) { - members.forEach(async m => await addMember(_id, m)); - } - - return created || results.ok; -} - -export async function addMember(id, member) { - const results = await find([id]); - const doc = results.rows[0].doc; - - if (doc.members.indexOf(member) === -1) { - doc.members.push(member); - await db.put(doc); - added.fire(doc._id, member); - } - - return doc; -} - -export async function removeMember(id, member) { - const results = await find([id]); - const doc = results.rows[0].doc; - const idx = doc.members.indexOf(member); - - if (idx !== -1) { - if (doc.members.length > 1) { - doc.members.splice(idx, 1); - await db.put(doc); - removed.fire(doc._id, member); - } else { - await db.remove(doc); - removed.fire(doc._id, member); - } - } -} diff --git a/packages/gallery/src/interface/album.js b/packages/gallery/src/interface/album.js index 484d918..75119de 100644 --- a/packages/gallery/src/interface/album.js +++ b/packages/gallery/src/interface/album.js @@ -1,41 +1,70 @@ import { defineView, defineElement as el } from 'domvm'; -import * as image from '../data/image.js'; + +import { ImageType } from '../data/image.js'; +import { FileType } from '../data/file.js'; +import { pouchDocArrayHash, pouchDocHash } from '../utils/conversion.js'; import { ThumbnailView } from './thumbnail.js'; -import { LiveArray } from '../utils/livearray.js'; +import { prop, computed, bundle } from 'frptools'; -export function AlbumView(vm, model) { - const { remove, db } = model; - let data = null; - let title = null; +export function AlbumView(vm, params) { + const model = prop({}, pouchDocHash); + const images = prop([], pouchDocArrayHash); - function removeImageFromAlbum(id, rev) { - remove(title, id); + const id = computed(pouchDocHash, [model]); + const members = computed(d => d.members, [model]); // always update + const title = computed(d => d.title, [model]); // always update + + let laCleanup = null; + + id.subscribe(async () => { + const la = await ImageType.find( + { + _id: { $in: members() } + }, + true + ); + + function refresh() { + images(la()); + vm.redraw(); + } + + if (laCleanup) { + laCleanup(); + } + + laCleanup = la.subscribe(refresh); + la.ready.subscribe(refresh); + }); + + function removeImageFromAlbum(image) { + model().removeMember(image._id); } - return function(vm, model, key, opts) { - const { doc, remove } = model; - const { props } = doc; + function removeAlbum() { + model().delete(); + } - if (title !== props.title) { - if (data) { - data.cleanup(); - } - title = props.title; - const SELECTOR = Object.assign( - { - [`tags.${title}`]: { $eq: true } - }, - image.SELECTOR - ); + function uploadImages(album, evt) { + Promise.all(Array.from(evt.currentTarget.files).map(ImageType.upload)).then(images => { + images.forEach(i => album.addMember(i._id)); + }); + } - data = LiveArray(db, SELECTOR); - data.subscribe(() => vm.redraw()); - } - const images = data(); + model(params.doc); + + return function(vm, params, key, opts) { + model(params.doc); return el('.album', [ - el('h2', [title]), - ...images.map(i => { + el('h2', [title(), el('button', { onclick: removeAlbum }, 'X')]), + el('input#fInput', { + type: 'file', + multiple: true, + accept: 'image/jpeg', + onchange: [uploadImages, model()] + }), + ...images().map(i => { return defineView( ThumbnailView, { @@ -43,7 +72,7 @@ export function AlbumView(vm, model) { showTags: false, remove: removeImageFromAlbum }, - i._id + id() ); }) ]); diff --git a/packages/gallery/src/interface/attachmentImage.js b/packages/gallery/src/interface/attachmentImage.js index 3311d7b..2517327 100644 --- a/packages/gallery/src/interface/attachmentImage.js +++ b/packages/gallery/src/interface/attachmentImage.js @@ -3,28 +3,32 @@ import { prop, computed, bundle } from 'frptools'; import { ImageType } from '../data/image.js'; import { FileType } from '../data/file.js'; -import { pouchDocComparator } from '../utils/comparators.js'; +import { pouchDocArrayHash, pouchDocHash } from '../utils/conversion.js'; -export function AttachmentImageView(vm, doc) { - const model = bundle({ - _id: prop(doc._id), - _rev: prop(doc._rev), - sizes: prop(doc.sizes) - }); +export function AttachmentImageView(vm, image) { + const model = prop(image, pouchDocHash); + const id = computed(pouchDocHash, [model]); + const sizes = computed(d => d.sizes, [model]); // always update const blobURL = prop(''); const imageURL = computed((sizes, bURL) => bURL || sizes.thumbnail || sizes.full, [ - model.sizes, + sizes, blobURL ]); - const _key = computed((id, rev) => id + rev, [model._id, model._rev]); + + model.subscribe(() => { + if (blobURL()) { + URL.revokeObjectURL(blobURL()); + blobURL(''); + } + }); async function loadImageFromBlob() { - const options = ['thumbnail', 'full'].filter(o => model.sizes().hasOwnProperty(o)); + const options = ['thumbnail', 'full'].filter(o => sizes().hasOwnProperty(o)); for (let attempt of options) { try { - const data = await FileType.getFromURL(model.sizes()[attempt]); + const data = await FileType.getFromURL(sizes()[attempt]); if (blobURL()) { URL.revokeObjectURL(blobURL()); @@ -45,16 +49,12 @@ export function AttachmentImageView(vm, doc) { const redrawOff = imageURL.subscribe(() => vm.redraw()); return function render(vm, doc) { - if (!pouchDocComparator(doc, { _id: model._id(), _rev: model._rev() })) { - URL.revokeObjectURL(blobURL()); - blobURL(''); - } model(doc); return el('img', { src: imageURL(), onerror: loadImageFromBlob, - _key: _key(), + _key: id(), _hooks: { didRemove: cleanup } diff --git a/packages/gallery/src/interface/gallery.js b/packages/gallery/src/interface/gallery.js index 917bfc4..fc0d710 100644 --- a/packages/gallery/src/interface/gallery.js +++ b/packages/gallery/src/interface/gallery.js @@ -1,7 +1,6 @@ import { defineView as vw } from 'domvm'; import { ImageType } from '../data/image.js'; -// import * as index from '../data/indexType.js'; -// import * as imageTag from '../context/manageImageTags.js'; +import { AlbumType } from '../data/album.js'; import { ThumbnailView } from './thumbnail.js'; import { AlbumView } from './album.js'; import { router, routeChanged } from '../services/router.js'; @@ -18,11 +17,11 @@ export function GalleryView(vm, model) { true ), title: 'Images' + }, + albums: { + data: AlbumType.find({}, true), + title: 'Albums' } - // albums: { - // selector: index.SELECTOR, - // title: 'Albums' - // } }; let data = null; @@ -33,12 +32,26 @@ export function GalleryView(vm, model) { Array.from(evt.currentTarget.files).forEach(ImageType.upload); } + function deleteImage(i) { + ImageType.delete(i._id); + } + + function addAlbum() { + const a = new AlbumType({ + title: prompt('Album Name'), + members: [] + }); + a.save(); + } + routeChanged.subscribe(function onRouteChange(router, route) { if (laCleanup) { laCleanup(); } const o = NAV_OPTIONS[route.name]; title = o.title; + vm.redraw(); + return o.data.then(la => { data = la; laCleanup = data.subscribe(() => { @@ -52,6 +65,7 @@ export function GalleryView(vm, model) { return el('.gallery', [ header([ el('div', { css: { fontSize: '20pt' } }, 'Gallery'), + el('button', { onclick: addAlbum }, 'Add Album'), el('input#fInput', { type: 'file', multiple: true, @@ -73,7 +87,7 @@ export function GalleryView(vm, model) { doc: i, showTags: true, // addTag: imageTag.add, - remove: i.delete.bind(i) + remove: deleteImage // removeTag: imageTag.remove }, i._id + i._rev @@ -83,8 +97,7 @@ export function GalleryView(vm, model) { return vw( AlbumView, { - doc: a, - db + doc: a // addTag: imageTag.add, // remove: imageTag.remove }, diff --git a/packages/gallery/src/interface/thumbnail.js b/packages/gallery/src/interface/thumbnail.js index 4c98715..96124d3 100644 --- a/packages/gallery/src/interface/thumbnail.js +++ b/packages/gallery/src/interface/thumbnail.js @@ -24,7 +24,7 @@ export function ThumbnailView(vm, model) { el( `figure#${doc._id}.image`, { - onclick: { img: [remove, id, rev] } + onclick: { img: [remove, doc] } }, [ vw(AttachmentImageView, doc, doc._id + doc._rev), diff --git a/packages/gallery/src/services/db.js b/packages/gallery/src/services/db.js index faa5fd1..721567d 100644 --- a/packages/gallery/src/services/db.js +++ b/packages/gallery/src/services/db.js @@ -95,19 +95,21 @@ export function PouchORM(PouchDB) { const instantiate = doc => new cls(doc); - async function find(idOrQuery, live = false) { - if (typeof idOrQuery === 'string') { - return instantiate(await _db.get(idOrQuery)); + async function find(idOrSelector, live = false) { + if (typeof idOrSelector === 'string') { + return instantiate(await _db.get(idOrSelector)); } + const isSelector = isObject(idOrSelector); + const selector = Object.assign( - { _deleted: { exists: false } }, - isObject(idOrQuery) ? idOrQuery : { _id: { $gt: `${prefix}_0`, $lt: `${prefix}_\ufff0` } } + isSelector && idOrSelector._deleted ? { _deleted: true } : { _deleted: { exists: false } }, + isSelector ? idOrSelector : { _id: { $gt: `${prefix}_0`, $lt: `${prefix}_\ufff0` } } ); if (live) { - return LiveArray(_db, idOrQuery, instantiate); + return LiveArray(_db, idOrSelector, instantiate); } - return (await _db.find({ selector: idOrQuery })).docs.map(instantiate); + return (await _db.find({ selector: idOrSelector })).docs.map(instantiate); } async function getOrCreate(props) { @@ -123,6 +125,18 @@ export function PouchORM(PouchDB) { return doc; } + async function _delete(id) { + try { + const doc = await find(id); + doc._deleted = true; + await _db.put(doc); + } catch (e) { + if (e.status !== 404) { + throw e; + } + } + } + Object.defineProperties(cls.prototype, { _name: { value: name }, _prefix: { value: prefix }, @@ -133,6 +147,7 @@ export function PouchORM(PouchDB) { Object.defineProperties(cls, { getOrCreate: { value: getOrCreate }, find: { value: find }, + delete: { value: _delete }, db: { value: _db }, name: { value: name } }); diff --git a/packages/gallery/src/utils/conversion.js b/packages/gallery/src/utils/conversion.js index beea9bb..3c8808d 100644 --- a/packages/gallery/src/utils/conversion.js +++ b/packages/gallery/src/utils/conversion.js @@ -1,4 +1,5 @@ import { readAsArrayBuffer } from 'pouchdb-binary-utils'; +import { isObject } from './comparators'; export function bufferToHexString(buffer) { const hexCodes = []; @@ -20,6 +21,14 @@ export function blobToArrayBuffer(blob) { return new Promise(resolve => readAsArrayBuffer(blob, resolve)); } +export const arrayHashWrapper = hash => arr => (Array.isArray(arr) ? arr.map(hash).join('?') : arr); + +export function pouchDocHash(d) { + return isObject(d) ? `${d._id}:${d._rev}` : d; +} + +export const pouchDocArrayHash = arrayHashWrapper(pouchDocHash); + export function deepAssign(to, ...rest) { for (let src of rest) { for (let prop in src) { diff --git a/packages/gallery/src/utils/livearray.js b/packages/gallery/src/utils/livearray.js index 7ad1e63..271232d 100644 --- a/packages/gallery/src/utils/livearray.js +++ b/packages/gallery/src/utils/livearray.js @@ -1,7 +1,7 @@ import { prop, computed } from 'frptools'; import { Watcher } from './watcher.js'; -import { pouchDocArrayComparator } from './comparators.js'; +import { pouchDocArrayHash } from './conversion.js'; // LiveArray is a subscribable property function that always returns the db results that match the provided selector and calls subscribers when the results change. export function LiveArray(db, selector, mapper) { @@ -11,7 +11,7 @@ export function LiveArray(db, selector, mapper) { const ready = prop(false); const data = prop({ docs: [] }); - const docs = computed(r => r.docs.map(_mapper), [data], pouchDocArrayComparator); + const docs = computed(r => r.docs.map(_mapper), [data], pouchDocArrayHash); const cleanup = () => { docs.unsubscribeAll();