diff --git a/include/class.attachment.php b/include/class.attachment.php
index 3247d06912d8baafaa895843556b6236f809a35d..c4fc059c453f8e6da6541b7ff28548d104387393 100644
--- a/include/class.attachment.php
+++ b/include/class.attachment.php
@@ -90,26 +90,31 @@ class Attachment extends VerySimpleModel {
     }
 }
 
-class GenericAttachment extends VerySimpleModel {
-    static $meta = array(
-        'table' => ATTACHMENT_TABLE,
-        'pk' => array('id'),
-    );
-}
-
-class GenericAttachments {
-
-    var $id;
-    var $type;
-
-    function GenericAttachments($object_id, $type) {
-        $this->id = $object_id;
-        $this->type = $type;
+class GenericAttachments
+extends InstrumentedList {
+
+    var $lang;
+
+    function getId() { return $this->key['object_id']; }
+    function getType() { return $this->key['type']; }
+
+    /**
+     * Drop attachments whose file_id values are not in the included list,
+     * additionally, add new files whose IDs are in the list provided.
+     */
+    function keepOnlyFileIds($ids, $inline=false, $lang=false) {
+        $new = array_fill_keys($ids, 1);
+        foreach ($this as $A) {
+            $idx = array_search($A->file_id, $ids);
+            if ($idx === false && (!$A->lang || $A->lang == $lang))
+                // Not in the $ids list, delete
+                $this->remove($A);
+            unset($new[$A->file_id]);
+        }
+        // Everything remaining in $new is truly new
+        $this->upload(array_keys($new), $inline, $lang);
     }
 
-    function getId() { return $this->id; }
-    function getType() { return $this->type; }
-
     function upload($files, $inline=false, $lang=false) {
         $i=array();
         if (!is_array($files)) $files=array($files);
@@ -125,12 +130,10 @@ class GenericAttachments {
 
             $_inline = isset($file['inline']) ? $file['inline'] : $inline;
 
-            $att = Attachment::create(array(
-                'type' => $this->getType(),
-                'object_id' => $this->getId(),
+            $att = $this->add(Attachment::create(array(
                 'file_id' => $fileId,
                 'inline' => $_inline ? 1 : 0,
-            ));
+            )));
             if ($lang)
                 $att->lang = $lang;
 
@@ -139,31 +142,12 @@ class GenericAttachments {
             $att->save();
             $i[] = $fileId;
         }
-
         return $i;
     }
 
     function save($file, $inline=true) {
-
-        if (is_numeric($file))
-            $fileId = $file;
-        elseif (is_array($file) && isset($file['id']))
-            $fileId = $file['id'];
-        elseif ($file = AttachmentFile::create($file))
-            $fileId = $file->getId();
-        else
-            return false;
-
-        $att = Attachment::create(array(
-            'type' => $this->getType(),
-            'object_id' => $this->getId(),
-            'file_id' => $fileId,
-            'inline' => $inline ? 1 : 0,
-        ));
-        if (!$att->save())
-            return false;
-
-        return $fileId;
+        $ids = $this->upload($file, $inline);
+        return $ids[0];
     }
 
     function getInlines($lang=false) { return $this->_getList(false, true, $lang); }
@@ -171,28 +155,30 @@ class GenericAttachments {
     function getAll($lang=false) { return $this->_getList(true, true, $lang); }
     function count($lang=false) { return count($this->getSeparates($lang)); }
 
-    function _getList($separate=false, $inlines=false, $lang=false) {
-        return Attachment::objects()->filter(array(
-            'type' => $this->getType(),
-            'object_id' => $this->getId(),
-        ));
+    function _getList($separates=false, $inlines=false, $lang=false) {
+        $base = $this;
+
+        if ($separates && !$inline)
+            $base = $base->filter(array('inline' => 0));
+        elseif (!$separates && $inline)
+            $base = $base->filter(array('inline' => 1));
+
+        if ($lang)
+            $base = $base->filter(array('lang' => $lang));
+        else
+            $base = $base->filter(array('lang__isnull' => true));
+
+        return $base;
     }
 
     function delete($file_id) {
-        return Attachment::objects()->filter(array(
-            'type' => $this->getType(),
-            'object_id' => $this->getId(),
-            'file_id' => $file_id,
-        ))->delete();
+        return $this->objects()->filter(array('file_id'=>$file_id))->delete();
     }
 
     function deleteAll($inline_only=false){
-        $objects = Attachment::objects()->filter(array(
-            'type' => $this->getType(),
-            'object_id' => $this->getId(),
-        ));
+        $objects = $this;
         if ($inline_only)
-            $objects->filter(array('inline' => 1));
+            $objects = $objects->filter(array('inline' => 1));
 
         return $objects->delete();
     }
@@ -200,5 +186,12 @@ class GenericAttachments {
     function deleteInlines() {
         return $this->deleteAll(true);
     }
+
+    static function forIdAndType($id, $type) {
+        return new static(array(
+            'Attachment',
+            array('object_id' => $id, 'type' => $type)
+        ));
+    }
 }
 ?>
diff --git a/include/class.canned.php b/include/class.canned.php
index 8c842776af205ac4e66881922b9d32d1d13716ca..9ea3a66cb82bf82b4526cda1c7b2108862b0e3f9 100644
--- a/include/class.canned.php
+++ b/include/class.canned.php
@@ -34,50 +34,30 @@ class CannedModel {
 
 RolePermission::register( /* @trans */ 'Knowledgebase', CannedModel::getPermissions());
 
-class Canned {
-    var $id;
-    var $ht;
-
-    var $attachments;
-
-    function Canned($id){
-        $this->id=0;
-        $this->load($id);
-    }
-
-    function load($id=0) {
-
-        if(!$id && !($id=$this->getId()))
-            return false;
-
-        $sql='SELECT canned.*, count(attach.file_id) as attachments '
-            .' FROM '.CANNED_TABLE.' canned '
-            .' LEFT JOIN '.ATTACHMENT_TABLE.' attach
-                    ON (attach.object_id=canned.canned_id AND attach.`type`=\'C\' AND NOT attach.inline) '
-            .' WHERE canned.canned_id='.db_input($id)
-            .' GROUP BY canned.canned_id';
-
-        if(!($res=db_query($sql)) ||  !db_num_rows($res))
-            return false;
-
-
-        $this->ht = db_fetch_array($res);
-        $this->id = $this->ht['canned_id'];
-        $this->attachments = new GenericAttachments($this->id, 'C');
-
-        return true;
-    }
-
-    function reload() {
-        return $this->load();
-    }
+class Canned
+extends VerySimpleModel {
+    static $meta = array(
+        'table' => CANNED_TABLE,
+        'pk' => array('canned_id'),
+        'joins' => array(
+            'attachments' => array(
+                'constraint' => array(
+                    "'C'" => 'Attachment.type',
+                    'canned_id' => 'Attachment.object_id',
+                ),
+                'list' => true,
+                'null' => true,
+                'broker' => 'GenericAttachments',
+            ),
+        ),
+    );
 
     function getId(){
-        return $this->id;
+        return $this->canned_id;
     }
 
     function isEnabled() {
-         return ($this->ht['isenabled']);
+         return $this->isenabled;
     }
 
     function isActive(){
@@ -118,11 +98,11 @@ class Canned {
     }
 
     function getTitle() {
-        return $this->ht['title'];
+        return $this->title;
     }
 
     function getResponse() {
-        return $this->ht['response'];
+        return $this->response;
     }
     function getResponseWithImages() {
         return Format::viewableImages($this->getResponse());
@@ -202,71 +182,65 @@ class Canned {
     }
 
     function getHashtable() {
-        return $this->ht;
+        $base = $this->ht;
+        unset($base['attachments']);
+        return $base;
     }
 
     function getInfo() {
         return $this->getHashtable();
     }
 
-    function update($vars, &$errors) {
-
-        if(!$this->save($this->getId(),$vars,$errors))
-            return false;
-
-        $this->reload();
-
-        return true;
-    }
-
     function getNumAttachments() {
-        return $this->ht['attachments'];
+        return $this->attachments->count();
     }
 
     function delete(){
-        if ($this->getNumFilters() > 0) return false;
+        if ($this->getNumFilters() > 0)
+            return false;
 
-        $sql='DELETE FROM '.CANNED_TABLE.' WHERE canned_id='.db_input($this->getId()).' LIMIT 1';
-        if(db_query($sql) && ($num=db_affected_rows())) {
-            $this->attachments->deleteAll();
-        }
+        if (!parent::delete())
+            return false;
 
-        return $num;
+        $this->attachments->deleteAll();
+
+        return true;
     }
 
     /*** Static functions ***/
-    function lookup($id){
-        return ($id && is_numeric($id) && ($c= new Canned($id)) && $c->getId()==$id)?$c:null;
-    }
 
-    function create($vars,&$errors) {
-        return self::save(0,$vars,$errors);
+    static function create($vars=false) {
+        $faq = parent::create($vars);
+        $faq->created = SqlFunction::NOW();
+        return $faq;
     }
 
-    function getIdByTitle($title) {
-        $sql='SELECT canned_id FROM '.CANNED_TABLE.' WHERE title='.db_input($title);
-        if(($res=db_query($sql)) && db_num_rows($res))
-            list($id)=db_fetch_row($res);
+    static function getIdByTitle($title) {
+        $row = static::objects()
+            ->filter(array('title' => $title))
+            ->values_flat('canned_id')
+            ->first();
 
-        return $id;
+        return $row ? $row[0] : null;
     }
 
-    function getCannedResponses($deptId=0, $explicit=false) {
-
-        $sql='SELECT canned_id, title FROM '.CANNED_TABLE
-           .' WHERE isenabled';
-        if($deptId){
-            $sql.=' AND (dept_id='.db_input($deptId);
-            if(!$explicit)
-                $sql.=' OR dept_id=0';
-            $sql.=')';
+    static function getCannedResponses($deptId=0, $explicit=false) {
+        $canned = static::objects()
+            ->filter(array('isenabled' => true))
+            ->order_by('title')
+            ->values_flat('canned_id', 'title');
+
+        if ($deptId) {
+            $depts = Q::any(array('dept_id' => $deptId));
+            if (!$explicit)
+                $depts->add(array('dept_id' => 0));
+            $canned->filter($depts);
         }
-        $sql.=' ORDER BY title';
 
         $responses = array();
-        if(($res=db_query($sql)) && db_num_rows($res)) {
-            while(list($id,$title)=db_fetch_row($res))
-                $responses[$id]=$title;
+        foreach ($canned as $row) {
+            list($id, $title) = $row;
+            $responses[$id] = $title;
         }
 
         return $responses;
@@ -276,50 +250,51 @@ class Canned {
         return self::getCannedResponses($deptId, $explicit);
     }
 
-    function save($id,$vars,&$errors) {
+    function save($refetch=false) {
+        if ($this->dirty || $refetch)
+            $this->updated = SqlFunction::NOW();
+        return parent::save($refetch || $this->dirty);
+    }
+
+    function update($vars,&$errors) {
         global $cfg;
 
-        $vars['title']=Format::striptags(trim($vars['title']));
+        $vars['title'] = Format::striptags(trim($vars['title']));
 
-        if($id && $id!=$vars['id'])
+        $id = isset($this->canned_id) ? $this->canned_id : null;
+        if ($id && $id != $vars['id'])
             $errors['err']=__('Internal error. Try again');
 
-        if(!$vars['title'])
-            $errors['title']=__('Title required');
-        elseif(strlen($vars['title'])<3)
-            $errors['title']=__('Title is too short. 3 chars minimum');
-        elseif(($cid=self::getIdByTitle($vars['title'])) && $cid!=$id)
-            $errors['title']=__('Title already exists');
-
-        if(!$vars['response'])
-            $errors['response']=__('Response text is required');
+        if (!$vars['title'])
+            $errors['title'] = __('Title required');
+        elseif (strlen($vars['title']) < 3)
+            $errors['title'] = __('Title is too short. 3 chars minimum');
+        elseif (($cid=self::getIdByTitle($vars['title'])) && $cid!=$id)
+            $errors['title'] = __('Title already exists');
 
-        if($errors) return false;
+        if (!$vars['response'])
+            $errors['response'] = __('Response text is required');
 
-        $sql=' updated=NOW() '.
-             ',dept_id='.db_input($vars['dept_id']?:0).
-             ',isenabled='.db_input($vars['isenabled']).
-             ',title='.db_input($vars['title']).
-             ',response='.db_input(Format::sanitize($vars['response'])).
-             ',notes='.db_input(Format::sanitize($vars['notes']));
-
-        if($id) {
-            $sql='UPDATE '.CANNED_TABLE.' SET '.$sql.' WHERE canned_id='.db_input($id);
-            if(db_query($sql))
-                return true;
+        if ($errors)
+            return false;
 
-            $errors['err']=sprintf(__('Unable to update %s.'), __('this canned response'));
+        $this->dept_id = $vars['dept_id'] ?: 0;
+        $this->isenabled = $vars['isenabled'];
+        $this->title = $vars['title'];
+        $this->response = Format::sanitize($vars['response']);
+        $this->notes = Format::sanitize($vars['notes']);
 
-        } else {
-            $sql='INSERT INTO '.CANNED_TABLE.' SET '.$sql.',created=NOW()';
-            if(db_query($sql) && ($id=db_insert_id()))
-                return $id;
+        $isnew = !isset($id);
+        if ($this->save())
+            return true;
 
+        if ($isnew)
+            $errors['err'] = sprintf(__('Unable to update %s.'), __('this canned response'));
+        else
             $errors['err']=sprintf(__('Unable to create %s.'), __('this canned response'))
                .' '.__('Internal error occurred');
-        }
 
-        return false;
+        return true;
     }
 }
 ?>
diff --git a/include/class.draft.php b/include/class.draft.php
index 1938064f8bfe5dd605bfc1c56077f0be58e06bd1..d83a47e4e7e456c97b6d0a5a072d73e2a59c1eaa 100644
--- a/include/class.draft.php
+++ b/include/class.draft.php
@@ -21,16 +21,19 @@ class Draft extends VerySimpleModel {
     static $meta = array(
         'table' => DRAFT_TABLE,
         'pk' => array('id'),
+        'joins' => array(
+            'attachments' => array(
+                'constraint' => array(
+                    "'D'" => 'Attachment.type',
+                    'id' => 'Attachment.object_id',
+                ),
+                'list' => true,
+                'null' => true,
+                'broker' => 'GenericAttachments',
+            ),
+        ),
     );
 
-    var $attachments;
-
-    function __construct() {
-        call_user_func_array(array('parent', '__construct'), func_get_args());
-        if (isset($this->id))
-            $this->attachments = new GenericAttachments($this->id, 'D');
-    }
-
     function getId() { return $this->id; }
     function getBody() { return $this->body; }
     function getStaffId() { return $this->staff_id; }
diff --git a/include/class.faq.php b/include/class.faq.php
index f2d42355bff78ea3781f62a4b1ca4b9b8e663421..f3d9d5bb76cfde2e0d454387b153d12475ade707 100644
--- a/include/class.faq.php
+++ b/include/class.faq.php
@@ -32,11 +32,12 @@ class FAQ extends VerySimpleModel {
             ),
             'attachments' => array(
                 'constraint' => array(
-                    "'F'" => 'GenericAttachment.type',
-                    'faq_id' => 'GenericAttachment.object_id',
+                    "'F'" => 'Attachment.type',
+                    'faq_id' => 'Attachment.object_id',
                 ),
                 'list' => true,
                 'null' => true,
+                'broker' => 'GenericAttachments',
             ),
             'topics' => array(
                 'constraint' => array(
@@ -57,24 +58,20 @@ class FAQ extends VerySimpleModel {
                 'primary' => true,
             ));
 
-    var $attachments;
     var $topics;
     var $_local;
+    var $_attachments;
 
     const VISIBILITY_PRIVATE = 0;
     const VISIBILITY_PUBLIC = 1;
     const VISIBILITY_FEATURED = 2;
 
-    function __onload() {
-        if (isset($this->faq_id))
-            $this->attachments = new GenericAttachments($this->getId(), 'F');
-    }
-
     /* ------------------> Getter methods <--------------------- */
     function getId() { return $this->faq_id; }
     function getHashtable() {
         $base = $this->ht;
         unset($base['category']);
+        unset($base['attachments']);
         return $base;
     }
     function getKeywords() { return $this->keywords; }
@@ -148,10 +145,6 @@ class FAQ extends VerySimpleModel {
     function setKeywords($words) { $this->keywords = $words; }
     function setNotes($text) { $this->notes = $text; }
 
-    /* For ->attach() and ->detach(), use $this->attachments() (nolint) */
-    function attach($file) { return $this->attachments->add($file); }
-    function detach($file) { return $this->attachments->remove($file); }
-
     function publish() {
         $this->setPublished(1);
         return $this->save();
@@ -169,7 +162,7 @@ class FAQ extends VerySimpleModel {
 
     function printPdf() {
         global $thisstaff;
-        require_once(INCLUDE_DIR.'mpdf/mpdf.php');
+        require_once(INCLUDE_DIR.'class.pdf.php');
 
         $paper = 'Letter';
         if ($thisstaff)
@@ -180,7 +173,7 @@ class FAQ extends VerySimpleModel {
         include STAFFINC_DIR . 'templates/faq-print.tmpl.php';
         $html = ob_get_clean();
 
-        $pdf = new mPDF('', $paper);
+        $pdf = new mPDFWithLocalImages('', $paper);
         // Setup HTML writing and load default thread stylesheet
         $pdf->WriteHtml(
             '<style>
@@ -190,7 +183,7 @@ class FAQ extends VerySimpleModel {
             .thread-body { font-family: serif; }'
             .file_get_contents(ROOT_DIR.'css/thread.css')
             .'</style>'
-            .'<div>'.$html.'</div>');
+            .'<div>'.$html.'</div>', 0, true, true);
 
         $pdf->Output(Format::slugify($faq->getQuestion()) . '.pdf', 'I');
     }
@@ -215,9 +208,12 @@ class FAQ extends VerySimpleModel {
     function getLocalQuestion($lang=false) {
         return $this->_getLocal('question', $lang);
     }
-    function getLocalAnswerWithImages($lang=false) {
+    function getLocalAnswer($lang=false) {
         return $this->_getLocal('answer', $lang);
     }
+    function getLocalAnswerWithImages($lang=false) {
+        return Format::viewableImages($this->getLocalAnswer($lang));
+    }
     function _getLocal($what, $lang=false) {
         if (!$lang) {
             $lang = $this->getDisplayLang();
@@ -241,8 +237,10 @@ class FAQ extends VerySimpleModel {
     }
 
     function getLocalAttachments($lang=false) {
-        return $this->attachments->getSeparates(
-            $lang ?: $this->getDisplayLang());
+        return $this->attachments->getSeparates()->filter(Q::any(array(
+            'lang__isnull' => true,
+            'lang' => $lang ?: $this->getDisplayLang(),
+        )));
     }
 
     function updateTopics($ids){
@@ -314,16 +312,18 @@ class FAQ extends VerySimpleModel {
         return true;
     }
 
-    function getVisibleAttachments() {
-        return array_merge(
-            $this->attachments->getSeparates()->all() ?: array(),
-            $this->getLocalAttachments()->all());
+    function getAttachments($lang=false) {
+        $att = $this->attachments;
+        if ($lang)
+            $att = $att->window(array('lang'=>$lang));
+
+        return $att;
     }
 
     function getAttachmentsLinks($separator=' ',$target='') {
 
         $str='';
-        if ($attachments = $this->getVisibleAttachments()) {
+        if ($attachments = $this->getLocalAttachments()->all()) {
             foreach($attachments as $attachment ) {
             /* The h key must match validation in file.php */
             if($attachment['size'])
@@ -441,15 +441,15 @@ class FAQ extends VerySimpleModel {
         // Delete removed attachments.
         if (isset($vars['files'])) {
             $keepers = $vars['files'];
-            if (($attachments = $this->attachments->getSeparates())) {
-                foreach($attachments as $file) {
-                    if($file['id'] && !in_array($file['id'], $keepers))
-                        $this->attachments->delete($file['id']);
-                }
-            }
         }
-        // Upload new attachments IF any.
-        $this->attachments->upload($keepers);
+        else {
+            $keepers = array();
+        }
+
+        $images = Draft::getAttachmentIds($vars['answer']);
+        $images = array_map(function($i) { return $i['id']; }, $images);
+        $keepers = array_merge($keepers, $images);
+        $this->getAttachments()->keepOnlyFileIds($keepers);
 
         // Handle language-specific attachments
         // ----------------------
@@ -463,22 +463,12 @@ class FAQ extends VerySimpleModel {
 
                 $keepers = $vars['files_'.$lang];
 
-                // Delete removed attachments.
-                if (($attachments = $this->attachments->getSeparates($lang))) {
-                    foreach ($attachments as $file) {
-                        if ($file['id'] && !in_array($file['id'], $keepers))
-                            $this->attachments->delete($file['id']);
-                    }
-                }
-                // Upload new attachments IF any.
-                $this->attachments->upload($keepers, false, $lang);
+                // FIXME: Include inline images in translated content
+
+                $this->getAttachments()->keepOnlyFileIds($keepers, false, $lang);
             }
         }
 
-        // Inline images (attached to the draft)
-        $this->attachments->deleteInlines();
-        $this->attachments->upload(Draft::getAttachmentIds($vars['answer']));
-
         if (isset($vars['trans']) && !$this->saveTranslations($vars))
             return false;
 
diff --git a/include/class.forms.php b/include/class.forms.php
index 38cb31ef7639550239107deea6c818599ab9501b..f1745be92f6e830d5c06fab30c5c641da1e0db22 100644
--- a/include/class.forms.php
+++ b/include/class.forms.php
@@ -119,6 +119,9 @@ class Form {
                 if (!$field->hasData())
                     continue;
 
+                // Prefer indexing by field.id if indexing numerically
+                if (is_int($key) && $field->get('id'))
+                    $key = $field->get('id');
                 $this->_clean[$key] = $this->_clean[$field->get('name')]
                     = $field->getClean();
             }
@@ -2233,7 +2236,7 @@ class FileUploadField extends FormField {
         if (!isset($this->attachments) && ($a = $this->getAnswer())
             && ($e = $a->getEntry()) && ($e->get('id'))
         ) {
-            $this->attachments = new GenericAttachments(
+            $this->attachments = GenericAttachments::forIdAndType(
                 // Combine the field and entry ids to make the key
                 sprintf('%u', crc32('E'.$this->get('id').$e->get('id'))),
                 'E');
@@ -2304,19 +2307,7 @@ class FileUploadField extends FormField {
     function to_database($value) {
         $this->getFiles();
         if (isset($this->attachments)) {
-            $ids = array();
-            // Handle deletes
-            foreach ($this->attachments->getAll() as $f) {
-                if (!in_array($f->id, $value))
-                    $this->attachments->delete($f->id);
-                else
-                    $ids[] = $f->id;
-            }
-            // Handle new files
-            foreach ($value as $id) {
-                if (!in_array($id, $ids))
-                    $this->attachments->upload($id);
-            }
+            $this->attachments->keepOnlyFileIds($value);
         }
         return JsonDataEncoder::encode($value);
     }
@@ -3037,19 +3028,26 @@ class FileUploadWidget extends Widget {
         $mimetypes = array_filter($config['__mimetypes'],
             function($t) { return strpos($t, '/') !== false; }
         );
-        $files = array();
-        foreach ($this->value ?: array() as $fid) {
-            $found = false;
-            foreach ($attachments as $f) {
-                $file = $f->file;
-                $files[] = array(
-                    'id' => $file->getId(),
-                    'name' => $file->getName(),
-                    'type' => $file->getType(),
-                    'size' => $file->getSize(),
-                    'download_url' => $file->getDownloadUrl(),
-                );
-            }
+        $files = $F = array();
+        $new = array_fill_keys($this->field->getClean(), 1);
+        foreach ($attachments as $f) {
+            $F[] = $f->file;
+            unset($new[$file->id]);
+        }
+        // Add in newly added files not yet saved (if redisplaying after an
+        // error)
+        if ($new) {
+            $F = array_merge($F, AttachmentFile::objects()->filter(array(
+                'id__in' => array_keys($new)))->all());
+        }
+        foreach ($F as $file) {
+            $files[] = array(
+                'id' => $file->getId(),
+                'name' => $file->getName(),
+                'type' => $file->getType(),
+                'size' => $file->getSize(),
+                'download_url' => $file->getDownloadUrl(),
+            );
         }
         ?><div id="<?php echo $id;
             ?>" class="filedrop"><div class="files"></div>
@@ -3081,10 +3079,9 @@ class FileUploadWidget extends Widget {
     }
 
     function getValue() {
-        $data = $this->field->getSource();
-        $ids = array();
         // Handle manual uploads (IE<10)
         if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_FILES[$this->name])) {
+            $ids = array();
             foreach (AttachmentFile::format($_FILES[$this->name]) as $file) {
                 try {
                     $F = $this->field->uploadFile($file);
@@ -3095,10 +3092,7 @@ class FileUploadWidget extends Widget {
             return array_merge($ids, parent::getValue() ?: array());
         }
         // If no value was sent, assume an empty list
-        elseif ($data && is_array($data) && !isset($data[$this->name]))
-            return array();
-
-        return parent::getValue();
+        return parent::getValue() ?: array();
     }
 }
 
diff --git a/include/class.i18n.php b/include/class.i18n.php
index 5d5000b2a9b06bafbdad7025ea918f2308d7560e..bc7634329ea581a81727b0cf80c80a1a490b1f7f 100644
--- a/include/class.i18n.php
+++ b/include/class.i18n.php
@@ -146,12 +146,9 @@ class Internationalization {
         if (($tpl = $this->getTemplate('templates/premade.yaml'))
                 && ($canned = $tpl->getData())) {
             foreach ($canned as $c) {
-                if (($id = Canned::create($c, $errors))
+                if (($premade = Canned::create($c))
                         && isset($c['attachments'])) {
-                    $premade = Canned::lookup($id);
-                    foreach ($c['attachments'] as $a) {
-                        $premade->attachments->save($a, false);
-                    }
+                    $premade->attachments->upload($c['attachments']);
                 }
             }
         }
diff --git a/include/class.orm.php b/include/class.orm.php
index 39ad7728b7f8030226c1e97379f9b59a45acc419..326736f81012d171fd0c9f502025aec956f9b157 100644
--- a/include/class.orm.php
+++ b/include/class.orm.php
@@ -96,6 +96,12 @@ class ModelMeta implements ArrayAccess {
                     = explode('.', $foreign);
             }
         }
+        if ($j['list'] && !isset($j['broker'])) {
+            $j['broker'] = 'InstrumentedList';
+        }
+        if ($j['broker'] && !class_exists($j['broker'])) {
+            throw new OrmException($j['broker'] . ': List broker does not exist');
+        }
         foreach ($constraint as $local => $foreign) {
             list($class, $field) = $foreign;
             if ($local[0] == "'" || $field[0] == "'" || !class_exists($class))
@@ -193,7 +199,7 @@ class VerySimpleModel {
                     $fkey[$F ?: $_klas] = ($local[0] == "'")
                         ? trim($local, "'") : $this->ht[$local];
                 }
-                $v = $this->ht[$field] = new InstrumentedList(
+                $v = $this->ht[$field] = new $j['broker'](
                     // Send Model, [Foriegn-Field => Local-Id]
                     array($class, $fkey)
                 );
@@ -1381,7 +1387,11 @@ class InstrumentedList extends ModelInstanceManager {
 
     function add($object, $at=false) {
         if (!$object || !$object instanceof $this->model)
-            throw new Exception(__('Attempting to add invalid object to list'));
+            throw new Exception(sprintf(
+                'Attempting to add invalid object to list. Expected <%s>, but got <%s>',
+                $this->model,
+                get_class($object)
+            ));
 
         foreach ($this->key as $field=>$value)
             $object->set($field, $value);
@@ -1407,6 +1417,23 @@ class InstrumentedList extends ModelInstanceManager {
         $this->cache = array();
     }
 
+    /**
+     * Reduce the list to a subset using a simply key/value constraint. New
+     * items added to the subset will have the constraint automatically
+     * added to all new items.
+     */
+    function window($constraint) {
+        $model = $this->model;
+        $meta = $model::$meta;
+        $key = $this->key;
+        foreach ($constraint as $field=>$value) {
+            if (!is_string($field) || false === in_array($field, $meta['fields']))
+                throw new OrmException('InstrumentedList windowing must be performed on local fields only');
+            $key[$field] = $value;
+        }
+        return new static(array($this->model, $key), $this->filter($constraint));
+    }
+
     // QuerySet delegates
     function count() {
         return $this->objects()->count();
diff --git a/include/class.page.php b/include/class.page.php
index a49a7b263d12632ee662aba4c1288c2bcf4616b9..69596e88257fb00a167af3f7b99897ced071b10c 100644
--- a/include/class.page.php
+++ b/include/class.page.php
@@ -24,16 +24,20 @@ class Page extends VerySimpleModel {
             'topics' => array(
                 'reverse' => 'Topic.page',
             ),
+            'attachments' => array(
+                'constraint' => array(
+                    "'P'" => 'Attachment.type',
+                    'id' => 'Attachment.object_id',
+                ),
+                'list' => true,
+                'null' => true,
+                'broker' => 'GenericAttachments',
+            ),
         ),
     );
 
-    var $attachments;
     var $_local;
 
-    function __onload() {
-        $this->attachments = new GenericAttachments($this->id, 'P');
-    }
-
     function getId() {
         return $this->id;
     }
@@ -285,9 +289,9 @@ class Page extends VerySimpleModel {
             $rv = $this->saveTranslations($vars, $errors);
 
         // Attach inline attachments from the editor
-        $this->attachments->deleteInlines();
-        $this->attachments->upload(
-            Draft::getAttachmentIds($vars['body']), true);
+        $keepers = Draft::getAttachmentIds($vars['body']);
+        $keepers = array_map(function($i) { return $i['id']; }, $keepers);
+        $this->attachments->keepOnlyFileIds($keepers, true);
 
         if ($rv)
             return $rv;
diff --git a/include/class.pdf.php b/include/class.pdf.php
index 32bc4b8810ecb66d5f9b4cbadea0a164ff7ed790..20006d01ec6788b1ad9318a12166212882420b6b 100644
--- a/include/class.pdf.php
+++ b/include/class.pdf.php
@@ -18,7 +18,36 @@ define('THIS_DIR', str_replace('\\', '/', Misc::realpath(dirname(__FILE__))) . '
 
 require_once(INCLUDE_DIR.'mpdf/mpdf.php');
 
-class Ticket2PDF extends mPDF
+class mPDFWithLocalImages extends mPDF {
+    function WriteHtml($html) {
+        static $filenumber = 1;
+        $args = func_get_args();
+        $self = $this;
+        $images = $cids = array();
+        // Try and get information for all the files in one query
+        if (preg_match_all('/"cid:([\w._-]{32})"/', $html, $cids)) {
+            foreach (AttachmentFile::objects()
+                ->filter(array('key__in' => $cids[1]))
+                as $file
+            ) {
+                $images[strtolower($file->getKey())] = $file;
+            }
+        }
+        $args[0] = preg_replace_callback('/"cid:([\w.-]{32})"/',
+            function($match) use ($self, $images, &$filenumber) {
+                if (!($file = @$images[strtolower($match[1])]))
+                    return $match[0];
+                $key = "__attached_file_".$filenumber++;
+                $self->{$key} = $file->getData();
+                return 'var:'.$key;
+            },
+            $html
+        );
+        return call_user_func_array(array('parent', 'WriteHtml'), $args);
+    }
+}
+
+class Ticket2PDF extends mPDFWithLocalImages
 {
 
 	var $includenotes = false;
@@ -42,24 +71,6 @@ class Ticket2PDF extends mPDF
         return $this->ticket;
     }
 
-    function WriteHtml() {
-        static $filenumber = 1;
-        $args = func_get_args();
-        $text = &$args[0];
-        $self = $this;
-        $text = preg_replace_callback('/cid:([\w.-]{32})/',
-            function($match) use ($self, &$filenumber) {
-                if (!($file = AttachmentFile::lookup($match[1])))
-                    return $match[0];
-                $key = "__attached_file_".$filenumber++;
-                $self->{$key} = $file->getData();
-                return 'var:'.$key;
-            },
-            $text
-        );
-        call_user_func_array(array('parent', 'WriteHtml'), $args);
-    }
-
     function _print() {
         global $thisstaff, $thisclient, $cfg;
 
diff --git a/include/class.template.php b/include/class.template.php
index c1bdb80df2135a6b81c09a33c6d7973dbd03c5a4..18c04548731719bcc09a169d50f0d639dc437a8c 100644
--- a/include/class.template.php
+++ b/include/class.template.php
@@ -507,7 +507,7 @@ class EmailTemplate {
 
         $this->ht=db_fetch_array($res);
         $this->id=$this->ht['id'];
-        $this->attachments = new GenericAttachments($this->id, 'T');
+        $this->attachments = GenericAttachments::forIdAndType($this->id, 'T');
 
         return true;
     }
@@ -586,12 +586,10 @@ class EmailTemplate {
         $this->reload();
 
         // Inline images (attached to the draft)
-        if (isset($vars['draft_id']) && $vars['draft_id']) {
-            if ($draft = Draft::lookup($vars['draft_id'])) {
-                $this->attachments->deleteInlines();
-                $this->attachments->upload($draft->getAttachmentIds($this->getBody()), true);
-            }
-        }
+        $keepers = Draft::getAttachmentIds($this->getBody());
+        // Just keep the IDs only
+        $keepers = array_map(function($i) { return $i['id']; }, $keepers);
+        $this->attachments->keepOnlyFileIds($keepers, true);
 
         return true;
     }
diff --git a/include/client/faq.inc.php b/include/client/faq.inc.php
index b879645c8f54d92ccc6b6ab99538d344d80564b5..36c25d5cfe2912d52dfb8984dbd15dca7c80aaf1 100644
--- a/include/client/faq.inc.php
+++ b/include/client/faq.inc.php
@@ -17,10 +17,11 @@ $category=$faq->getCategory();
 <div class="article-title flush-left">
 <?php echo $faq->getLocalQuestion() ?>
 </div>
-<div class="faded"><?php echo __('Last updated').' '.Format::daydatetime($category->getUpdateDate()); ?></div>
+<div class="faded"><?php echo __('Last updated').' '
+    . Format::relativeTime(Misc::db2gmtime($category->getUpdateDate())); ?></div>
 <br/>
 <div class="thread-body bleed">
-<?php echo Format::safe_html($faq->getLocalAnswerWithImages()); ?>
+<?php echo $faq->getLocalAnswerWithImages(); ?>
 </div>
 </div>
 </div>
@@ -35,8 +36,8 @@ $category=$faq->getCategory();
     <input type="submit" style="display:none" value="search"/>
     </form>
 </div>
-<div class="content">
-<?php if ($attachments = $faq->getVisibleAttachments()) { ?>
+<div class="content"><?php
+    if ($attachments = $faq->getLocalAttachments()->all()) { ?>
 <section>
     <strong><?php echo __('Attachments');?>:</strong>
 <?php foreach ($attachments as $att) { ?>
@@ -48,17 +49,16 @@ $category=$faq->getCategory();
     </div>
 <?php } ?>
 </section>
-<?php } ?>
-
-<?php if ($faq->getHelpTopics()->count()) { ?>
+<?php }
+if ($faq->getHelpTopics()->count()) { ?>
 <section>
     <strong><?php echo __('Help Topics'); ?></strong>
 <?php foreach ($faq->getHelpTopics() as $topic) { ?>
     <div><?php echo $topic->getFullName(); ?></div>
 <?php } ?>
 </section>
-<?php } ?>
-</div>
+<?php }
+?></div>
 </div>
 </div>
 
diff --git a/include/staff/cannedresponse.inc.php b/include/staff/cannedresponse.inc.php
index 4679c14c0fb1aab857019f0bc0c63a1fa45be717..930052225625746cc9b088d608e11407f85ee2f0 100644
--- a/include/staff/cannedresponse.inc.php
+++ b/include/staff/cannedresponse.inc.php
@@ -93,12 +93,8 @@ $info=Format::htmlchars(($errors && $_POST)?$_POST:$info);
                 </div>
                 <?php
                 $attachments = $canned_form->getField('attachments');
-                if ($canned) {
+                if ($canned && $attachments) {
                     $attachments->setAttachments($canned->attachments);
-                    if ($files = $canned->getAttachedFiles()) {
-                        $ids = $files->values_flat('id')->all();
-                        $attachments->value = $ids;
-                    }
                 }
                 print $attachments->render(); ?>
                 <br/>
diff --git a/include/staff/faq-categories.inc.php b/include/staff/faq-categories.inc.php
index 5e55d7ff69a6d8d86a29d8bda2c628896dfdf65e..d1b7316a8dcaa06c01bae05805a766790e9d961d 100644
--- a/include/staff/faq-categories.inc.php
+++ b/include/staff/faq-categories.inc.php
@@ -15,7 +15,6 @@ if(!defined('OSTSTAFFINC') || !$thisstaff) die('Access Denied');
                 ->annotate(array('faq_count'=>SqlAggregate::COUNT('faqs')))
                 ->filter(array('faq_count__gt'=>0))
                 ->order_by('name');
-print $categories;
             foreach ($categories as $C) {
                 echo sprintf('<option value="%d" %s>%s (%d)</option>',
                     $C->getId(),
diff --git a/include/staff/faq-view.inc.php b/include/staff/faq-view.inc.php
index 8afda9a67e320f6636f9fe4119c011561ea35a58..05a6b0f48cf50d15087677dcb286707f0003c944 100644
--- a/include/staff/faq-view.inc.php
+++ b/include/staff/faq-view.inc.php
@@ -12,14 +12,14 @@ $category=$faq->getCategory();
 </div>
 
 <div class="pull-right sidebar faq-meta">
-<?php if ($attachments = $faq->getVisibleAttachments()) { ?>
+<?php if ($attachments = $faq->getLocalAttachments()->all()) { ?>
 <section>
     <strong><?php echo __('Attachments');?>:</strong>
 <?php foreach ($attachments as $att) { ?>
     <div>
-    <a href="file.php?h=<?php echo $att['download']; ?>" class="no-pjax">
+    <a href="<?php echo $att->file->getDownloadUrl(); ?>" class="no-pjax">
         <i class="icon-file"></i>
-        <?php echo Format::htmlchars($att['name']); ?>
+        <?php echo Format::htmlchars($att->file->name); ?>
     </a>
     </div>
 <?php } ?>
@@ -67,24 +67,22 @@ if ($otherLangs) { ?>
 
 <div class="faq-content">
 <div class="faq-manage pull-right">
-    <button>
-    <i class="icon-print"></i>
 <?php
 $query = array();
 parse_str($_SERVER['QUERY_STRING'], $query);
 $query['a'] = 'print';
 $query['id'] = $faq->getId();
 $query = http_build_query($query); ?>
-    <a href="faq.php?<?php echo $query; ?>" class="no-pjax"><?php
-        echo __('Print'); ?>
-    </a></button>
+    <a href="faq.php?<?php echo $query; ?>" class="no-pjax action-button">
+    <i class="icon-print"></i>
+        <?php echo __('Print'); ?>
+    </a>
 <?php
 if ($thisstaff->getRole()->hasPerm(FAQ::PERM_MANAGE)) { ?>
-    <button>
+    <a href="faq.php?id=<?php echo $faq->getId(); ?>&a=edit" class="action-button">
     <i class="icon-edit"></i>
-    <a href="faq.php?id=<?php echo $faq->getId(); ?>&a=edit"><?php
-        echo __('Edit FAQ'); ?>
-    </a></button>
+        <?php echo __('Edit FAQ'); ?>
+    </a>
 <?php } ?>
 </div>
 
@@ -92,7 +90,7 @@ if ($thisstaff->getRole()->hasPerm(FAQ::PERM_MANAGE)) { ?>
 </div>
 
 <div class="faded"><?php echo __('Last updated');?>
-    <?php echo Format::daydatetime($category->getUpdateDate()); ?>
+    <?php echo Format::relativeTime(Misc::db2gmtime($category->getUpdateDate())); ?>
 </div>
 <br/>
 <div class="thread-body bleed">
diff --git a/include/staff/login.header.php b/include/staff/login.header.php
index 7cf18d895239f5a9425428cb112d6a1aba1827a1..d4068027840b811e2641f4870e6cd3927603e4f9 100644
--- a/include/staff/login.header.php
+++ b/include/staff/login.header.php
@@ -13,7 +13,7 @@ defined('OSTSCPINC') or die('Invalid path');
     <meta http-equiv="cache-control" content="no-cache" />
     <meta http-equiv="pragma" content="no-cache" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
-    <script type="text/javascript" src="<?php echo ROOT_PATH; ?>js/jquery-1.8.3.min.js"></script>
+    <script type="text/javascript" src="<?php echo ROOT_PATH; ?>js/jquery-1.11.2.min.js"></script>
     <script type="text/javascript">
         $(document).ready(function() {
             $("input:not(.dp):visible:enabled:first").focus();
diff --git a/include/staff/templates/faq-print.tmpl.php b/include/staff/templates/faq-print.tmpl.php
index 76842eeb37dec37a27f1695f570ecedfc93f761f..6f07f898dbd0c00510b2af5e63975c1c9c095e59 100644
--- a/include/staff/templates/faq-print.tmpl.php
+++ b/include/staff/templates/faq-print.tmpl.php
@@ -8,5 +8,5 @@
 <br/>
 
 <div class="thread-body bleed">
-<?php echo $faq->getLocalAnswerWithImages(); ?>
+<?php echo $faq->getLocalAnswer(); ?>
 </div>
diff --git a/scp/canned.php b/scp/canned.php
index 129d1993bea8870d09585dbb98e9900a7d0ff2ad..85af1b2c46e4420a0e7528f95a33e76d8ab96060 100644
--- a/scp/canned.php
+++ b/scp/canned.php
@@ -48,27 +48,17 @@ if ($_POST) {
                 //Delete removed attachments.
                 //XXX: files[] shouldn't be changed under any circumstances.
                 $keepers = $canned_form->getField('attachments')->getClean();
-                $attachments = $canned->attachments->getSeparates(); //current list of attachments.
-                foreach($attachments as $k=>$file) {
-                    if(isset($file->id) && !in_array($file->id, $keepers)) {
-                        $canned->attachments->delete($file->id);
-                    }
-                }
-
-                //Upload NEW attachments IF ANY - TODO: validate attachment types??
-                if ($keepers)
-                    $canned->attachments->upload($keepers);
 
                 // Attach inline attachments from the editor
                 if (isset($_POST['draft_id'])
                         && ($draft = Draft::lookup($_POST['draft_id']))) {
-                    $canned->attachments->deleteInlines();
-                    $canned->attachments->upload(
-                        $draft->getAttachmentIds($_POST['response']),
-                        true);
+                    $images = $draft->getAttachmentIds($_POST['response']);
+                    $images = array_map(function($i) { return $i['id']; }, $images);
+                    $keepers = array_merge($keepers, $images);
                 }
 
-                $canned->reload();
+                // Upload NEW attachments IF ANY - TODO: validate attachment types??
+                $canned->attachments->keepOnlyFileIds($keepers);
 
                 // XXX: Handle nicely notifying a user that the draft was
                 // deleted | OR | show the draft for the user on the name
@@ -83,18 +73,19 @@ if ($_POST) {
             }
             break;
         case 'create':
-            if(($id=Canned::create($_POST, $errors))) {
+            $premade = FAQ::create();
+            if ($premade->update($_POST,$errors)) {
                 $msg=sprintf(__('Successfully added %s'), Format::htmlchars($_POST['title']));
                 $_REQUEST['a']=null;
                 //Upload attachments
                 $keepers = $canned_form->getField('attachments')->getClean();
-                if (($c=Canned::lookup($id)) && $keepers)
-                    $c->attachments->upload($keepers);
+                if ($keepers)
+                    $premade->attachments->upload($keepers);
 
                 // Attach inline attachments from the editor
-                if ($c && isset($_POST['draft_id'])
+                if (isset($_POST['draft_id'])
                         && ($draft = Draft::lookup($_POST['draft_id'])))
-                    $c->attachments->upload(
+                    $premade->attachments->upload(
                         $draft->getAttachmentIds($_POST['response']), true);
 
                 // Delete this user's drafts for new canned-responses
diff --git a/scp/faq.php b/scp/faq.php
index 09b0d6bcb04a0b1cb0c9cfca40786b4c7b26625d..bac34c7ceefe4b8cd1c30bd396c93cab2745bc02 100644
--- a/scp/faq.php
+++ b/scp/faq.php
@@ -130,23 +130,13 @@ else {
         // Multi-lingual system
         foreach ($langs as $lang) {
             $attachments = $faq_form->getField('attachments.'.$lang);
-            if ($files = $faq->attachments->getSeparates($lang)) {
-                $ids = array();
-                foreach ($files as $f)
-                    $ids[] = $f['id'];
-                $attachments->value = $ids;
-            }
+            $attachments->setAttachments($faq->getAttachments($lang));
         }
     }
     if ($faq) {
         // Common attachments
         $attachments = $faq_form->getField('attachments');
-        if ($files = $faq->attachments->getSeparates()) {
-            $ids = array();
-            foreach ($files as $f)
-                $ids[] = $f['id'];
-            $attachments->value = $ids;
-        }
+        $attachments->setAttachments($faq->getAttachments());
     }
 }
 
diff --git a/scp/js/scp.js b/scp/js/scp.js
index 437ce5f6f3f2d59317ed1b3621a42bfa6879a095..e356e825e7810bdf1c92891efa61af38a6f58cde 100644
--- a/scp/js/scp.js
+++ b/scp/js/scp.js
@@ -632,7 +632,10 @@ $.dialog = function (url, codes, cb, options) {
         $('div.body', $popup).slideDown({
             duration: 300,
             queue: false,
-            complete: function() { if (options.onshow) options.onshow(); }
+            complete: function() {
+                if (options.onshow) options.onshow();
+                $(this).removeAttr('style');
+            }
         });
         $("input[autofocus]:visible:enabled:first", $popup).focus();
         var submit_button = null;