[med-svn] [Git][med-team/gnumed-client][master] 5 commits: routine-update: New upstream version

Andreas Tille (@tille) gitlab at salsa.debian.org
Sat Feb 12 21:24:57 GMT 2022



Andreas Tille pushed to branch master at Debian Med / gnumed-client


Commits:
14f25ce8 by Andreas Tille at 2022-02-12T22:00:10+01:00
routine-update: New upstream version

- - - - -
cc9ec2f0 by Andreas Tille at 2022-02-12T22:00:11+01:00
New upstream version 1.8.7+dfsg
- - - - -
84370e5d by Andreas Tille at 2022-02-12T22:00:29+01:00
Update upstream source from tag 'upstream/1.8.7+dfsg'

Update to upstream version '1.8.7+dfsg'
with Debian dir f190df297b50222525b3be328decc409b047a33f
- - - - -
7540bab8 by Andreas Tille at 2022-02-12T22:04:24+01:00
Update Build-Depends

- - - - -
a6594b1a by Andreas Tille at 2022-02-12T22:09:29+01:00
Upload to unstable

- - - - -


13 changed files:

- client/CHANGELOG
- client/business/gmDocuments.py
- client/business/gmExportArea.py
- client/doc/api/gmDocuments.html
- client/doc/api/gmDrugDataSources.html
- client/doc/api/gmExportArea.html
- client/doc/api/gmTools.html
- client/doc/schema/gnumed-entire_schema.html
- client/gnumed.py
- client/pycommon/gmMimeLib.py
- client/wxpython/gmTopPanel.py
- debian/changelog
- debian/control


Changes:

=====================================
client/CHANGELOG
=====================================
@@ -6,6 +6,12 @@
 # rel-1-8-patches
 ------------------------------------------------
 
+	1.8.7
+
+FIX: export area: dumping encrypted/PDFed image to disk
+FIX: top panel: heart rate display
+FIX: paperwork: recalls list LaTeX template
+
 	1.8.6
 
 FIX: unlocking encounters (missing import)
@@ -2172,6 +2178,10 @@ FIX: missing cast to ::text in dem.date_trunc_utc() calls
 # gnumed_v22
 ------------------------------------------------
 
+	22.17
+
+FIX: CREATE FUNCTION ... RETURNS OPAQUE -> TRIGGER [thanks SantyCW at es_AR]
+
 	22.16
 
 IMPROVED: bootstrapper: check disk space


=====================================
client/business/gmDocuments.py
=====================================
@@ -277,48 +277,28 @@ class cDocumentPart(gmBusinessDBObject.cBusinessDBObject):
 	#--------------------------------------------------------
 	# retrieve data
 	#--------------------------------------------------------
-	def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, adjust_extension=False, conn=None):
-
+	def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, conn=None):
 		if filename is None:
 			filename = self.get_useful_filename(make_unique = True, directory = directory)
-
-		filename = self.__download_to_file(filename = filename)
-		if filename is None:
+		dl_fname = self.__download_to_file(filename = filename)
+		if dl_fname is None:
 			return None
 
 		if target_mime is None:
-			if filename.endswith('.dat'):
-				if adjust_extension:
-					return gmMimeLib.adjust_extension_by_mimetype(filename)
-			return filename
+			return gmMimeLib.adjust_extension_by_mimetype(dl_fname)
 
-		if target_extension is None:
-			target_extension = gmMimeLib.guess_ext_by_mimetype(mimetype = target_mime)
-
-		target_path, name = os.path.split(filename)
-		name, tmp = os.path.splitext(name)
-		target_fname = gmTools.get_unique_filename (
-			prefix = '%s-conv-' % name,
-			suffix = target_extension
-		)
-		_log.debug('attempting conversion: [%s] -> [<%s>:%s]', filename, target_mime, target_fname)
-		converted_fname = gmMimeLib.convert_file (
-			filename = filename,
+		converted_fname = self.__convert_file_to (
+			filename = dl_fname,
 			target_mime = target_mime,
-			target_filename = target_fname
+			target_extension = target_extension
 		)
-		if converted_fname is not None:
-			return converted_fname
-
-		_log.warning('conversion failed')
-		if not ignore_conversion_problems:
+		if converted_fname is None:
+			if ignore_conversion_problems:
+				return dl_fname
 			return None
 
-		if filename.endswith('.dat'):
-			if adjust_extension:
-				filename = gmMimeLib.adjust_extension_by_mimetype(filename)
-		_log.warning('programmed to ignore conversion problems, hoping receiver can handle [%s]', filename)
-		return filename
+		gmTools.remove_file(dl_fname)
+		return converted_fname
 
 	#--------------------------------------------------------
 	def get_reviews(self):
@@ -634,6 +614,39 @@ insert into blobs.reviewed_doc_objs (
 
 		return filename
 
+	#--------------------------------------------------------
+	def __convert_file_to(self, filename=None, target_mime=None, target_extension=None):
+		assert (filename is not None), '<filename> must not be None'
+		assert (target_mime is not None), '<target_mime> must not be None'
+
+		if target_extension is None:
+			target_extension = gmMimeLib.guess_ext_by_mimetype(mimetype = target_mime)
+		src_path, src_name = os.path.split(filename)
+		src_stem, src_ext = os.path.splitext(src_name)
+		conversion_tmp_name = gmTools.get_unique_filename (
+			prefix = '%s.conv2.' % src_stem,
+			suffix = target_extension
+		)
+		_log.debug('attempting conversion: [%s] -> [<%s>:%s]', filename, target_mime, conversion_tmp_name)
+		converted_fname = gmMimeLib.convert_file (
+			filename = filename,
+			target_mime = target_mime,
+			target_filename = conversion_tmp_name
+		)
+		if converted_fname is None:
+			_log.warning('conversion failed')
+			return None
+
+		tmp_path, conv_name = os.path.split(converted_fname)
+		conv_name_in_src_path = os.path.join(src_path, conv_name)
+		try:
+			os.replace(converted_fname, conv_name_in_src_path)
+		except OSError:
+			_log.exception('cannot os.replace(%s, %s)', converted_fname, conv_name_in_src_path)
+			return None
+
+		return gmMimeLib.adjust_extension_by_mimetype(conv_name_in_src_path)
+
 	#--------------------------------------------------------
 	def __run_metainfo_formatter(self):
 		filename = self.__download_to_file()
@@ -1286,6 +1299,14 @@ if __name__ == '__main__':
 			print(doc.format(single_line = True))
 			print(doc.format())
 
+	#--------------------------------------------------------
+	def test_save_to_file():
+		doc_folder = cDocumentFolder(aPKey=12)
+		docs = doc_folder.get_documents()
+		for doc in docs:
+			for part in doc.parts:
+				print(part.save_to_file(target_mime = 'application/pdf', ignore_conversion_problems = True))
+
 	#--------------------------------------------------------
 	def test_get_useful_filename():
 		pk = 12
@@ -1346,11 +1367,14 @@ if __name__ == '__main__':
 	gmI18N.activate_locale()
 	gmI18N.install_domain()
 
+	gmPG2.request_login_params(setup_pool = True)
+
 	#test_doc_types()
 	#test_adding_doc_part()
 	#test_get_documents()
 	#test_get_useful_filename()
 	#test_part_metainfo_formatter()
-	test_check_mimetypes_in_archive()
+	#test_check_mimetypes_in_archive()
+	test_save_to_file()
 
 #	print get_ext_ref()


=====================================
client/business/gmExportArea.py
=====================================
@@ -271,8 +271,7 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
 		if not success:
 			return None
 
-		if filename.endswith('.dat'):
-			filename = gmMimeLib.adjust_extension_by_mimetype(filename)
+		filename = gmMimeLib.adjust_extension_by_mimetype(filename)
 		if passphrase is None:
 			return filename
 
@@ -315,14 +314,17 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
 				date_before_type = True,
 				name_first = False
 			)
-		target_mime = 'application/pdf' if convert2pdf else None
-		target_ext = '.pdf' if convert2pdf else None
+		if convert2pdf:
+			target_mime = 'application/pdf'
+			target_ext = '.pdf'
+		else:
+			target_mime = None
+			target_ext = None
 		part_fname = part.save_to_file (
 			filename = filename,
 			target_mime = target_mime,
 			target_extension = target_ext,
-			ignore_conversion_problems = False,
-			adjust_extension = True
+			ignore_conversion_problems = False
 		)
 		if part_fname is None:
 			_log.error('cannot save document part to file')
@@ -337,7 +339,7 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
 			verbose = _cfg.get(option = 'debug'),
 			remove_unencrypted = True
 		)
-		removed = gmTools.remove_file(filename)
+		removed = gmTools.remove_file(part_fname)
 		if enc_filename is None:
 			_log.error('cannot encrypt')
 			return False
@@ -963,7 +965,7 @@ class cExportArea(object):
 		# - export mugshot
 		mugshot = pat.document_folder.latest_mugshot
 		if mugshot is not None:
-			mugshot_fname = mugshot.save_to_file(directory = doc_dir, adjust_extension = True)
+			mugshot_fname = mugshot.save_to_file(directory = doc_dir)
 			fname = os.path.split(mugshot_fname)[1]
 			html_data['mugshot_url'] = os.path.join(DOCUMENTS_SUBDIR, fname)
 			html_data['mugshot_alt'] =_('patient photograph from %s') % gmDateTime.pydt_strftime(mugshot['date_generated'], '%B %Y')


=====================================
client/doc/api/gmDocuments.html
=====================================
@@ -301,48 +301,28 @@ class cDocumentPart(gmBusinessDBObject.cBusinessDBObject):
         #--------------------------------------------------------
         # retrieve data
         #--------------------------------------------------------
-        def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, adjust_extension=False, conn=None):
-
+        def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, conn=None):
                 if filename is None:
                         filename = self.get_useful_filename(make_unique = True, directory = directory)
-
-                filename = self.__download_to_file(filename = filename)
-                if filename is None:
+                dl_fname = self.__download_to_file(filename = filename)
+                if dl_fname is None:
                         return None
 
                 if target_mime is None:
-                        if filename.endswith('.dat'):
-                                if adjust_extension:
-                                        return gmMimeLib.adjust_extension_by_mimetype(filename)
-                        return filename
+                        return gmMimeLib.adjust_extension_by_mimetype(dl_fname)
 
-                if target_extension is None:
-                        target_extension = gmMimeLib.guess_ext_by_mimetype(mimetype = target_mime)
-
-                target_path, name = os.path.split(filename)
-                name, tmp = os.path.splitext(name)
-                target_fname = gmTools.get_unique_filename (
-                        prefix = '%s-conv-' % name,
-                        suffix = target_extension
-                )
-                _log.debug('attempting conversion: [%s] -> [<%s>:%s]', filename, target_mime, target_fname)
-                converted_fname = gmMimeLib.convert_file (
-                        filename = filename,
+                converted_fname = self.__convert_file_to (
+                        filename = dl_fname,
                         target_mime = target_mime,
-                        target_filename = target_fname
+                        target_extension = target_extension
                 )
-                if converted_fname is not None:
-                        return converted_fname
-
-                _log.warning('conversion failed')
-                if not ignore_conversion_problems:
+                if converted_fname is None:
+                        if ignore_conversion_problems:
+                                return dl_fname
                         return None
 
-                if filename.endswith('.dat'):
-                        if adjust_extension:
-                                filename = gmMimeLib.adjust_extension_by_mimetype(filename)
-                _log.warning('programmed to ignore conversion problems, hoping receiver can handle [%s]', filename)
-                return filename
+                gmTools.remove_file(dl_fname)
+                return converted_fname
 
         #--------------------------------------------------------
         def get_reviews(self):
@@ -658,6 +638,39 @@ insert into blobs.reviewed_doc_objs (
 
                 return filename
 
+        #--------------------------------------------------------
+        def __convert_file_to(self, filename=None, target_mime=None, target_extension=None):
+                assert (filename is not None), '<filename> must not be None'
+                assert (target_mime is not None), '<target_mime> must not be None'
+
+                if target_extension is None:
+                        target_extension = gmMimeLib.guess_ext_by_mimetype(mimetype = target_mime)
+                src_path, src_name = os.path.split(filename)
+                src_stem, src_ext = os.path.splitext(src_name)
+                conversion_tmp_name = gmTools.get_unique_filename (
+                        prefix = '%s.conv2.' % src_stem,
+                        suffix = target_extension
+                )
+                _log.debug('attempting conversion: [%s] -> [<%s>:%s]', filename, target_mime, conversion_tmp_name)
+                converted_fname = gmMimeLib.convert_file (
+                        filename = filename,
+                        target_mime = target_mime,
+                        target_filename = conversion_tmp_name
+                )
+                if converted_fname is None:
+                        _log.warning('conversion failed')
+                        return None
+
+                tmp_path, conv_name = os.path.split(converted_fname)
+                conv_name_in_src_path = os.path.join(src_path, conv_name)
+                try:
+                        os.replace(converted_fname, conv_name_in_src_path)
+                except OSError:
+                        _log.exception('cannot os.replace(%s, %s)', converted_fname, conv_name_in_src_path)
+                        return None
+
+                return gmMimeLib.adjust_extension_by_mimetype(conv_name_in_src_path)
+
         #--------------------------------------------------------
         def __run_metainfo_formatter(self):
                 filename = self.__download_to_file()
@@ -1311,6 +1324,14 @@ if __name__ == '__main__':
                         print(doc.format())
                         #print(doc['pk_type'])
 
+        #--------------------------------------------------------
+        def test_save_to_file():
+                doc_folder = cDocumentFolder(aPKey=12)
+                docs = doc_folder.get_documents()
+                for doc in docs:
+                        for part in doc.parts:
+                                print(part.save_to_file(target_mime = 'application/pdf', ignore_conversion_problems = True))
+
         #--------------------------------------------------------
         def test_get_useful_filename():
                 pk = 12
@@ -1375,10 +1396,11 @@ if __name__ == '__main__':
 
         #test_doc_types()
         #test_adding_doc_part()
-        test_get_documents()
+        #test_get_documents()
         #test_get_useful_filename()
         #test_part_metainfo_formatter()
         #test_check_mimetypes_in_archive()
+        test_save_to_file()
 
 #       print get_ext_ref()</code></pre>
 </details>
@@ -3060,48 +3082,28 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
         #--------------------------------------------------------
         # retrieve data
         #--------------------------------------------------------
-        def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, adjust_extension=False, conn=None):
-
+        def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, conn=None):
                 if filename is None:
                         filename = self.get_useful_filename(make_unique = True, directory = directory)
-
-                filename = self.__download_to_file(filename = filename)
-                if filename is None:
+                dl_fname = self.__download_to_file(filename = filename)
+                if dl_fname is None:
                         return None
 
                 if target_mime is None:
-                        if filename.endswith('.dat'):
-                                if adjust_extension:
-                                        return gmMimeLib.adjust_extension_by_mimetype(filename)
-                        return filename
+                        return gmMimeLib.adjust_extension_by_mimetype(dl_fname)
 
-                if target_extension is None:
-                        target_extension = gmMimeLib.guess_ext_by_mimetype(mimetype = target_mime)
-
-                target_path, name = os.path.split(filename)
-                name, tmp = os.path.splitext(name)
-                target_fname = gmTools.get_unique_filename (
-                        prefix = '%s-conv-' % name,
-                        suffix = target_extension
-                )
-                _log.debug('attempting conversion: [%s] -> [<%s>:%s]', filename, target_mime, target_fname)
-                converted_fname = gmMimeLib.convert_file (
-                        filename = filename,
+                converted_fname = self.__convert_file_to (
+                        filename = dl_fname,
                         target_mime = target_mime,
-                        target_filename = target_fname
+                        target_extension = target_extension
                 )
-                if converted_fname is not None:
-                        return converted_fname
-
-                _log.warning('conversion failed')
-                if not ignore_conversion_problems:
+                if converted_fname is None:
+                        if ignore_conversion_problems:
+                                return dl_fname
                         return None
 
-                if filename.endswith('.dat'):
-                        if adjust_extension:
-                                filename = gmMimeLib.adjust_extension_by_mimetype(filename)
-                _log.warning('programmed to ignore conversion problems, hoping receiver can handle [%s]', filename)
-                return filename
+                gmTools.remove_file(dl_fname)
+                return converted_fname
 
         #--------------------------------------------------------
         def get_reviews(self):
@@ -3417,6 +3419,39 @@ insert into blobs.reviewed_doc_objs (
 
                 return filename
 
+        #--------------------------------------------------------
+        def __convert_file_to(self, filename=None, target_mime=None, target_extension=None):
+                assert (filename is not None), '<filename> must not be None'
+                assert (target_mime is not None), '<target_mime> must not be None'
+
+                if target_extension is None:
+                        target_extension = gmMimeLib.guess_ext_by_mimetype(mimetype = target_mime)
+                src_path, src_name = os.path.split(filename)
+                src_stem, src_ext = os.path.splitext(src_name)
+                conversion_tmp_name = gmTools.get_unique_filename (
+                        prefix = '%s.conv2.' % src_stem,
+                        suffix = target_extension
+                )
+                _log.debug('attempting conversion: [%s] -> [<%s>:%s]', filename, target_mime, conversion_tmp_name)
+                converted_fname = gmMimeLib.convert_file (
+                        filename = filename,
+                        target_mime = target_mime,
+                        target_filename = conversion_tmp_name
+                )
+                if converted_fname is None:
+                        _log.warning('conversion failed')
+                        return None
+
+                tmp_path, conv_name = os.path.split(converted_fname)
+                conv_name_in_src_path = os.path.join(src_path, conv_name)
+                try:
+                        os.replace(converted_fname, conv_name_in_src_path)
+                except OSError:
+                        _log.exception('cannot os.replace(%s, %s)', converted_fname, conv_name_in_src_path)
+                        return None
+
+                return gmMimeLib.adjust_extension_by_mimetype(conv_name_in_src_path)
+
         #--------------------------------------------------------
         def __run_metainfo_formatter(self):
                 filename = self.__download_to_file()
@@ -3715,7 +3750,7 @@ ORDER BY
 </details>
 </dd>
 <dt id="Gnumed.business.gmDocuments.cDocumentPart.save_to_file"><code class="name flex">
-<span>def <span class="ident">save_to_file</span></span>(<span>self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, adjust_extension=False, conn=None)</span>
+<span>def <span class="ident">save_to_file</span></span>(<span>self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, conn=None)</span>
 </code></dt>
 <dd>
 <div class="desc"></div>
@@ -3723,48 +3758,28 @@ ORDER BY
 <summary>
 <span>Expand source code</span>
 </summary>
-<pre><code class="python">def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, adjust_extension=False, conn=None):
-
+<pre><code class="python">def save_to_file(self, aChunkSize=0, filename=None, target_mime=None, target_extension=None, ignore_conversion_problems=False, directory=None, conn=None):
         if filename is None:
                 filename = self.get_useful_filename(make_unique = True, directory = directory)
-
-        filename = self.__download_to_file(filename = filename)
-        if filename is None:
+        dl_fname = self.__download_to_file(filename = filename)
+        if dl_fname is None:
                 return None
 
         if target_mime is None:
-                if filename.endswith('.dat'):
-                        if adjust_extension:
-                                return gmMimeLib.adjust_extension_by_mimetype(filename)
-                return filename
+                return gmMimeLib.adjust_extension_by_mimetype(dl_fname)
 
-        if target_extension is None:
-                target_extension = gmMimeLib.guess_ext_by_mimetype(mimetype = target_mime)
-
-        target_path, name = os.path.split(filename)
-        name, tmp = os.path.splitext(name)
-        target_fname = gmTools.get_unique_filename (
-                prefix = '%s-conv-' % name,
-                suffix = target_extension
-        )
-        _log.debug('attempting conversion: [%s] -> [<%s>:%s]', filename, target_mime, target_fname)
-        converted_fname = gmMimeLib.convert_file (
-                filename = filename,
+        converted_fname = self.__convert_file_to (
+                filename = dl_fname,
                 target_mime = target_mime,
-                target_filename = target_fname
+                target_extension = target_extension
         )
-        if converted_fname is not None:
-                return converted_fname
-
-        _log.warning('conversion failed')
-        if not ignore_conversion_problems:
+        if converted_fname is None:
+                if ignore_conversion_problems:
+                        return dl_fname
                 return None
 
-        if filename.endswith('.dat'):
-                if adjust_extension:
-                        filename = gmMimeLib.adjust_extension_by_mimetype(filename)
-        _log.warning('programmed to ignore conversion problems, hoping receiver can handle [%s]', filename)
-        return filename</code></pre>
+        gmTools.remove_file(dl_fname)
+        return converted_fname</code></pre>
 </details>
 </dd>
 <dt id="Gnumed.business.gmDocuments.cDocumentPart.set_as_active_photograph"><code class="name flex">


=====================================
client/doc/api/gmDrugDataSources.html
=====================================
@@ -520,7 +520,7 @@ class cFreeDiamsInterface(cDrugDataSourceInterface):
                 _log.debug('GNUmed -> FreeDiams "exchange-in" file: %s', self.__gm2fd_filename)
                 self.__fd2gm_filename = gmTools.get_unique_filename(prefix = r'freediams2gm-', suffix = r'.xml')
                 _log.debug('GNUmed <-> FreeDiams "exchange-out"/"prescription" file: %s', self.__fd2gm_filename)
-                # this file can be modified by the user as needed:
+                # this file can be modified by the user as needed (therefore in user_config_dir):
                 self.__fd4gm_config_file = os.path.join(gmTools.gmPaths().user_config_dir, 'freediams4gm.conf')
                 _log.debug('FreeDiams config file for GNUmed use: %s', self.__fd4gm_config_file)
 
@@ -1604,7 +1604,7 @@ if __name__ == "__main__":
                 _log.debug('GNUmed -> FreeDiams "exchange-in" file: %s', self.__gm2fd_filename)
                 self.__fd2gm_filename = gmTools.get_unique_filename(prefix = r'freediams2gm-', suffix = r'.xml')
                 _log.debug('GNUmed <-> FreeDiams "exchange-out"/"prescription" file: %s', self.__fd2gm_filename)
-                # this file can be modified by the user as needed:
+                # this file can be modified by the user as needed (therefore in user_config_dir):
                 self.__fd4gm_config_file = os.path.join(gmTools.gmPaths().user_config_dir, 'freediams4gm.conf')
                 _log.debug('FreeDiams config file for GNUmed use: %s', self.__fd4gm_config_file)
 


=====================================
client/doc/api/gmExportArea.html
=====================================
@@ -142,6 +142,9 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
         def update_data(self, data=None):
                 assert (data is not None), '<data> must not be <None>'
 
+                if self.is_DIRENTRY or self.is_document_part:
+                        return False
+
                 SQL = """
                         UPDATE clin.export_item SET
                                 data = %(data)s::bytea,
@@ -154,7 +157,15 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
                 return True
 
         #--------------------------------------------------------
-        def update_data_from_file(self, filename=None):
+        def update_data_from_file(self, filename=None, convert_document_part=False):
+
+                if self.is_DIRENTRY:
+                        return False
+
+                if self.is_document_part:
+                        if not convert_document_part:
+                                return False
+
                 # sanity check
                 if not (os.access(filename, os.R_OK) and os.path.isfile(filename)):
                         _log.error('[%s] is not a readable file' % filename)
@@ -185,7 +196,7 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
                         convert2pdf: Convert file(s) to PDF on the way out. Before encryption, that is.
 
                 Returns:
-                        Directory for DIRENTRIES, or filename.
+                        Directory for DIRENTRIES, or filename, or None on failure.
                 """
                 if self.is_DIRENTRY and convert2pdf:
                         # cannot convert dir entries to PDF
@@ -290,44 +301,49 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
         # helpers
         #--------------------------------------------------------
         def __save_normal_item(self, filename:str=None, directory:str=None, passphrase:str=None, convert2pdf:bool=False) -> str:
-                if filename is None:
-                        filename = self.get_useful_filename(directory = directory)
+                _SQL = 'SELECT substring(data FROM %(start)s FOR %(size)s) FROM clin.export_item WHERE pk = %(pk)s'
+                tmp_fname = gmTools.get_unique_filename()
                 success = gmPG2.bytea2file (
-                        data_query = {
-                                'cmd': 'SELECT substring(data from %(start)s for %(size)s) FROM clin.export_item WHERE pk = %(pk)s',
-                                'args': {'pk': self.pk_obj}
-                        },
-                        filename = filename,
+                        data_query = {'cmd': _SQL, 'args': {'pk': self.pk_obj}},
+                        filename = tmp_fname,
                         data_size = self._payload[self._idx['size']]
                 )
                 if not success:
                         return None
 
-                if filename.endswith('.dat'):
-                        filename = gmMimeLib.adjust_extension_by_mimetype(filename)
+                tmp_fname = gmMimeLib.adjust_extension_by_mimetype(tmp_fname)
+                if convert2pdf:
+                        tmp_fname = gmMimeLib.convert_file(filename = tmp_fname, target_mime = 'application/pdf', target_extension = '.pdf')
+                if filename is None:
+                        target_fname = self.get_useful_filename(directory = directory)
+                else:
+                        target_fname = filename
                 if passphrase is None:
-                        if not convert2pdf:
-                                return filename
+                        if not gmTools.rename_file(tmp_fname, target_fname, overwrite = True, allow_symlink = True):
+                                return None
 
-                        return gmMimeLib.convert_file(filename = filename, target_mime = 'application/pdf', target_extension = '.pdf')
+                        if filename is None:
+                                return gmMimeLib.adjust_extension_by_mimetype(target_fname)
 
-                enc_filename = gmCrypto.encrypt_file (
-                        filename = filename,
+                        return target_filename
+
+                enc_fname = gmCrypto.encrypt_file (
+                        filename = tmp_fname,
                         passphrase = passphrase,
                         verbose = _cfg.get(option = 'debug'),
                         remove_unencrypted = True,
-                        convert2pdf = convert2pdf
+                        convert2pdf = False     # already done, if desired
                 )
-                removed = gmTools.remove_file(filename)
-                if enc_filename is None:
+                removed = gmTools.remove_file(tmp_fname)
+                if enc_fname is None:
                         _log.error('cannot encrypt or, possibly, convert')
                         return None
 
                 if removed:
-                        return enc_filename
+                        return enc_fname
 
                 _log.error('cannot remove unencrypted file')
-                gmTools.remove(enc_filename)
+                gmTools.remove(enc_fname)
                 return None
 
         #--------------------------------------------------------
@@ -352,14 +368,17 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
                         )
                 path, name = os.path.split(filename)
                 filename = os.path.join(path, '%s-%s' % (self._payload[self._idx['list_position']], name))
-                target_mime = 'application/pdf' if convert2pdf else None
-                target_ext = '.pdf' if convert2pdf else None
+                if convert2pdf:
+                        target_mime = 'application/pdf'
+                        target_ext = '.pdf'
+                else:
+                        target_mime = None
+                        target_ext = None
                 part_fname = part.save_to_file (
                         filename = filename,
                         target_mime = target_mime,
                         target_extension = target_ext,
-                        ignore_conversion_problems = False,
-                        adjust_extension = True
+                        ignore_conversion_problems = False
                 )
                 if part_fname is None:
                         _log.error('cannot save document part to file')
@@ -374,7 +393,7 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
                         verbose = _cfg.get(option = 'debug'),
                         remove_unencrypted = True
                 )
-                removed = gmTools.remove_file(filename)
+                removed = gmTools.remove_file(part_fname)
                 if enc_filename is None:
                         _log.error('cannot encrypt')
                         return False
@@ -440,6 +459,12 @@ class cExportItem(gmBusinessDBObject.cBusinessDBObject):
 
         #--------------------------------------------------------
         # properties
+        #--------------------------------------------------------
+        def _get_is_doc_part(self):
+                return self._payload[self._idx['pk_doc_obj']] is not None
+
+        is_document_part = property(_get_is_doc_part)
+
         #--------------------------------------------------------
         def _get_doc_part(self):
                 if self._payload[self._idx['pk_doc_obj']] is None:
@@ -996,7 +1021,7 @@ class cExportArea(object):
                 # - export mugshot
                 mugshot = pat.document_folder.latest_mugshot
                 if mugshot is not None:
-                        mugshot_fname = mugshot.save_to_file(directory = doc_dir, adjust_extension = True)
+                        mugshot_fname = mugshot.save_to_file(directory = doc_dir)
                         fname = os.path.split(mugshot_fname)[1]
                         html_data['mugshot_url'] = os.path.join(DOCUMENTS_SUBDIR, fname)
                         html_data['mugshot_alt'] =_('patient photograph from %s') % gmDateTime.pydt_strftime(mugshot['date_generated'], '%B %Y')
@@ -1973,7 +1998,7 @@ if __name__ == '__main__':
                 # - export mugshot
                 mugshot = pat.document_folder.latest_mugshot
                 if mugshot is not None:
-                        mugshot_fname = mugshot.save_to_file(directory = doc_dir, adjust_extension = True)
+                        mugshot_fname = mugshot.save_to_file(directory = doc_dir)
                         fname = os.path.split(mugshot_fname)[1]
                         html_data['mugshot_url'] = os.path.join(DOCUMENTS_SUBDIR, fname)
                         html_data['mugshot_alt'] =_('patient photograph from %s') % gmDateTime.pydt_strftime(mugshot['date_generated'], '%B %Y')
@@ -2776,7 +2801,7 @@ as a subdirectory.</p></div>
         # - export mugshot
         mugshot = pat.document_folder.latest_mugshot
         if mugshot is not None:
-                mugshot_fname = mugshot.save_to_file(directory = doc_dir, adjust_extension = True)
+                mugshot_fname = mugshot.save_to_file(directory = doc_dir)
                 fname = os.path.split(mugshot_fname)[1]
                 html_data['mugshot_url'] = os.path.join(DOCUMENTS_SUBDIR, fname)
                 html_data['mugshot_alt'] =_('patient photograph from %s') % gmDateTime.pydt_strftime(mugshot['date_generated'], '%B %Y')
@@ -3150,6 +3175,9 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
         def update_data(self, data=None):
                 assert (data is not None), '<data> must not be <None>'
 
+                if self.is_DIRENTRY or self.is_document_part:
+                        return False
+
                 SQL = """
                         UPDATE clin.export_item SET
                                 data = %(data)s::bytea,
@@ -3162,7 +3190,15 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
                 return True
 
         #--------------------------------------------------------
-        def update_data_from_file(self, filename=None):
+        def update_data_from_file(self, filename=None, convert_document_part=False):
+
+                if self.is_DIRENTRY:
+                        return False
+
+                if self.is_document_part:
+                        if not convert_document_part:
+                                return False
+
                 # sanity check
                 if not (os.access(filename, os.R_OK) and os.path.isfile(filename)):
                         _log.error('[%s] is not a readable file' % filename)
@@ -3193,7 +3229,7 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
                         convert2pdf: Convert file(s) to PDF on the way out. Before encryption, that is.
 
                 Returns:
-                        Directory for DIRENTRIES, or filename.
+                        Directory for DIRENTRIES, or filename, or None on failure.
                 """
                 if self.is_DIRENTRY and convert2pdf:
                         # cannot convert dir entries to PDF
@@ -3298,44 +3334,49 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
         # helpers
         #--------------------------------------------------------
         def __save_normal_item(self, filename:str=None, directory:str=None, passphrase:str=None, convert2pdf:bool=False) -> str:
-                if filename is None:
-                        filename = self.get_useful_filename(directory = directory)
+                _SQL = 'SELECT substring(data FROM %(start)s FOR %(size)s) FROM clin.export_item WHERE pk = %(pk)s'
+                tmp_fname = gmTools.get_unique_filename()
                 success = gmPG2.bytea2file (
-                        data_query = {
-                                'cmd': 'SELECT substring(data from %(start)s for %(size)s) FROM clin.export_item WHERE pk = %(pk)s',
-                                'args': {'pk': self.pk_obj}
-                        },
-                        filename = filename,
+                        data_query = {'cmd': _SQL, 'args': {'pk': self.pk_obj}},
+                        filename = tmp_fname,
                         data_size = self._payload[self._idx['size']]
                 )
                 if not success:
                         return None
 
-                if filename.endswith('.dat'):
-                        filename = gmMimeLib.adjust_extension_by_mimetype(filename)
+                tmp_fname = gmMimeLib.adjust_extension_by_mimetype(tmp_fname)
+                if convert2pdf:
+                        tmp_fname = gmMimeLib.convert_file(filename = tmp_fname, target_mime = 'application/pdf', target_extension = '.pdf')
+                if filename is None:
+                        target_fname = self.get_useful_filename(directory = directory)
+                else:
+                        target_fname = filename
                 if passphrase is None:
-                        if not convert2pdf:
-                                return filename
+                        if not gmTools.rename_file(tmp_fname, target_fname, overwrite = True, allow_symlink = True):
+                                return None
 
-                        return gmMimeLib.convert_file(filename = filename, target_mime = 'application/pdf', target_extension = '.pdf')
+                        if filename is None:
+                                return gmMimeLib.adjust_extension_by_mimetype(target_fname)
 
-                enc_filename = gmCrypto.encrypt_file (
-                        filename = filename,
+                        return target_filename
+
+                enc_fname = gmCrypto.encrypt_file (
+                        filename = tmp_fname,
                         passphrase = passphrase,
                         verbose = _cfg.get(option = 'debug'),
                         remove_unencrypted = True,
-                        convert2pdf = convert2pdf
+                        convert2pdf = False     # already done, if desired
                 )
-                removed = gmTools.remove_file(filename)
-                if enc_filename is None:
+                removed = gmTools.remove_file(tmp_fname)
+                if enc_fname is None:
                         _log.error('cannot encrypt or, possibly, convert')
                         return None
 
                 if removed:
-                        return enc_filename
+                        return enc_fname
 
                 _log.error('cannot remove unencrypted file')
-                gmTools.remove(enc_filename)
+                gmTools.remove(enc_fname)
                 return None
 
         #--------------------------------------------------------
@@ -3360,14 +3401,17 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
                         )
                 path, name = os.path.split(filename)
                 filename = os.path.join(path, '%s-%s' % (self._payload[self._idx['list_position']], name))
-                target_mime = 'application/pdf' if convert2pdf else None
-                target_ext = '.pdf' if convert2pdf else None
+                if convert2pdf:
+                        target_mime = 'application/pdf'
+                        target_ext = '.pdf'
+                else:
+                        target_mime = None
+                        target_ext = None
                 part_fname = part.save_to_file (
                         filename = filename,
                         target_mime = target_mime,
                         target_extension = target_ext,
-                        ignore_conversion_problems = False,
-                        adjust_extension = True
+                        ignore_conversion_problems = False
                 )
                 if part_fname is None:
                         _log.error('cannot save document part to file')
@@ -3382,7 +3426,7 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
                         verbose = _cfg.get(option = 'debug'),
                         remove_unencrypted = True
                 )
-                removed = gmTools.remove_file(filename)
+                removed = gmTools.remove_file(part_fname)
                 if enc_filename is None:
                         _log.error('cannot encrypt')
                         return False
@@ -3448,6 +3492,12 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
 
         #--------------------------------------------------------
         # properties
+        #--------------------------------------------------------
+        def _get_is_doc_part(self):
+                return self._payload[self._idx['pk_doc_obj']] is not None
+
+        is_document_part = property(_get_is_doc_part)
+
         #--------------------------------------------------------
         def _get_doc_part(self):
                 if self._payload[self._idx['pk_doc_obj']] is None:
@@ -3626,6 +3676,17 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
         return True</code></pre>
 </details>
 </dd>
+<dt id="Gnumed.business.gmExportArea.cExportItem.is_document_part"><code class="name">var <span class="ident">is_document_part</span></code></dt>
+<dd>
+<div class="desc"></div>
+<details class="source">
+<summary>
+<span>Expand source code</span>
+</summary>
+<pre><code class="python">def _get_is_doc_part(self):
+        return self._payload[self._idx['pk_doc_obj']] is not None</code></pre>
+</details>
+</dd>
 <dt id="Gnumed.business.gmExportArea.cExportItem.is_print_job"><code class="name">var <span class="ident">is_print_job</span></code></dt>
 <dd>
 <div class="desc"></div>
@@ -3761,7 +3822,7 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
 <dd>Convert file(s) to PDF on the way out. Before encryption, that is.</dd>
 </dl>
 <h2 id="returns">Returns</h2>
-<p>Directory for DIRENTRIES, or filename.</p></div>
+<p>Directory for DIRENTRIES, or filename, or None on failure.</p></div>
 <details class="source">
 <summary>
 <span>Expand source code</span>
@@ -3776,7 +3837,7 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
                 convert2pdf: Convert file(s) to PDF on the way out. Before encryption, that is.
 
         Returns:
-                Directory for DIRENTRIES, or filename.
+                Directory for DIRENTRIES, or filename, or None on failure.
         """
         if self.is_DIRENTRY and convert2pdf:
                 # cannot convert dir entries to PDF
@@ -3834,6 +3895,9 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
 <pre><code class="python">def update_data(self, data=None):
         assert (data is not None), '<data> must not be <None>'
 
+        if self.is_DIRENTRY or self.is_document_part:
+                return False
+
         SQL = """
                 UPDATE clin.export_item SET
                         data = %(data)s::bytea,
@@ -3847,7 +3911,7 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
 </details>
 </dd>
 <dt id="Gnumed.business.gmExportArea.cExportItem.update_data_from_file"><code class="name flex">
-<span>def <span class="ident">update_data_from_file</span></span>(<span>self, filename=None)</span>
+<span>def <span class="ident">update_data_from_file</span></span>(<span>self, filename=None, convert_document_part=False)</span>
 </code></dt>
 <dd>
 <div class="desc"></div>
@@ -3855,7 +3919,15 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
 <summary>
 <span>Expand source code</span>
 </summary>
-<pre><code class="python">def update_data_from_file(self, filename=None):
+<pre><code class="python">def update_data_from_file(self, filename=None, convert_document_part=False):
+
+        if self.is_DIRENTRY:
+                return False
+
+        if self.is_document_part:
+                if not convert_document_part:
+                        return False
+
         # sanity check
         if not (os.access(filename, os.R_OK) and os.path.isfile(filename)):
                 _log.error('[%s] is not a readable file' % filename)
@@ -3953,6 +4025,7 @@ objects = [ cChildClass(row = {'data': r, 'idx': idx, 'pk_field': 'the PK column
 <li><code><a title="Gnumed.business.gmExportArea.cExportItem.has_files_in_root" href="gmExportArea.html#Gnumed.business.gmExportArea.cExportItem.has_files_in_root">has_files_in_root</a></code></li>
 <li><code><a title="Gnumed.business.gmExportArea.cExportItem.is_DICOM_directory" href="gmExportArea.html#Gnumed.business.gmExportArea.cExportItem.is_DICOM_directory">is_DICOM_directory</a></code></li>
 <li><code><a title="Gnumed.business.gmExportArea.cExportItem.is_DIRENTRY" href="gmExportArea.html#Gnumed.business.gmExportArea.cExportItem.is_DIRENTRY">is_DIRENTRY</a></code></li>
+<li><code><a title="Gnumed.business.gmExportArea.cExportItem.is_document_part" href="gmExportArea.html#Gnumed.business.gmExportArea.cExportItem.is_document_part">is_document_part</a></code></li>
 <li><code><a title="Gnumed.business.gmExportArea.cExportItem.is_print_job" href="gmExportArea.html#Gnumed.business.gmExportArea.cExportItem.is_print_job">is_print_job</a></code></li>
 <li><code><a title="Gnumed.business.gmExportArea.cExportItem.is_valid_DIRENTRY" href="gmExportArea.html#Gnumed.business.gmExportArea.cExportItem.is_valid_DIRENTRY">is_valid_DIRENTRY</a></code></li>
 <li><code><a title="Gnumed.business.gmExportArea.cExportItem.save_to_file" href="gmExportArea.html#Gnumed.business.gmExportArea.cExportItem.save_to_file">save_to_file</a></code></li>


=====================================
client/doc/api/gmTools.html
=====================================
@@ -204,7 +204,7 @@ def mkdir(directory=None, mode=None) -> bool:
         Args:
                 mode: numeric, say 0o0700 for "-rwx------"
 
-        Results:
+        Returns:
                 True/False based on success
         """
         if os.path.isdir(directory):
@@ -234,8 +234,11 @@ def mkdir(directory=None, mode=None) -> bool:
         return True
 
 #---------------------------------------------------------------------------
-def create_directory_description_file(directory=None, readme=None, suffix=None):
+def create_directory_description_file(directory:str=None, readme:str=None, suffix:str=None) -> bool:
         """Create a directory description file.
+
+                Returns:
+                        <False> if it cannot create the description file.
         """
         assert (directory is not None), '<directory> must not be None'
 
@@ -428,7 +431,13 @@ class gmPaths(gmBorg.cBorg):
 
         - .working_dir: current dir
 
-        - .user_config_dir
+        - .user_config_dir, in the following order:
+                - ~/.config/gnumed/
+                - ~/
+
+        - .user_appdata_dir, in the following order:
+                - ~/.local/gnumed/
+                - ~/
 
         - .system_config_dir
 
@@ -502,15 +511,25 @@ class gmPaths(gmBorg.cBorg):
                 # the current working dir at the OS
                 self.working_dir = os.path.abspath(os.curdir)
 
-                # user-specific config dir, usually below the home dir
-                mkdir(os.path.join(self.home_dir, '.%s' % app_name))
-                self.user_config_dir = os.path.join(self.home_dir, '.%s' % app_name)
+                # user-specific config dir, usually below the home dir, default to $XDG_CONFIG_HOME
+                _dir = os.path.join(self.home_dir, '.config', app_name)
+                if not mkdir(_dir):
+                        _log.error('cannot make config dir [%s], falling back to home dir', _dir)
+                        _dir = self.home_dir
+                self.user_config_dir = _dir
 
                 # user-specific app dir, usually below the home dir
                 mkdir(os.path.join(self.home_dir, app_name))
                 self.user_work_dir = os.path.join(self.home_dir, app_name)
 
-                # system-wide config dir, usually below /etc/ under UN*X
+                # user-specific app data/state dir, usually below home dir
+                _dir = os.path.join(self.home_dir, '.local', app_name)
+                if not mkdir(_dir):
+                        _log.error('cannot make data/state dir [%s], falling back to home dir', _dir)
+                        _dir = self.home_dir
+                self.user_appdata_dir = _dir
+
+                # system-wide config dir, under UN*X usually below /etc/
                 try:
                         self.system_config_dir = os.path.join('/etc', app_name)
                 except ValueError:
@@ -564,8 +583,15 @@ class gmPaths(gmBorg.cBorg):
                 _log.info('wxPython app name is [%s]', wx.GetApp().GetAppName())
 
                 # user-specific config dir, usually below the home dir
-                mkdir(os.path.join(std_paths.GetUserConfigDir(), '.%s' % app_name))
-                self.user_config_dir = os.path.join(std_paths.GetUserConfigDir(), '.%s' % app_name)
+                _dir = std_paths.UserConfigDir
+                if _dir == self.home_dir:
+                        _dir = os.path.join(self.home_dir, '.config', app_name)
+                else:
+                        _dir = os.path.join(_dir, '.%s' % app_name)
+                if not mkdir(_dir):
+                        _log.error('cannot make config dir [%s], falling back to home dir', _dir)
+                        _dir = self.home_dir
+                self.user_config_dir = _dir
 
                 # system-wide config dir, usually below /etc/ under UN*X
                 try:
@@ -602,6 +628,7 @@ class gmPaths(gmBorg.cBorg):
                 _log.debug('current working dir: %s', self.working_dir)
                 _log.debug('user home dir: %s', self.home_dir)
                 _log.debug('user-specific config dir: %s', self.user_config_dir)
+                _log.debug('user-specific application data dir: %s', self.user_appdata_dir)
                 _log.debug('system-wide config dir: %s', self.system_config_dir)
                 _log.debug('system-wide application data dir: %s', self.system_app_data_dir)
                 _log.debug('temporary dir (user): %s', self.user_tmp_dir)
@@ -615,7 +642,7 @@ class gmPaths(gmBorg.cBorg):
         #--------------------------------------
         def _set_user_config_dir(self, path):
                 if not (os.access(path, os.R_OK) and os.access(path, os.X_OK)):
-                        msg = '[%s:user_config_dir]: invalid path [%s]' % (self.__class__.__name__, path)
+                        msg = '[%s:user_config_dir]: unusable path [%s]' % (self.__class__.__name__, path)
                         _log.error(msg)
                         raise ValueError(msg)
                 self.__user_config_dir = path
@@ -625,6 +652,7 @@ class gmPaths(gmBorg.cBorg):
                 return self.__user_config_dir
 
         user_config_dir = property(_get_user_config_dir, _set_user_config_dir)
+
         #--------------------------------------
         def _set_system_config_dir(self, path):
                 if not (os.access(path, os.R_OK) and os.access(path, os.X_OK)):
@@ -762,6 +790,9 @@ def remove_file(filename:str, log_error:bool=True, force:bool=False) -> bool:
         Args:
                 filename: file to remove
                 force: if remove does not work attempt to rename the file
+
+        Returns:
+                True/False: Removed or not.
         """
         if not os.path.lexists(filename):
                 return True
@@ -774,16 +805,76 @@ def remove_file(filename:str, log_error:bool=True, force:bool=False) -> bool:
         except Exception:
                 if log_error:
                         _log.exception('cannot os.remove(%s)', filename)
-        if force:
-                tmp_name = get_unique_filename(tmp_dir = fname_dir(filename))
-                _log.debug('attempting os.replace(%s -> %s)', filename, tmp_name)
-                try:
-                        os.replace(filename, tmp_name)
-                        return True
+        if not force:
+                return False
+
+        tmp_name = get_unique_filename(tmp_dir = fname_dir(filename))
+        _log.debug('attempting os.replace(%s -> %s)', filename, tmp_name)
+        try:
+                os.replace(filename, tmp_name)
+                return True
+
+        except Exception:
+                if log_error:
+                        _log.exception('cannot os.replace(%s)', filename)
+        return False
+
+#---------------------------------------------------------------------------
+def rename_file(filename:str, new_filename:str, overwrite:bool=False, allow_symlink:bool=False) -> bool:
+        """Rename a file.
+
+        Args:
+                filename: source filename
+                new_filename: target filename
+                overwrite: overwrite existing target ?
+                allow_symlink: allow soft links ?
+
+        Returns:
+                True/False: Renamed or not.
+        """
+        _log.debug('renaming: source [%s] -> target [%s]', filename, new_filename)
+        if filename == new_filename:
+                return True
+
+        if not os.path.lexists(filename):
+                _log.error('source does not exist')
+                return False
+
+        if overwrite and not remove_file(new_filename, force = True):
+                _log.error('cannot remove existing target')
+                return False
+
+        try:
+                shutil.move(filename, new_filename)
+                return True
+
+        except OSError:
+                _log.exception('shutil.move() failed')
+
+        try:
+                os.replace(filename, new_filename)
+                return True
+
+        except Exception:
+                _log.exception('os.replace() failed')
+
+        try:
+                os.link(filename, new_filename)
+                return True
+
+        except Exeption:
+                _log.exception('os.link() failed')
+
+        if not allow_symlink:
+                return False
+
+        try:
+                os.symlink(filename, new_filename)
+                return True
+
+        except Exeption:
+                _log.exception('os.symlink() failed')
 
-                except Exception:
-                        if log_error:
-                                _log.exception('cannot os.replace(%s)', filename)
         return False
 
 #---------------------------------------------------------------------------
@@ -1484,38 +1575,48 @@ def xml_escape_string(text=None):
         return xml_tools.escape(text)
 
 #---------------------------------------------------------------------------
-def tex_escape_string(text=None, replace_known_unicode=True, replace_eol=False, keep_visual_eol=False):
+def tex_escape_string(text:str=None, replace_known_unicode:bool=True, replace_eol:bool=False, keep_visual_eol:bool=False) -> str:
         """Check for special TeX characters and transform them.
 
-                replace_eol:
-                        replaces "\n" with "\\newline"
-                keep_visual_eol:
-                        replaces "\n" with "\\newline \n" such that
+        Args:
+                text: plain (unicode) text to escape for LaTeX processing,
+                        note that any valid LaTeX code contained within will be
+                        escaped, too
+                replace_eol: replaces "\n" with "\\newline{}"
+                keep_visual_eol: replaces "\n" with "\\newline{}%\n" such that
                         both LaTeX will know to place a line break
                         at this point as well as the visual formatting
                         is preserved in the LaTeX source (think multi-
                         row table cells)
+
+        Returns:
         """
-        text = text.replace('\\', '\\textbackslash')                    # requires \usepackage{textcomp} in LaTeX source
-        text = text.replace('^', '\\textasciicircum')
-        text = text.replace('~', '\\textasciitilde')
-
-        text = text.replace('{', '\\{')
-        text = text.replace('}', '\\}')
-        text = text.replace('%', '\\%')
-        text = text.replace('&', '\\&')
-        text = text.replace('#', '\\#')
-        text = text.replace('$', '\\$')
-        text = text.replace('_', '\\_')
+        # must happen first
+        text = text.replace('{', '-----{{{{{-----')
+        text = text.replace('}', '-----}}}}}-----')
+
+        text = text.replace('\\', '\\textbackslash{}')                  # requires \usepackage{textcomp} in LaTeX source
+
+        text = text.replace('-----{{{{{-----', '\\{{}')
+        text = text.replace('-----}}}}}-----', '\\}{}')
+
+        text = text.replace('^', '\\textasciicircum{}')
+        text = text.replace('~', '\\textasciitilde{}')
+
+        text = text.replace('%', '\\%{}')
+        text = text.replace('&', '\\&{}')
+        text = text.replace('#', '\\#{}')
+        text = text.replace('$', '\\${}')
+        text = text.replace('_', '\\_{}')
         if replace_eol:
                 if keep_visual_eol:
-                        text = text.replace('\n', '\\newline \n')
+                        text = text.replace('\n', '\\newline{}%\n')
                 else:
-                        text = text.replace('\n', '\\newline ')
+                        text = text.replace('\n', '\\newline{}')
 
         if replace_known_unicode:
                 # this should NOT be replaced for Xe(La)Tex
-                text = text.replace(u_euro, '\\EUR')            # requires \usepackage{textcomp} in LaTeX source
+                text = text.replace(u_euro, '\\euro{}')         # requires \usepackage[official]{eurosym} in LaTeX source
                 text = text.replace(u_sum, '$\\Sigma$')
 
         return text
@@ -2275,6 +2376,7 @@ if __name__ == '__main__':
                 paths = gmPaths(wx=None, app_name='gnumed')
                 print("user       home dir:", paths.home_dir)
                 print("user     config dir:", paths.user_config_dir)
+                print("user    appdata dir:", paths.user_appdata_dir)
                 print("user       work dir:", paths.user_work_dir)
                 print("user       temp dir:", paths.user_tmp_dir)
                 print("user+app   temp dir:", paths.tmp_dir)
@@ -2674,7 +2776,7 @@ second line\n
         #test_xml_escape()
         #test_strip_trailing_empty_lines()
         #test_fname_stem()
-        #test_tex_escape()
+        test_tex_escape()
         #test_rst2latex_snippet()
         #test_dir_is_empty()
         #test_compare_dicts()
@@ -2691,7 +2793,8 @@ second line\n
         #test_mk_sandbox_dir()
         #test_make_table_from_dicts()
         #test_create_dir_desc_file()
-        test_dir_list_files()
+        #test_dir_list_files()
+        #test_decorate_window_title()
 
 #===========================================================================</code></pre>
 </details>
@@ -2988,16 +3091,21 @@ have issues ! However, for UTF strings it should just work.</p></div>
 </details>
 </dd>
 <dt id="Gnumed.pycommon.gmTools.create_directory_description_file"><code class="name flex">
-<span>def <span class="ident">create_directory_description_file</span></span>(<span>directory=None, readme=None, suffix=None)</span>
+<span>def <span class="ident">create_directory_description_file</span></span>(<span>directory: str = None, readme: str = None, suffix: str = None) ‑> bool</span>
 </code></dt>
 <dd>
-<div class="desc"><p>Create a directory description file.</p></div>
+<div class="desc"><p>Create a directory description file.</p>
+<h2 id="returns">Returns</h2>
+<p><False> if it cannot create the description file.</p></div>
 <details class="source">
 <summary>
 <span>Expand source code</span>
 </summary>
-<pre><code class="python">def create_directory_description_file(directory=None, readme=None, suffix=None):
+<pre><code class="python">def create_directory_description_file(directory:str=None, readme:str=None, suffix:str=None) -> bool:
         """Create a directory description file.
+
+                Returns:
+                        <False> if it cannot create the description file.
         """
         assert (directory is not None), '<directory> must not be None'
 
@@ -4094,7 +4202,7 @@ filename here and actually using the filename in callers.</p>
 <dt><strong><code>mode</code></strong></dt>
 <dd>numeric, say 0o0700 for "-rwx------"</dd>
 </dl>
-<h2 id="results">Results</h2>
+<h2 id="returns">Returns</h2>
 <p>True/False based on success</p></div>
 <details class="source">
 <summary>
@@ -4109,7 +4217,7 @@ filename here and actually using the filename in callers.</p>
         Args:
                 mode: numeric, say 0o0700 for "-rwx------"
 
-        Results:
+        Returns:
                 True/False based on success
         """
         if os.path.isdir(directory):
@@ -4411,7 +4519,9 @@ filename here and actually using the filename in callers.</p>
 <dd>file to remove</dd>
 <dt><strong><code>force</code></strong></dt>
 <dd>if remove does not work attempt to rename the file</dd>
-</dl></div>
+</dl>
+<h2 id="returns">Returns</h2>
+<p>True/False: Removed or not.</p></div>
 <details class="source">
 <summary>
 <span>Expand source code</span>
@@ -4422,6 +4532,9 @@ filename here and actually using the filename in callers.</p>
         Args:
                 filename: file to remove
                 force: if remove does not work attempt to rename the file
+
+        Returns:
+                True/False: Removed or not.
         """
         if not os.path.lexists(filename):
                 return True
@@ -4434,16 +4547,98 @@ filename here and actually using the filename in callers.</p>
         except Exception:
                 if log_error:
                         _log.exception('cannot os.remove(%s)', filename)
-        if force:
-                tmp_name = get_unique_filename(tmp_dir = fname_dir(filename))
-                _log.debug('attempting os.replace(%s -> %s)', filename, tmp_name)
-                try:
-                        os.replace(filename, tmp_name)
-                        return True
+        if not force:
+                return False
+
+        tmp_name = get_unique_filename(tmp_dir = fname_dir(filename))
+        _log.debug('attempting os.replace(%s -> %s)', filename, tmp_name)
+        try:
+                os.replace(filename, tmp_name)
+                return True
+
+        except Exception:
+                if log_error:
+                        _log.exception('cannot os.replace(%s)', filename)
+        return False</code></pre>
+</details>
+</dd>
+<dt id="Gnumed.pycommon.gmTools.rename_file"><code class="name flex">
+<span>def <span class="ident">rename_file</span></span>(<span>filename: str, new_filename: str, overwrite: bool = False, allow_symlink: bool = False) ‑> bool</span>
+</code></dt>
+<dd>
+<div class="desc"><p>Rename a file.</p>
+<h2 id="args">Args</h2>
+<dl>
+<dt><strong><code>filename</code></strong></dt>
+<dd>source filename</dd>
+<dt><strong><code>new_filename</code></strong></dt>
+<dd>target filename</dd>
+<dt><strong><code>overwrite</code></strong></dt>
+<dd>overwrite existing target ?</dd>
+<dt><strong><code>allow_symlink</code></strong></dt>
+<dd>allow soft links ?</dd>
+</dl>
+<h2 id="returns">Returns</h2>
+<p>True/False: Renamed or not.</p></div>
+<details class="source">
+<summary>
+<span>Expand source code</span>
+</summary>
+<pre><code class="python">def rename_file(filename:str, new_filename:str, overwrite:bool=False, allow_symlink:bool=False) -> bool:
+        """Rename a file.
+
+        Args:
+                filename: source filename
+                new_filename: target filename
+                overwrite: overwrite existing target ?
+                allow_symlink: allow soft links ?
+
+        Returns:
+                True/False: Renamed or not.
+        """
+        _log.debug('renaming: source [%s] -> target [%s]', filename, new_filename)
+        if filename == new_filename:
+                return True
+
+        if not os.path.lexists(filename):
+                _log.error('source does not exist')
+                return False
+
+        if overwrite and not remove_file(new_filename, force = True):
+                _log.error('cannot remove existing target')
+                return False
+
+        try:
+                shutil.move(filename, new_filename)
+                return True
+
+        except OSError:
+                _log.exception('shutil.move() failed')
+
+        try:
+                os.replace(filename, new_filename)
+                return True
+
+        except Exception:
+                _log.exception('os.replace() failed')
+
+        try:
+                os.link(filename, new_filename)
+                return True
+
+        except Exeption:
+                _log.exception('os.link() failed')
+
+        if not allow_symlink:
+                return False
+
+        try:
+                os.symlink(filename, new_filename)
+                return True
+
+        except Exeption:
+                _log.exception('os.symlink() failed')
 
-                except Exception:
-                        if log_error:
-                                _log.exception('cannot os.replace(%s)', filename)
         return False</code></pre>
 </details>
 </dd>
@@ -4768,58 +4963,72 @@ filename here and actually using the filename in callers.</p>
 </details>
 </dd>
 <dt id="Gnumed.pycommon.gmTools.tex_escape_string"><code class="name flex">
-<span>def <span class="ident">tex_escape_string</span></span>(<span>text=None, replace_known_unicode=True, replace_eol=False, keep_visual_eol=False)</span>
+<span>def <span class="ident">tex_escape_string</span></span>(<span>text: str = None, replace_known_unicode: bool = True, replace_eol: bool = False, keep_visual_eol: bool = False) ‑> str</span>
 </code></dt>
 <dd>
 <div class="desc"><p>Check for special TeX characters and transform them.</p>
-<pre><code>            replace_eol:
-                    replaces "
+<pre><code>    Args:
+            text: plain (unicode) text to escape for LaTeX processing,
+                    note that any valid LaTeX code contained within will be
+                    escaped, too
+            replace_eol: replaces "
 </code></pre>
-<p>" with "\newline"
-keep_visual_eol:
-replaces "
-" with "\newline
+<p>" with "\newline{}"
+keep_visual_eol: replaces "
+" with "\newline{}%
 " such that
 both LaTeX will know to place a line break
 at this point as well as the visual formatting
 is preserved in the LaTeX source (think multi-
-row table cells)</p></div>
+row table cells)</p>
+<pre><code>    Returns:
+</code></pre></div>
 <details class="source">
 <summary>
 <span>Expand source code</span>
 </summary>
-<pre><code class="python">def tex_escape_string(text=None, replace_known_unicode=True, replace_eol=False, keep_visual_eol=False):
+<pre><code class="python">def tex_escape_string(text:str=None, replace_known_unicode:bool=True, replace_eol:bool=False, keep_visual_eol:bool=False) -> str:
         """Check for special TeX characters and transform them.
 
-                replace_eol:
-                        replaces "\n" with "\\newline"
-                keep_visual_eol:
-                        replaces "\n" with "\\newline \n" such that
+        Args:
+                text: plain (unicode) text to escape for LaTeX processing,
+                        note that any valid LaTeX code contained within will be
+                        escaped, too
+                replace_eol: replaces "\n" with "\\newline{}"
+                keep_visual_eol: replaces "\n" with "\\newline{}%\n" such that
                         both LaTeX will know to place a line break
                         at this point as well as the visual formatting
                         is preserved in the LaTeX source (think multi-
                         row table cells)
+
+        Returns:
         """
-        text = text.replace('\\', '\\textbackslash')                    # requires \usepackage{textcomp} in LaTeX source
-        text = text.replace('^', '\\textasciicircum')
-        text = text.replace('~', '\\textasciitilde')
-
-        text = text.replace('{', '\\{')
-        text = text.replace('}', '\\}')
-        text = text.replace('%', '\\%')
-        text = text.replace('&', '\\&')
-        text = text.replace('#', '\\#')
-        text = text.replace('$', '\\$')
-        text = text.replace('_', '\\_')
+        # must happen first
+        text = text.replace('{', '-----{{{{{-----')
+        text = text.replace('}', '-----}}}}}-----')
+
+        text = text.replace('\\', '\\textbackslash{}')                  # requires \usepackage{textcomp} in LaTeX source
+
+        text = text.replace('-----{{{{{-----', '\\{{}')
+        text = text.replace('-----}}}}}-----', '\\}{}')
+
+        text = text.replace('^', '\\textasciicircum{}')
+        text = text.replace('~', '\\textasciitilde{}')
+
+        text = text.replace('%', '\\%{}')
+        text = text.replace('&', '\\&{}')
+        text = text.replace('#', '\\#{}')
+        text = text.replace('$', '\\${}')
+        text = text.replace('_', '\\_{}')
         if replace_eol:
                 if keep_visual_eol:
-                        text = text.replace('\n', '\\newline \n')
+                        text = text.replace('\n', '\\newline{}%\n')
                 else:
-                        text = text.replace('\n', '\\newline ')
+                        text = text.replace('\n', '\\newline{}')
 
         if replace_known_unicode:
                 # this should NOT be replaced for Xe(La)Tex
-                text = text.replace(u_euro, '\\EUR')            # requires \usepackage{textcomp} in LaTeX source
+                text = text.replace(u_euro, '\\euro{}')         # requires \usepackage[official]{eurosym} in LaTeX source
                 text = text.replace(u_sum, '$\\Sigma$')
 
         return text</code></pre>
@@ -4992,7 +5201,14 @@ breaks are posix newlines (
 <p>.working_dir: current dir</p>
 </li>
 <li>
-<p>.user_config_dir</p>
+<p>.user_config_dir, in the following order:
+- ~/.config/gnumed/
+- ~/</p>
+</li>
+<li>
+<p>.user_appdata_dir, in the following order:
+- ~/.local/gnumed/
+- ~/</p>
 </li>
 <li>
 <p>.system_config_dir</p>
@@ -5035,7 +5251,13 @@ breaks are posix newlines (
 
         - .working_dir: current dir
 
-        - .user_config_dir
+        - .user_config_dir, in the following order:
+                - ~/.config/gnumed/
+                - ~/
+
+        - .user_appdata_dir, in the following order:
+                - ~/.local/gnumed/
+                - ~/
 
         - .system_config_dir
 
@@ -5109,15 +5331,25 @@ breaks are posix newlines (
                 # the current working dir at the OS
                 self.working_dir = os.path.abspath(os.curdir)
 
-                # user-specific config dir, usually below the home dir
-                mkdir(os.path.join(self.home_dir, '.%s' % app_name))
-                self.user_config_dir = os.path.join(self.home_dir, '.%s' % app_name)
+                # user-specific config dir, usually below the home dir, default to $XDG_CONFIG_HOME
+                _dir = os.path.join(self.home_dir, '.config', app_name)
+                if not mkdir(_dir):
+                        _log.error('cannot make config dir [%s], falling back to home dir', _dir)
+                        _dir = self.home_dir
+                self.user_config_dir = _dir
 
                 # user-specific app dir, usually below the home dir
                 mkdir(os.path.join(self.home_dir, app_name))
                 self.user_work_dir = os.path.join(self.home_dir, app_name)
 
-                # system-wide config dir, usually below /etc/ under UN*X
+                # user-specific app data/state dir, usually below home dir
+                _dir = os.path.join(self.home_dir, '.local', app_name)
+                if not mkdir(_dir):
+                        _log.error('cannot make data/state dir [%s], falling back to home dir', _dir)
+                        _dir = self.home_dir
+                self.user_appdata_dir = _dir
+
+                # system-wide config dir, under UN*X usually below /etc/
                 try:
                         self.system_config_dir = os.path.join('/etc', app_name)
                 except ValueError:
@@ -5171,8 +5403,15 @@ breaks are posix newlines (
                 _log.info('wxPython app name is [%s]', wx.GetApp().GetAppName())
 
                 # user-specific config dir, usually below the home dir
-                mkdir(os.path.join(std_paths.GetUserConfigDir(), '.%s' % app_name))
-                self.user_config_dir = os.path.join(std_paths.GetUserConfigDir(), '.%s' % app_name)
+                _dir = std_paths.UserConfigDir
+                if _dir == self.home_dir:
+                        _dir = os.path.join(self.home_dir, '.config', app_name)
+                else:
+                        _dir = os.path.join(_dir, '.%s' % app_name)
+                if not mkdir(_dir):
+                        _log.error('cannot make config dir [%s], falling back to home dir', _dir)
+                        _dir = self.home_dir
+                self.user_config_dir = _dir
 
                 # system-wide config dir, usually below /etc/ under UN*X
                 try:
@@ -5209,6 +5448,7 @@ breaks are posix newlines (
                 _log.debug('current working dir: %s', self.working_dir)
                 _log.debug('user home dir: %s', self.home_dir)
                 _log.debug('user-specific config dir: %s', self.user_config_dir)
+                _log.debug('user-specific application data dir: %s', self.user_appdata_dir)
                 _log.debug('system-wide config dir: %s', self.system_config_dir)
                 _log.debug('system-wide application data dir: %s', self.system_app_data_dir)
                 _log.debug('temporary dir (user): %s', self.user_tmp_dir)
@@ -5222,7 +5462,7 @@ breaks are posix newlines (
         #--------------------------------------
         def _set_user_config_dir(self, path):
                 if not (os.access(path, os.R_OK) and os.access(path, os.X_OK)):
-                        msg = '[%s:user_config_dir]: invalid path [%s]' % (self.__class__.__name__, path)
+                        msg = '[%s:user_config_dir]: unusable path [%s]' % (self.__class__.__name__, path)
                         _log.error(msg)
                         raise ValueError(msg)
                 self.__user_config_dir = path
@@ -5232,6 +5472,7 @@ breaks are posix newlines (
                 return self.__user_config_dir
 
         user_config_dir = property(_get_user_config_dir, _set_user_config_dir)
+
         #--------------------------------------
         def _set_system_config_dir(self, path):
                 if not (os.access(path, os.R_OK) and os.access(path, os.X_OK)):
@@ -5490,15 +5731,25 @@ breaks are posix newlines (
         # the current working dir at the OS
         self.working_dir = os.path.abspath(os.curdir)
 
-        # user-specific config dir, usually below the home dir
-        mkdir(os.path.join(self.home_dir, '.%s' % app_name))
-        self.user_config_dir = os.path.join(self.home_dir, '.%s' % app_name)
+        # user-specific config dir, usually below the home dir, default to $XDG_CONFIG_HOME
+        _dir = os.path.join(self.home_dir, '.config', app_name)
+        if not mkdir(_dir):
+                _log.error('cannot make config dir [%s], falling back to home dir', _dir)
+                _dir = self.home_dir
+        self.user_config_dir = _dir
 
         # user-specific app dir, usually below the home dir
         mkdir(os.path.join(self.home_dir, app_name))
         self.user_work_dir = os.path.join(self.home_dir, app_name)
 
-        # system-wide config dir, usually below /etc/ under UN*X
+        # user-specific app data/state dir, usually below home dir
+        _dir = os.path.join(self.home_dir, '.local', app_name)
+        if not mkdir(_dir):
+                _log.error('cannot make data/state dir [%s], falling back to home dir', _dir)
+                _dir = self.home_dir
+        self.user_appdata_dir = _dir
+
+        # system-wide config dir, under UN*X usually below /etc/
         try:
                 self.system_config_dir = os.path.join('/etc', app_name)
         except ValueError:
@@ -5552,8 +5803,15 @@ breaks are posix newlines (
         _log.info('wxPython app name is [%s]', wx.GetApp().GetAppName())
 
         # user-specific config dir, usually below the home dir
-        mkdir(os.path.join(std_paths.GetUserConfigDir(), '.%s' % app_name))
-        self.user_config_dir = os.path.join(std_paths.GetUserConfigDir(), '.%s' % app_name)
+        _dir = std_paths.UserConfigDir
+        if _dir == self.home_dir:
+                _dir = os.path.join(self.home_dir, '.config', app_name)
+        else:
+                _dir = os.path.join(_dir, '.%s' % app_name)
+        if not mkdir(_dir):
+                _log.error('cannot make config dir [%s], falling back to home dir', _dir)
+                _dir = self.home_dir
+        self.user_config_dir = _dir
 
         # system-wide config dir, usually below /etc/ under UN*X
         try:
@@ -5643,6 +5901,7 @@ breaks are posix newlines (
 <li><code><a title="Gnumed.pycommon.gmTools.prompted_input" href="gmTools.html#Gnumed.pycommon.gmTools.prompted_input">prompted_input</a></code></li>
 <li><code><a title="Gnumed.pycommon.gmTools.recode_file" href="gmTools.html#Gnumed.pycommon.gmTools.recode_file">recode_file</a></code></li>
 <li><code><a title="Gnumed.pycommon.gmTools.remove_file" href="gmTools.html#Gnumed.pycommon.gmTools.remove_file">remove_file</a></code></li>
+<li><code><a title="Gnumed.pycommon.gmTools.rename_file" href="gmTools.html#Gnumed.pycommon.gmTools.rename_file">rename_file</a></code></li>
 <li><code><a title="Gnumed.pycommon.gmTools.rm_dir_content" href="gmTools.html#Gnumed.pycommon.gmTools.rm_dir_content">rm_dir_content</a></code></li>
 <li><code><a title="Gnumed.pycommon.gmTools.rmdir" href="gmTools.html#Gnumed.pycommon.gmTools.rmdir">rmdir</a></code></li>
 <li><code><a title="Gnumed.pycommon.gmTools.rst2html" href="gmTools.html#Gnumed.pycommon.gmTools.rst2html">rst2html</a></code></li>


=====================================
client/doc/schema/gnumed-entire_schema.html
=====================================
@@ -112,7 +112,7 @@
   <body>
 
     <!-- Primary Index -->
-	<p><br><br>Dumped on 2021-08-02</p>
+	<p><br><br>Dumped on 2022-01-19</p>
 <h1><a name="index">Index of database - gnumed_v22</a></h1>
 <ul>
     


=====================================
client/gnumed.py
=====================================
@@ -95,7 +95,7 @@ against. Please run GNUmed as a non-root user.
 	sys.exit(1)
 
 #----------------------------------------------------------
-current_client_version = '1.8.6'
+current_client_version = '1.8.7'
 current_client_branch = '1.8'
 
 _log = None


=====================================
client/pycommon/gmMimeLib.py
=====================================
@@ -189,16 +189,19 @@ def adjust_extension_by_mimetype(filename):
 	mime_suffix = guess_ext_by_mimetype(mimetype)
 	if mime_suffix is None:
 		return filename
+
 	old_name, old_ext = os.path.splitext(filename)
 	if old_ext == '':
 		new_filename = filename + mime_suffix
 	elif old_ext.lower() == mime_suffix.lower():
 		return filename
+
 	new_filename = old_name + mime_suffix
 	_log.debug('[%s] -> [%s]', filename, new_filename)
 	try:
 		os.rename(filename, new_filename)
 		return new_filename
+
 	except OSError:
 		_log.exception('cannot rename, returning original filename')
 	return filename


=====================================
client/wxpython/gmTopPanel.py
=====================================
@@ -211,8 +211,8 @@ class cTopPnl(wxgTopPnl.wxgTopPnl):
 
 		HRs = self.curr_pat.emr.get_most_recent_results_in_loinc_group(loincs = gmLOINC.LOINC_heart_rate_quantity, max_no_of_results = 1)
 		if len(HRs) > 0:
-			tests2show.append('@ %s' % (HRs[0]['abbrev_tt'], HRs[0]['unified_val']))
-			tooltip_lines.append(_('%s (@): %s ago') % (
+			tests2show.append('@ %s' % HRs[0]['unified_val'])
+			tooltip_lines.append(_('%s (@) in bpm: %s ago') % (
 				HRs[0]['abbrev_tt'],
 				gmDateTime.format_apparent_age_medically (
 					age = gmDateTime.calculate_apparent_age(start = HRs[0]['clin_when'])


=====================================
debian/changelog
=====================================
@@ -1,3 +1,11 @@
+gnumed-client (1.8.7+dfsg-1) unstable; urgency=medium
+
+  * New upstream version
+    Closes: #1005388
+  * Update Build-Depends
+
+ -- Andreas Tille <tille at debian.org>  Sat, 12 Feb 2022 22:04:45 +0100
+
 gnumed-client (1.8.6+dfsg-1) unstable; urgency=medium
 
   * New upstream version


=====================================
debian/control
=====================================
@@ -51,7 +51,6 @@ Recommends: gnumed-doc (= ${source:Version}),
             gtklp,
             texlive-latex-recommended,
             texlive-latex-extra,
-            cups-pdf,
             pdftk,
             python3-vobject,
             python3-pyqrcode,
@@ -118,7 +117,7 @@ Package: gnumed-common
 Architecture: all
 Depends: ${python3:Depends},
          ${misc:Depends},
-         python3-psycopg2 (>= 2.7.4~)
+         python3-psycopg2
 Suggests: dmtx-utils | iec16022
 Provides: ${python3:Provides}
 Description: medical practice management - common files



View it on GitLab: https://salsa.debian.org/med-team/gnumed-client/-/compare/b235313318e4786a0f1de4d70f733888ae9f5091...a6594b1a085d593c33a11dd2eab7285e16e00de6

-- 
View it on GitLab: https://salsa.debian.org/med-team/gnumed-client/-/compare/b235313318e4786a0f1de4d70f733888ae9f5091...a6594b1a085d593c33a11dd2eab7285e16e00de6
You're receiving this email because of your account on salsa.debian.org.


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/debian-med-commit/attachments/20220212/69ffff1e/attachment-0001.htm>


More information about the debian-med-commit mailing list