Permalink
Browse files

First code commit.

  • Loading branch information...
1 parent 3b2bb1f commit 204084b830ac21fc6a362f902810109915c7c5dc @panta committed Sep 14, 2012
Showing with 528 additions and 0 deletions.
  1. +3 −0 .gitignore
  2. +2 −0 .npmignore
  3. +32 −0 Cakefile
  4. +20 −0 LICENSE
  5. +96 −0 README.md
  6. +42 −0 package.json
  7. +161 −0 src/index.coffee
  8. +172 −0 test/test.coffee
View
3 .gitignore
@@ -0,0 +1,3 @@
+.swp
+node_modules/
+lib/*
View
2 .npmignore
@@ -0,0 +1,2 @@
+.swp
+node_modules/
View
32 Cakefile
@@ -0,0 +1,32 @@
+fs = require 'fs'
+{exec} = require 'child_process'
+util = require 'util'
+glob = require 'glob'
+muffin = require 'muffin'
+
+option '-w', '--watch', 'continue to watch the files and rebuild them when they change'
+option '-c', '--commit', 'operate on the git index instead of the working tree'
+option '-m', '--compare', 'compare across git refs, stats task only.'
+
+# Define a Cake task called build
+task 'build', 'compile library', (options) ->
+ # Run a map on all the files in the top directory
+ muffin.run
+ files: './src/**/*'
+ options: options
+ # For any file matching 'src/*.coffee', compile it to 'lib/*.js'
+ map:
+ 'src/(.+).coffee' : (matches) -> muffin.compileScript(matches[0], "lib/#{matches[1]}.js", options)
+ console.log "Watching src..." if options.watch
+
+task 'stats', 'print source code stats', (options) ->
+ muffin.statFiles(['lib/index.js'], options)
+
+task 'doc', 'autogenerate docco anotated source and node IDL files', (options) ->
+ muffin.run
+ files: './src/**/*'
+ options: options
+ map:
+ 'src/index.coffee' : (matches) -> muffin.doccoFile(matches[0], options)
+
+task 'test', ->
View
20 LICENSE
@@ -0,0 +1,20 @@
+Copyright (C) 2012 Marco Pantaleoni <marco.pantaleoni@gmail.com>
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
View
96 README.md
@@ -0,0 +1,96 @@
+## About mongoose-file
+
+[mongoose][] plugin that adds a file field to a mongoose schema.
+This is especially suited to handle file uploads with [nodejs][]/[expressjs][].
+
+## Install
+
+npm install mongoose-file
+
+## Usage
+
+The plugin adds a file field to the mongoose schema. Assigning to the **file** property of the field causes (optionally) the file to be moved into place (see `upload_to` below) and the field sub-properties to be assigned.
+This field expects to be assigned (to its `file` sub-field, as said) an object with a semantic like that of an [expressjs][] request file object (see [req.files](http://expressjs.com/api.html#req.files)).
+Assigning to the field `file` property caused the instance to be marked as modified (but it's not saved automatically).
+
+The field added to the schema is a compound JavaScript object, containing the following fields:
+
+* `name` - original name of the uploaded file, without the directory components
+* `path` - the full final path where the uploaded file is stored
+* `rel` - path relative to a user specified directory (see the `relative_to` option below)
+* `type` - the file type
+* `size` - the file size
+* `lastModified` - the uploaded file object `lastModifiedDate` value
+
+These values are extracted from the request-like file object received on assignment.
+
+When attaching the plugin, it's possible to specify an options object containing the following parameters:
+
+* `name` - the field name (`name`), which defaults to `file`
+* `change_cb` - a callback function called whenever the file path is changed. It's called in the context of the model instance, and receives as parameters the field name, the new path value and the previous one.
+* `upload_to` - the directory name where the file will be moved from the temporary upload directory. If this is a function instead of a string, it will be called in the context of the model instance with the file object as a parameter, and it must return a string
+* `relative_to` - the base directory name used to construct the relative path stored in the `rel` subfield. If this is a function, it will be called in the context of the model instance with the file object as a parameter. This can be useful to get a path usable from HTML, like in `<img>` `src` attribute.
+
+### JavaScript
+
+```javascript
+var mongoose = require('mongoose');
+var filePluginLib = require('mongoose-file');
+var filePlugin = filePluginLib.filePlugin;
+var make_upload_to_model = filePluginLib.make_upload_to_model;
+
+...
+
+var uploads_base = path.join(__dirname, "uploads");
+var uploads = path.join(uploads_base, "u");
+...
+
+var SampleSchema = new Schema({
+ ...
+});
+SampleSchema.plugin(filePlugin, {
+ name: "photo",
+ upload_to: make_upload_to_model(uploads, 'photos'),
+ relative_to: uploads_base
+});
+var SampleModel = db.model("SampleModel", SampleSchema);
+```
+
+### [CoffeeScript][]
+
+```coffeescript
+mongoose = require 'mongoose'
+filePluginLib = require 'mongoose-file'
+filePlugin = filePluginLib.filePlugin
+make_upload_to_model = filePluginLib.make_upload_to_model
+
+...
+uploads_base = path.join(__dirname, "uploads")
+uploads = path.join(uploads_base, "u")
+...
+
+SampleSchema = new Schema
+ ...
+SampleSchema.plugin filePlugin
+ name: "photo"
+ upload_to: make_upload_to_model(uploads, 'photos')
+ relative_to: uploads_base
+SampleModel = db.model("SampleModel", SampleSchema)
+```
+
+## Bugs and pull requests
+
+Please use the github [repository][] to notify bugs and make pull requests.
+
+## License
+
+This software is © 2012 Marco Pantaleoni, released under the MIT licence. Use it, fork it.
+
+See the LICENSE file for details.
+
+[mongoose]: http://mongoosejs.com
+[CoffeeScript]: http://jashkenas.github.com/coffee-script/
+[nodejs]: http://nodejs.org/
+[expressjs]: http://expressjs.com
+[Mocha]: http://visionmedia.github.com/mocha/
+[repository]: http://github.com/panta/mongoose-file
View
42 package.json
@@ -0,0 +1,42 @@
+{ "name": "mongoose-file"
+ , "description": "Mongoose plugin adding a file field to a schema - useful for nodejs file uploads"
+ , "version": "0.0.1"
+ , "homepage": "https://github.com/panta/mongoose-file"
+ , "author": "Marco Pantaleoni <marco.pantaleoni@gmail.com>"
+ , "dependencies": {
+ "mongoose": ">= 3.0.1"
+ , "mkdirp": ">= 0.3.4"
+ }
+ , "devDependencies": {
+ "coffee-script": ">= 1.3.3"
+ , "muffin": ">= 0.6.2"
+ , "glob": ">= 3.0.1"
+ , "mocha": ">= 1.4.2"
+ , "chai": ">= 1.2.0"
+ }
+ , "keywords": ["mongoose", "plugin", "plugins", "types", "file", "upload"]
+ , "repository": {
+ "type": "git"
+ , "url": "git://github.com/panta/mongoose-file.git"
+ }
+ , "bugs": {
+ "url" : "https://github.com/panta/mongoose-file/issues"
+ }
+ , "licenses": [{
+ "type": "MIT",
+ "url": "https://raw.github.com/panta/mongoose-file/master/LICENSE"
+ }]
+ , "directories" : {
+ "lib" : "./lib"
+ , "test" : "./test"
+ }
+ , "scripts": {
+ "watch": "coffee -c -w -o lib src"
+ , "test": "NODE_ENV=test node_modules/.bin/mocha --compilers coffee:coffee-script --timeout 10000 -R spec test/*.coffee"
+ }
+ , "main": "lib/index.js"
+ , "engines": {
+ "node": ">= 0.6.0"
+ , "npm": ">= 1.0.0"
+ }
+}
View
161 src/index.coffee
@@ -0,0 +1,161 @@
+mongoose = require('mongoose')
+
+path = require('path')
+fs = require('fs')
+mkdirp = require('mkdirp')
+
+Schema = mongoose.Schema
+ObjectId = Schema.ObjectId
+
+# ---------------------------------------------------------------------
+# helper functions
+# ---------------------------------------------------------------------
+
+# Extend a source object with the properties of another object (shallow copy).
+extend = (dst, src) ->
+ for key, val of src
+ dst[key] = val
+ dst
+
+# Add missing properties from a `src` object.
+defaults = (dst, src) ->
+ for key, val of src
+ if not (key of dst)
+ dst[key] = val
+ dst
+
+# Add a new field by name to a mongoose schema
+addSchemaField = (schema, pathname, fieldSpec) ->
+ fieldSchema = {}
+ fieldSchema[pathname] = fieldSpec
+ schema.add fieldSchema
+
+addSchemaSubField = (schema, masterPathname, subName, fieldSpec) ->
+ addSchemaField schema, "#{masterPathname}.#{subName}", fieldSpec
+
+is_callable = (f) ->
+ (typeof f is 'function')
+
+# ---------------------------------------------------------------------
+# M O N G O O S E P L U G I N S
+# ---------------------------------------------------------------------
+# http://mongoosejs.com/docs/plugins.html
+
+filePlugin = (schema, options={}) ->
+ pathname = options.name or 'file'
+ onChangeCb = options.change_cb or null
+ upload_to = options.upload_to or null # if null, uploaded file is left in the temp upload dir
+ relative_to = options.relative_to or null # if null, .rel field is equal to .path
+
+ # fieldSchema = {}
+ # fieldSchema[pathname] = {} # mixed: { type: Schema.Types.Mixed }
+ # schema.add fieldSchema
+ # fieldSchema = {}
+ # fieldSchema["#{pathname}.name"] = String
+ # schema.add fieldSchema
+ # fieldSchema = {}
+ # fieldSchema["#{pathname}.path"] = String
+ # schema.add fieldSchema
+ # fieldSchema = {}
+ # fieldSchema["#{pathname}.type"] = {type: String}
+ # schema.add fieldSchema
+ # fieldSchema = {}
+ # fieldSchema["#{pathname}.size"] = Number
+ # schema.add fieldSchema
+ # fieldSchema = {}
+ # fieldSchema["#{pathname}.lastModified"] = Date
+ # schema.add fieldSchema
+
+ # fieldSchema = {}
+ # fieldSchema[pathname] =
+ # name: String
+ # path: String
+ # rel: String
+ # type: String
+ # size: Number
+ # lastModified: Date
+ # schema.add fieldSchema
+ # fieldSchema = {}
+ # fieldSchema[pathname] = {} # mixed: { type: Schema.Types.Mixed }
+ # schema.add fieldSchema
+
+ addSchemaField schema, pathname, {} # mixed: { type: Schema.Types.Mixed }
+ addSchemaSubField schema, pathname, 'name', { type: String, default: () -> null }
+ addSchemaSubField schema, pathname, 'path', { type: String, default: () -> null }
+ addSchemaSubField schema, pathname, 'rel', { type: String, default: () -> null }
+ addSchemaSubField schema, pathname, 'type', { type: String, default: () -> null }
+ addSchemaSubField schema, pathname, 'size', { type: Number, default: () -> null }
+ addSchemaSubField schema, pathname, 'lastModified', { type: Date, default: () -> null }
+
+ schema.virtual("#{pathname}.file").set (fileObj) ->
+ u_path = fileObj.path
+ if upload_to
+ # move from temp. upload directory to final destination
+ if is_callable(upload_to)
+ dst = upload_to.call(@, fileObj)
+ else
+ dst = path.join(upload_to, fileObj.name)
+ dst_dirname = path.dirname(dst)
+ mkdirp dst_dirname, (err) =>
+ throw err if err
+ fs.rename u_path, dst, (err) =>
+ if (err)
+ # delete the temporary file, so that the explicitly set temporary upload dir does not get filled with unwanted files
+ fs.unlink u_path, (err) =>
+ throw err if err
+ throw err
+ console.log("moved from #{u_path} to #{dst}")
+ rel = dst
+ if relative_to
+ if is_callable(relative_to)
+ rel = relative_to.call(@, fileObj)
+ else
+ rel = path.relative(relative_to, dst)
+ @set("#{pathname}.name", fileObj.name)
+ @set("#{pathname}.path", dst)
+ @set("#{pathname}.rel", rel)
+ @set("#{pathname}.type", fileObj.type)
+ @set("#{pathname}.size", fileObj.size)
+ @set("#{pathname}.lastModified", fileObj.lastModifiedDate)
+ @markModified(pathname)
+ else
+ dst = u_path
+ rel = dst
+ if relative_to
+ if is_callable(relative_to)
+ rel = relative_to.call(@, fileObj)
+ else
+ rel = path.relative(relative_to, dst)
+ @set("#{pathname}.name", fileObj.name)
+ @set("#{pathname}.path", dst)
+ @set("#{pathname}.rel", rel)
+ @set("#{pathname}.type", fileObj.type)
+ @set("#{pathname}.size", fileObj.size)
+ @set("#{pathname}.lastModified", fileObj.lastModifiedDate)
+ @markModified(pathname)
+ schema.pre 'set', (next, path, val, typel) ->
+ if path is "#{pathname}.path"
+ if onChangeCb
+ oldValue = @get("#{pathname}.path")
+ console.log("old: #{oldValue} new: #{val}")
+ onChangeCb.call(@, pathname, val, oldValue)
+ next()
+
+make_upload_to_model = (basedir, subdir) ->
+ b_dir = basedir
+ s_dir = subdir
+ upload_to_model = (fileObj) ->
+ dstdir = b_dir
+ if s_dir
+ dstdir = path.join(dstdir, s_dir)
+ id = @get('id')
+ if id
+ dstdir = path.join(dstdir, "#{id}")
+ path.join(dstdir, fileObj.name)
+ upload_to_model
+
+# -- exports ----------------------------------------------------------
+
+module.exports =
+ filePlugin: filePlugin
+ make_upload_to_model: make_upload_to_model
View
172 test/test.coffee
@@ -0,0 +1,172 @@
+chai = require 'chai'
+assert = chai.assert
+expect = chai.expect
+should = chai.should()
+mongoose = require 'mongoose'
+
+fs = require 'fs'
+path = require 'path'
+
+index = require '../src/index'
+
+PLUGIN_TIMEOUT = 800
+
+rmDir = (dirPath) ->
+ try
+ files = fs.readdirSync(dirPath)
+ catch e
+ return
+ if files.length > 0
+ i = 0
+
+ while i < files.length
+ continue if files[i] in ['.', '..']
+ filePath = dirPath + "/" + files[i]
+ if fs.statSync(filePath).isFile()
+ fs.unlinkSync filePath
+ else
+ rmDir filePath
+ i++
+ fs.rmdirSync dirPath
+
+db = mongoose.createConnection('localhost', 'mongoose_file_tests')
+db.on('error', console.error.bind(console, 'connection error:'))
+
+uploads_base = __dirname + "/uploads"
+uploads = uploads_base + "/u"
+
+tmpFilePath = '/tmp/mongoose-file-test.txt'
+uploadedDate = new Date()
+uploadedFile =
+ size: 12345
+ path: tmpFilePath
+ name: 'photo.png'
+ type: 'image/png',
+ hash: false,
+ lastModifiedDate: uploadedDate
+
+Schema = mongoose.Schema
+ObjectId = Schema.ObjectId
+
+SimpleSchema = new Schema
+ name: String
+ title: String
+
+describe 'WHEN working with the plugin', ->
+ before (done) ->
+ done()
+
+ after (done) ->
+ SimpleModel = db.model("SimpleModel", SimpleSchema)
+ SimpleModel.remove {}, (err) ->
+ return done(err) if err
+ rmDir(uploads_base)
+ done()
+
+ describe 'library', ->
+ it 'should exist', (done) ->
+ should.exist index
+ done()
+
+ describe 'adding the plugin', ->
+ it 'should work', (done) ->
+
+ SimpleSchema.plugin index.filePlugin,
+ name: "photo",
+ upload_to: index.make_upload_to_model(uploads, 'photos'),
+ relative_to: uploads_base
+ SimpleModel = db.model("SimpleModel", SimpleSchema)
+
+ instance = new SimpleModel({name: 'testName', title: 'testTitle'})
+ should.exist instance
+ should.equal instance.isModified(), true
+ instance.should.have.property 'name', 'testName'
+ instance.should.have.property 'title', 'testTitle'
+ instance.should.have.property 'photo'
+ should.exist instance.photo
+ instance.photo.should.have.property 'name'
+ instance.photo.should.have.property 'path'
+ instance.photo.should.have.property 'rel'
+ instance.photo.should.have.property 'type'
+ instance.photo.should.have.property 'size'
+ instance.photo.should.have.property 'lastModified'
+ should.not.exist instance.photo.name
+ should.not.exist instance.photo.path
+ should.not.exist instance.photo.rel
+ should.not.exist instance.photo.type
+ should.not.exist instance.photo.size
+ should.not.exist instance.photo.lastModified
+ done()
+
+ describe 'assigning to the instance field', ->
+ it 'should populate subfields', (done) ->
+
+ SimpleSchema.plugin index.filePlugin,
+ name: "photo",
+ upload_to: index.make_upload_to_model(uploads, 'photos'),
+ relative_to: uploads_base
+ SimpleModel = db.model("SimpleModel", SimpleSchema)
+
+ instance = new SimpleModel({name: 'testName', title: 'testTitle'})
+ should.exist instance
+ should.exist instance.photo
+ should.equal instance.isModified(), true
+
+ fs.writeFile tmpFilePath, "Dummy content here.\n", (err) ->
+ return done(err) if (err)
+
+ instance.set('photo.file', uploadedFile)
+ # give the plugin some time to notice the assignment and execute its
+ # asynchronous code
+ setTimeout ->
+ should.equal instance.isModified(), true
+ should.exist instance.photo.name
+ should.exist instance.photo.path
+ should.exist instance.photo.rel
+ should.exist instance.photo.type
+ should.exist instance.photo.size
+ should.exist instance.photo.lastModified
+
+ should.equal instance.photo.name, uploadedFile.name
+ should.not.equal instance.photo.path, uploadedFile.path
+ should.equal instance.photo.type, uploadedFile.type
+ should.equal instance.photo.size, uploadedFile.size
+ should.equal instance.photo.lastModified, uploadedFile.lastModifiedDate
+
+ done()
+ , PLUGIN_TIMEOUT
+
+ describe 'assigning to the instance field', ->
+ it 'should mark as modified', (done) ->
+
+ SimpleSchema.plugin index.filePlugin,
+ name: "photo",
+ upload_to: index.make_upload_to_model(uploads, 'photos'),
+ relative_to: uploads_base
+ SimpleModel = db.model("SimpleModel", SimpleSchema)
+
+ instance = new SimpleModel({name: 'testName', title: 'testTitle'})
+ should.exist instance
+ should.equal instance.isModified(), true
+
+ instance.save (err) ->
+ return done(err) if err
+
+ should.equal instance.isModified(), false
+
+ fs.writeFile tmpFilePath, "Dummy content here.\n", (err) ->
+ return done(err) if (err)
+
+ instance.set('photo.file', uploadedFile)
+ # give the plugin some time to notice the assignment and execute its
+ # asynchronous code
+ setTimeout ->
+ should.equal instance.isModified(), true
+
+ instance.save (err) ->
+ return done(err) if err
+
+ should.equal instance.isModified(), false
+
+ done()
+ , PLUGIN_TIMEOUT

0 comments on commit 204084b

Please sign in to comment.