1 # -*- encoding: utf-8 -*-
4 from tarfile import filemode
14 from service import security
16 from document.nodes import node_res_dir, node_res_obj
20 logger = netsvc.Logger()
21 logger.notifyChannel('DMS', netsvc.LOG_ERROR, message)
24 def _get_month_name(month):
26 if month==1:return 'Jan'
27 elif month==2:return 'Feb'
28 elif month==3:return 'Mar'
29 elif month==4:return 'Apr'
30 elif month==5:return 'May'
31 elif month==6:return 'Jun'
32 elif month==7:return 'Jul'
33 elif month==8:return 'Aug'
34 elif month==9:return 'Sep'
35 elif month==10:return 'Oct'
36 elif month==11:return 'Nov'
37 elif month==12:return 'Dec'
41 return s.decode('utf-8')
44 return s.decode('latin')
47 return s.encode('ascii')
53 return s.encode('utf-8')
56 return s.encode('latin')
59 return s.decode('ascii')
64 class file_wrapper(StringIO.StringIO):
65 def __init__(self, sstr='', ressource_id=False, dbname=None, uid=1, name=''):
66 StringIO.StringIO.__init__(self, sstr)
67 self.ressource_id = ressource_id
71 def close(self, *args, **kwargs):
72 db,pool = pooler.get_db_and_pool(self.dbname)
78 'datas': base64.encodestring(val),
79 'file_size': len(val),
81 pool.get('ir.attachment').write(cr, self.uid, [self.ressource_id], val2)
85 StringIO.StringIO.close(self, *args, **kwargs)
87 class content_wrapper(StringIO.StringIO):
88 def __init__(self, dbname, uid, pool, node, name=''):
89 StringIO.StringIO.__init__(self, '')
95 def close(self, *args, **kwargs):
96 db,pool = pooler.get_db_and_pool(self.dbname)
100 getattr(self.pool.get('document.directory.content'), 'process_write_'+self.node.content.extension[1:])(cr, self.uid, self.node, self.getvalue())
104 StringIO.StringIO.close(self, *args, **kwargs)
108 """A class used to interact with the file system, providing a high
109 level, cross-platform interface compatible with both Windows and
110 UNIX style filesystems.
112 It provides some utility methods and some wraps around operations
113 involved in file creation and file system operations like moving
114 files or removing directories.
117 - (str) root: the user home directory.
118 - (str) cwd: the current working directory.
119 - (str) rnfr: source file to be renamed.
124 #return pooler.pool_dic.keys()
125 s = netsvc.ExportService.getService('db')
126 result = s.exp_list()
127 self.db_name_list = []
128 for db_name in result:
132 db = pooler.get_db_only(db_name)
134 cr.execute("SELECT 1 FROM pg_class WHERE relkind = 'r' AND relname = 'ir_module_module'")
135 if not cr.fetchone():
138 cr.execute("select id from ir_module_module where name like 'document%' and state='installed' ")
141 self.db_name_list.append(db_name)
149 # pooler.close_db(db_name)
150 return self.db_name_list
158 # --- Pathname / conversion utilities
161 def ftpnorm(self, ftppath):
162 """Normalize a "virtual" ftp pathname (tipically the raw string
163 coming from client) depending on the current working directory.
165 Example (having "/foo" as current working directory):
168 Note: directory separators are system independent ("/").
169 Pathname returned is always absolutized.
171 if os.path.isabs(ftppath):
172 p = os.path.normpath(ftppath)
174 p = os.path.normpath(os.path.join(self.cwd, ftppath))
175 # normalize string in a standard web-path notation having '/'
177 p = p.replace("\\", "/")
178 # os.path.normpath supports UNC paths (e.g. "//a/b/c") but we
179 # don't need them. In case we get an UNC path we collapse
180 # redundant separators appearing at the beginning of the string
183 # Anti path traversal: don't trust user input, in the event
184 # that self.cwd is not absolute, return "/" as a safety measure.
185 # This is for extra protection, maybe not really necessary.
186 if not os.path.isabs(p):
191 def ftp2fs(self, path_orig, data):
192 path = self.ftpnorm(path_orig)
193 if not data or (path and path=='/'):
195 path2 = filter(None,path.split('/'))[1:]
196 (cr, uid, pool) = data
198 path2[-1]=_to_unicode(path2[-1])
199 res = pool.get('document.directory').get_object(cr, uid, path2[:])
201 raise OSError(2, 'Not such file or directory.')
205 def fs2ftp(self, node):
208 res = os.path.normpath(''.join(node.full_path()))
209 res = res.replace("\\", "/")
210 while res[:2] == '//':
212 res = '/' + node.context.dbname + '/' + _to_decode(res)
214 #res = node and ('/' + node.cr.dbname + '/' + _to_decode(self.ftpnorm(node.path))) or '/'
218 def validpath(self, path):
219 """Check whether the path belongs to user's home directory.
220 Expected argument is a "real" filesystem pathname.
222 If path is a symbolic link it is resolved to check its real
225 Pathnames escaping from user's root directory are considered
228 return path and True or False
230 # --- Wrapper methods around open() and tempfile.mkstemp
233 def create(self, node, objname, mode):
234 objname = _to_unicode(objname)
237 uid = node.context.uid
238 pool = pooler.get_pool(node.context.dbname)
239 cr = pooler.get_db(node.context.dbname).cursor()
240 child = node.child(cr, objname)
242 if child.type in ('collection','database'):
243 raise OSError(1, 'Operation not permited.')
244 if child.type == 'content':
245 s = content_wrapper(node.context.dbname, uid, pool, child)
247 fobj = pool.get('ir.attachment')
248 ext = objname.find('.') >0 and objname.split('.')[1] or False
250 # TODO: test if already exist and modify in this case if node.type=file
251 ### checked already exits
253 if isinstance(node, node_res_obj):
254 object2 = node and pool.get(node.context.context['res_model']).browse(cr, uid, node.context.context['res_id']) or False
257 object = node.context._dirobj.browse(cr, uid, node.dir_id)
258 where = [('name','=',objname)]
259 if object and (object.type in ('directory')) or object2:
260 where.append(('parent_id','=',object.id))
262 where.append(('parent_id','=',False))
265 where += [('res_id','=',object2.id),('res_model','=',object2._name)]
266 cids = fobj.search(cr, uid,where)
273 'datas_fname': objname,
274 'parent_id' : node.dir_id,
278 'store_method' : (object.storage_id.type == 'filestore' and 'fs')\
279 or (object.storage_id.type == 'db' and 'db')
281 if object and (object.type in ('directory')) or not object2:
282 val['parent_id']= object and object.id or False
285 if 'partner_id' in object2 and object2.partner_id.id:
286 partner = object2.partner_id.id
287 if object2._name == 'res.partner':
290 'res_model': object2._name,
291 'partner_id': partner,
294 cid = fobj.create(cr, uid, val, context={})
297 s = file_wrapper('', cid, node.context.dbname, uid, )
301 raise OSError(1, 'Operation not permited.')
307 def open(self, node, mode):
309 raise OSError(1, 'Operation not permited.')
311 if node.type=='file':
312 cr = pooler.get_db(node.context.dbname).cursor()
313 uid = node.context.uid
314 if not self.isfile(node):
315 raise OSError(1, 'Operation not permited.')
316 fobj = node.context._dirobj.pool.get('ir.attachment').browse(cr, uid, node.file_id, context=node.context.context)
317 if fobj.store_method and fobj.store_method== 'fs' :
318 s = StringIO.StringIO(node.get_data(cr, fobj))
320 s = StringIO.StringIO(base64.decodestring(fobj.db_datas or ''))
324 elif node.type=='content':
325 uid = node.context.uid
326 cr = pooler.get_db(node.context.dbname).cursor()
327 pool = pooler.get_pool(node.context.dbname)
328 res = getattr(pool.get('document.directory.content'), 'process_read')(cr, uid, node)
332 raise OSError(1, 'Operation not permited.')
334 # ok, but need test more
336 def mkstemp(self, suffix='', prefix='', dir=None, mode='wb'):
337 """A wrap around tempfile.mkstemp creating a file with a unique
338 name. Unlike mkstemp it returns an object with a file-like
341 raise 'Not Yet Implemented'
343 # def __init__(self, fd, name):
346 # def __getattr__(self, attr):
347 # return getattr(self.file, attr)
349 # text = not 'b' in mode
350 # # max number of tries to find out a unique file name
351 # tempfile.TMP_MAX = 50
352 # fd, name = tempfile.mkstemp(suffix, prefix, dir, text=text)
353 # file = os.fdopen(fd, mode)
354 # return FileWrapper(file, name)
356 text = not 'b' in mode
357 # for unique file , maintain version if duplicate file
361 pool = pooler.get_pool(node.context.dbname)
362 object=dir and dir.object or False
363 object2=dir and dir.object2 or False
364 res=pool.get('ir.attachment').search(cr,uid,[('name','like',prefix),('parent_id','=',object and object.type in ('directory','ressource') and object.id or False),('res_id','=',object2 and object2.id or False),('res_model','=',object2 and object2._name or False)])
366 pre = prefix.split('.')
367 prefix=pre[0] + '.v'+str(len(res))+'.'+pre[1]
368 #prefix = prefix + '.'
369 return self.create(dir,suffix+prefix,text)
374 def chdir(self, path):
378 if path.type in ('collection','database'):
379 self.cwd = self.fs2ftp(path)
380 elif path.type in ('file'):
381 parent_path = path.full_path()[:-1]
382 self.cwd = os.path.normpath(''.join(parent_path))
384 raise OSError(1, 'Operation not permited.')
387 def mkdir(self, node, basename):
388 """Create the specified directory."""
391 raise OSError(1, 'Operation not permited.')
393 basename =_to_unicode(basename)
394 cr = pooler.get_db(node.context.dbname).cursor()
395 uid = node.context.uid
396 pool = pooler.get_pool(node.context.dbname)
398 if isinstance(node, node_res_obj):
399 object2 = node and pool.get(node.context.context['res_model']).browse(cr, uid, node.context.context['res_id']) or False
400 obj = node.context._dirobj.browse(cr, uid, node.dir_id)
401 if obj and (obj.type == 'ressource') and not object2:
402 raise OSError(1, 'Operation not permited.')
405 'ressource_parent_type_id': obj and obj.ressource_type_id.id or False,
406 'ressource_id': object2 and object2.id or False,
409 if (obj and (obj.type in ('directory'))) or not object2:
410 val['parent_id'] = obj and obj.id or False
411 # Check if it alreayd exists !
412 pool.get('document.directory').create(cr, uid, val)
416 raise OSError(1, 'Operation not permited.')
421 def close_cr(self, data):
426 def get_cr(self, path):
427 path = self.ftpnorm(path)
430 dbname = path.split('/')[1]
431 if dbname not in self.db_list():
434 db,pool = pooler.get_db_and_pool(dbname)
436 raise OSError(1, 'Operation not permited.')
438 uid = security.login(dbname, self.username, self.password)
440 raise OSError(2, 'Authentification Required.')
444 def listdir(self, path):
445 """List the content of a directory."""
446 class false_node(object):
450 def __init__(self, db):
455 for db in self.db_list():
457 uid = security.login(db, self.username, self.password)
459 result.append(false_node(db))
460 except osv.except_osv:
463 cr = pooler.get_db(path.context.dbname).cursor()
464 res = path.children(cr)
469 def rmdir(self, node):
470 """Remove the specified directory."""
472 cr = pooler.get_db(node.context.dbname).cursor()
473 uid = node.context.uid
474 pool = pooler.get_pool(node.context.dbname)
475 object = node.context._dirobj.browse(cr, uid, node.dir_id)
477 raise OSError(2, 'Not such file or directory.')
478 if object._table_name == 'document.directory':
479 if node.children(cr):
480 raise OSError(39, 'Directory not empty.')
481 res = pool.get('document.directory').unlink(cr, uid, [object.id])
483 raise OSError(1, 'Operation not permited.')
489 def remove(self, node):
491 if node.type == 'collection':
492 return self.rmdir(node)
493 elif node.type == 'file':
494 return self.rmfile(node)
495 raise OSError(1, 'Operation not permited.')
497 def rmfile(self, node):
498 """Remove the specified file."""
500 if node.type == 'collection':
501 return self.rmdir(node)
502 uid = node.context.uid
503 pool = pooler.get_pool(node.context.dbname)
504 cr = pooler.get_db(node.context.dbname).cursor()
505 object = pool.get('ir.attachment').browse(cr, uid, node.file_id)
507 raise OSError(2, 'Not such file or directory.')
508 if object._table_name == 'ir.attachment':
509 res = pool.get('ir.attachment').unlink(cr, uid, [object.id])
511 raise OSError(1, 'Operation not permited.')
516 def rename(self, src, dst_basedir, dst_basename):
518 Renaming operation, the effect depends on the src:
519 * A file: read, create and remove
520 * A directory: change the parent and reassign childs to ressource
524 dst_basename = _to_unicode(dst_basename)
525 cr = pooler.get_db(src.context.dbname).cursor()
526 uid = src.context.uid
527 if src.type == 'collection':
530 pool = pooler.get_pool(src.context.dbname)
531 if isinstance(src, node_res_obj):
532 obj2 = src and pool.get(src.context.context['res_model']).browse(cr, uid, src.context.context['res_id']) or False
533 obj = src.context._dirobj.browse(cr, uid, src.dir_id)
534 if isinstance(dst_basedir, node_res_obj):
535 dst_obj2 = dst_basedir and pool.get(dst_basedir.context.context['res_model']).browse(cr, uid, dst_basedir.context.context['res_id']) or False
536 dst_obj = dst_basedir.context._dirobj.browse(cr, uid, dst_basedir.dir_id)
537 if obj._table_name <> 'document.directory':
538 raise OSError(1, 'Operation not permited.')
543 # Compute all childs to set the new ressource ID
545 while len(child_ids):
546 node = child_ids.pop(0)
547 child_ids += node.children(cr)
548 if node.type == 'collection':
550 if isinstance(node, node_res_obj):
551 object2 = node and pool.get(node.context.context['res_model']).browse(cr, uid, node.context.context['res_id']) or False
552 object = node.context._dirobj.browse(cr, uid, node.dir_id)
553 result['directory'].append(object.id)
554 if (not object.ressource_id) and object2:
555 raise OSError(1, 'Operation not permited.')
556 elif node.type == 'file':
557 result['attachment'].append(object.id)
559 if obj2 and not obj.ressource_id:
560 raise OSError(1, 'Operation not permited.')
562 if (dst_obj and (dst_obj.type in ('directory'))) or not dst_obj2:
563 parent_id = dst_obj and dst_obj.id or False
569 ressource_type_id = pool.get('ir.model').search(cr, uid, [('model','=',dst_obj2._name)])[0]
570 ressource_id = dst_obj2.id
571 title = dst_obj2.name
572 ressource_model = dst_obj2._name
573 if dst_obj2._name == 'res.partner':
574 partner_id = dst_obj2.id
576 partner_id = pool.get(dst_obj2._name).fields_get(cr, uid, ['partner_id']) and dst_obj2.partner_id.id or False
578 ressource_type_id = False
580 ressource_model = False
583 pool.get('document.directory').write(cr, uid, result['directory'], {
584 'name' : dst_basename,
585 'ressource_id': ressource_id,
586 'ressource_parent_type_id': ressource_type_id,
587 'parent_id' : parent_id
590 'res_id': ressource_id,
591 'res_model': ressource_model,
593 'partner_id': partner_id
595 pool.get('ir.attachment').write(cr, uid, result['attachment'], val)
596 if (not val['res_id']) and result['attachment']:
597 cr.execute('update ir_attachment set res_id=NULL where id in ('+','.join(map(str,result['attachment']))+')')
601 elif src.type == 'file':
602 pool = pooler.get_pool(src.context.dbname)
603 obj = pool.get('ir.attachment').browse(cr, uid, src.file_id)
605 if isinstance(dst_basedir, node_res_obj):
606 dst_obj2 = dst_basedir and pool.get(dst_basedir.context.context['res_model']).browse(cr, uid, dst_basedir.context.context['res_id']) or False
607 dst_obj = dst_basedir.context._dirobj.browse(cr, uid, dst_basedir.dir_id)
613 'name': dst_basename,
614 'datas_fname': dst_basename,
615 'title': dst_basename,
618 if (dst_obj and (dst_obj.type in ('directory','ressource'))) or not dst_obj2:
619 val['parent_id'] = dst_obj and dst_obj.id or False
621 val['parent_id'] = False
624 val['res_model'] = dst_obj2._name
625 val['res_id'] = dst_obj2.id
626 val['title'] = dst_obj2.name
627 if dst_obj2._name == 'res.partner':
628 val['partner_id'] = dst_obj2.id
630 val['partner_id'] = pool.get(dst_obj2._name).fields_get(cr, uid, ['partner_id']) and dst_obj2.partner_id.id or False
632 # I had to do that because writing False to an integer writes 0 instead of NULL
633 # change if one day we decide to improve osv/fields.py
634 cr.execute('update ir_attachment set res_id=NULL where id=%s', (obj.id,))
636 pool.get('ir.attachment').write(cr, uid, [obj.id], val)
638 elif src.type=='content':
639 src_file = self.open(src,'r')
640 dst_file = self.create(dst_basedir, dst_basename, 'w')
641 dst_file.write(src_file.getvalue())
646 raise OSError(1, 'Operation not permited.')
647 except Exception,err:
649 raise OSError(1,'Operation not permited.')
656 def stat(self, node):
657 r = list(os.stat('/'))
658 if self.isfile(node):
660 r[6] = self.getsize(node)
661 r[7] = self.getmtime(node)
662 r[8] = self.getmtime(node)
663 r[9] = self.getmtime(node)
664 return os.stat_result(r)
667 # --- Wrapper methods around os.path.*
670 def isfile(self, node):
671 if node and (node.type not in ('collection','database')):
676 def islink(self, path):
677 """Return True if path is a symbolic link."""
681 def isdir(self, node):
682 """Return True if path is a directory."""
685 if node and (node.type in ('collection','database')):
690 def getsize(self, node):
691 """Return the size of the specified file in bytes."""
693 if node.type=='file':
694 result = node.content_length or 0L
698 def getmtime(self, node):
699 """Return the last modified time as a number of seconds since
702 if node.write_date or node.create_date:
703 dt = (node.write_date or node.create_date)[:19]
704 result = time.mktime(time.strptime(dt, '%Y-%m-%d %H:%M:%S'))
706 result = time.mktime(time.localtime())
710 def realpath(self, path):
711 """Return the canonical version of path eliminating any
712 symbolic links encountered in the path (if they are
713 supported by the operating system).
718 def lexists(self, path):
719 """Return True if path refers to an existing path, including
720 a broken or circular symbolic link.
722 return path and True or False
725 # Ok, can be improved
726 def glob1(self, dirname, pattern):
727 """Return a list of files matching a dirname pattern
730 Unlike glob.glob1 raises exception if os.listdir() fails.
732 names = self.listdir(dirname)
733 if pattern[0] != '.':
734 names = filter(lambda x: x.path[0] != '.', names)
735 return fnmatch.filter(names, pattern)
737 # --- Listing utilities
739 # note: the following operations are no more blocking
742 def get_list_dir(self, path):
743 """"Return an iterator object that yields a directory listing
744 in a form suitable for LIST command.
747 listing = self.listdir(path)
749 return self.format_list(path and path.path or '/', listing)
750 # if path is a file or a symlink we return information about it
751 elif self.isfile(path):
752 basedir, filename = os.path.split(path.path)
753 self.lstat(path) # raise exc in case of problems
754 return self.format_list(basedir, [path])
758 def get_stat_dir(self, rawline, datacr):
759 """Return an iterator object that yields a list of files
760 matching a dirname pattern non-recursively in a form
761 suitable for STAT command.
763 - (str) rawline: the raw string passed by client as command
766 ftppath = self.ftpnorm(rawline)
767 if not glob.has_magic(ftppath):
768 return self.get_list_dir(self.ftp2fs(rawline, datacr))
770 basedir, basename = os.path.split(ftppath)
771 if glob.has_magic(basedir):
772 return iter(['Directory recursion not supported.\r\n'])
774 basedir = self.ftp2fs(basedir, datacr)
775 listing = self.glob1(basedir, basename)
778 return self.format_list(basedir, listing)
781 def format_list(self, basedir, listing, ignore_err=True):
782 """Return an iterator object that yields the entries of given
783 directory emulating the "/bin/ls -lA" UNIX command output.
785 - (str) basedir: the absolute dirname.
786 - (list) listing: the names of the entries in basedir
787 - (bool) ignore_err: when False raise exception if os.lstat()
790 On platforms which do not support the pwd and grp modules (such
791 as Windows), ownership is printed as "owner" and "group" as a
792 default, and number of hard links is always "1". On UNIX
793 systems, the actual owner, group, and number of links are
796 This is how output appears to client:
798 -rw-rw-rw- 1 owner group 7045120 Sep 02 3:47 music.mp3
799 drwxrwxrwx 1 owner group 0 Aug 31 18:50 e-books
800 -rw-rw-rw- 1 owner group 380 Sep 02 3:40 module.py
804 st = self.lstat(file)
809 perms = filemode(st.st_mode) # permissions
810 nlinks = st.st_nlink # number of links to inode
811 if not nlinks: # non-posix system, let's use a bogus value
813 size = st.st_size # file size
816 # stat.st_mtime could fail (-1) if last mtime is too old
817 # in which case we return the local time as last mtime
819 mname=_get_month_name(time.strftime("%m", time.localtime(st.st_mtime)))
820 mtime = mname+' '+time.strftime("%d %H:%M", time.localtime(st.st_mtime))
822 mname=_get_month_name(time.strftime("%m"))
823 mtime = mname+' '+time.strftime("%d %H:%M")
824 # formatting is matched with proftpd ls output
825 path=_to_decode(file.path) #file.path.encode('ascii','replace').replace('?','_')
826 yield "%s %3s %-8s %-8s %8s %s %s\r\n" %(perms, nlinks, uname, gname,
827 size, mtime, path.split('/')[-1])
830 def format_mlsx(self, basedir, listing, perms, facts, ignore_err=True):
831 """Return an iterator object that yields the entries of a given
832 directory or of a single file in a form suitable with MLSD and
835 Every entry includes a list of "facts" referring the listed
836 element. See RFC-3659, chapter 7, to see what every single
839 - (str) basedir: the absolute dirname.
840 - (list) listing: the names of the entries in basedir
841 - (str) perms: the string referencing the user permissions.
842 - (str) facts: the list of "facts" to be returned.
843 - (bool) ignore_err: when False raise exception if os.stat()
846 Note that "facts" returned may change depending on the platform
847 and on what user specified by using the OPTS command.
849 This is how output could appear to the client issuing
852 type=file;size=156;perm=r;modify=20071029155301;unique=801cd2; music.mp3
853 type=dir;size=0;perm=el;modify=20071127230206;unique=801e33; ebooks
854 type=file;size=211;perm=r;modify=20071103093626;unique=801e32; module.py
856 permdir = ''.join([x for x in perms if x not in 'arw'])
857 permfile = ''.join([x for x in perms if x not in 'celmp'])
858 if ('w' in perms) or ('a' in perms) or ('f' in perms):
862 type = size = perm = modify = create = unique = mode = uid = gid = ""
871 if stat.S_ISDIR(st.st_mode):
875 perm = 'perm=%s;' %permdir
880 perm = 'perm=%s;' %permfile
882 size = 'size=%s;' %st.st_size # file size
883 # last modification time
884 if 'modify' in facts:
886 modify = 'modify=%s;' %time.strftime("%Y%m%d%H%M%S",
887 time.localtime(st.st_mtime))
889 # stat.st_mtime could fail (-1) if last mtime is too old
891 if 'create' in facts:
892 # on Windows we can provide also the creation time
894 create = 'create=%s;' %time.strftime("%Y%m%d%H%M%S",
895 time.localtime(st.st_ctime))
899 if 'unix.mode' in facts:
900 mode = 'unix.mode=%s;' %oct(st.st_mode & 0777)
901 if 'unix.uid' in facts:
902 uid = 'unix.uid=%s;' %st.st_uid
903 if 'unix.gid' in facts:
904 gid = 'unix.gid=%s;' %st.st_gid
905 # We provide unique fact (see RFC-3659, chapter 7.5.2) on
906 # posix platforms only; we get it by mixing st_dev and
907 # st_ino values which should be enough for granting an
908 # uniqueness for the file listed.
909 # The same approach is used by pure-ftpd.
910 # Implementors who want to provide unique fact on other
911 # platforms should use some platform-specific method (e.g.
912 # on Windows NTFS filesystems MTF records could be used).
913 if 'unique' in facts:
914 unique = "unique=%x%x;" %(st.st_dev, st.st_ino)
915 path=_to_decode(file.path)
916 yield "%s%s%s%s%s%s%s%s%s %s\r\n" %(type, size, perm, modify, create,
917 mode, uid, gid, unique, path)
919 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: