Allow opening in external editor
CHANGELOG.md
| 8 | 8 | * A binary bundle for running the application under Linux is now available. | |
| 9 | 9 | Users do not need to compile and install dependencies by themselves. | |
| 10 | 10 | * New format for Android strings.xml files | |
| 11 | + | * New button to open a project in an external editor. When in the external editor, | |
| 12 | + | offlate's editon zone is grayed out but kept in sync with what the external | |
| 13 | + | editor is doing. When the external editor is closed, offlate will again allow | |
| 14 | + | edition. | |
| 11 | 15 | ||
| 12 | 16 | ### UI Changes ### | |
| 13 | 17 |
guix.scm
| 111 | 111 | (arguments | |
| 112 | 112 | `(#:phases | |
| 113 | 113 | (modify-phases %standard-phases | |
| 114 | - | (add-before 'build 'remove-failing-test | |
| 115 | - | (lambda _ | |
| 116 | - | (delete-file "translation_finder/test_api.py") | |
| 117 | - | #t))))) | |
| 114 | + | (add-before 'build 'remove-failing-test | |
| 115 | + | (lambda _ | |
| 116 | + | (delete-file "translation_finder/test_api.py") | |
| 117 | + | #t))))) | |
| 118 | 118 | (propagated-inputs | |
| 119 | 119 | `(("python-chardet" ,python-chardet) | |
| 120 | 120 | ("python-pathlib2" ,python-pathlib2) | |
| 121 | - | ("python-ruamel.yaml" ,python-ruamel.yaml) | |
| 121 | + | ("python-ruamel.yaml" ,python-ruamel.yaml) | |
| 122 | 122 | ("python-six" ,python-six))) | |
| 123 | 123 | (native-inputs | |
| 124 | 124 | `(("python-codecov" ,python-codecov) | |
… | |||
| 187 | 187 | (origin | |
| 188 | 188 | (method git-fetch) | |
| 189 | 189 | (uri (git-reference | |
| 190 | - | (url "https://framagit.org/tyreunom/python-android-strings-lib") | |
| 191 | - | (commit "0415535b125a64eb6da20a6b15ad92456e73ca8d"))) | |
| 192 | - | (file-name (git-file-name name version)) | |
| 190 | + | (url "https://framagit.org/tyreunom/python-android-strings-lib") | |
| 191 | + | (commit "0415535b125a64eb6da20a6b15ad92456e73ca8d"))) | |
| 192 | + | (file-name (git-file-name name version)) | |
| 193 | 193 | (sha256 | |
| 194 | 194 | (base32 | |
| 195 | 195 | "0icalva7a5w8a6qcrgkxkywckdqv8r5j5y9d5m6ln8bbk9s9fbls")))) | |
… | |||
| 204 | 204 | "Android Strings Lib provides support for android's strings.xml files. These files are used to translate strings in android apps.") | |
| 205 | 205 | (license license:expat))) | |
| 206 | 206 | ||
| 207 | + | (define-public python-pathtools | |
| 208 | + | (package | |
| 209 | + | (name "python-pathtools") | |
| 210 | + | (version "0.1.2") | |
| 211 | + | (source | |
| 212 | + | (origin | |
| 213 | + | (method url-fetch) | |
| 214 | + | (uri (pypi-uri "pathtools" version)) | |
| 215 | + | (sha256 | |
| 216 | + | (base32 | |
| 217 | + | "1h7iam33vwxk8bvslfj4qlsdprdnwf8bvzhqh3jq5frr391cadbw")))) | |
| 218 | + | (build-system python-build-system) | |
| 219 | + | (home-page | |
| 220 | + | "http://github.com/gorakhargosh/pathtools") | |
| 221 | + | (synopsis "File system general utilities") | |
| 222 | + | (description "File system general utilities") | |
| 223 | + | (license #f))) | |
| 224 | + | ||
| 225 | + | (define-public python-iocapture | |
| 226 | + | (package | |
| 227 | + | (name "python-iocapture") | |
| 228 | + | (version "0.1.2") | |
| 229 | + | (source | |
| 230 | + | (origin | |
| 231 | + | (method url-fetch) | |
| 232 | + | (uri (pypi-uri "iocapture" version)) | |
| 233 | + | (sha256 | |
| 234 | + | (base32 | |
| 235 | + | "1s3ywdr0l3kfrrqi079iv16g0rp75akkvx0j07vx9p5w10c0wrw6")))) | |
| 236 | + | (build-system python-build-system) | |
| 237 | + | (native-inputs | |
| 238 | + | `(("python-flexmock" ,python-flexmock) | |
| 239 | + | ("python-pytest-cov" ,python-pytest-cov) | |
| 240 | + | ("python-six" ,python-six))) | |
| 241 | + | (home-page "https://github.com/oinume/iocapture") | |
| 242 | + | (synopsis "Capture stdout, stderr easily.") | |
| 243 | + | (description "Capture stdout, stderr easily.") | |
| 244 | + | (license license:expat))) | |
| 245 | + | ||
| 246 | + | (define-public python-argh | |
| 247 | + | (package | |
| 248 | + | (name "python-argh") | |
| 249 | + | (version "0.26.2") | |
| 250 | + | (source | |
| 251 | + | (origin | |
| 252 | + | (method url-fetch) | |
| 253 | + | (uri (pypi-uri "argh" version)) | |
| 254 | + | (sha256 | |
| 255 | + | (base32 | |
| 256 | + | "0rdv0n2aa181mkrybwvl3czkrrikgzd4y2cri6j735fwhj65nlz9")))) | |
| 257 | + | (build-system python-build-system) | |
| 258 | + | (native-inputs | |
| 259 | + | `(("python-iocapture" ,python-iocapture) | |
| 260 | + | ("python-mock" ,python-mock) | |
| 261 | + | ("python-pytest" ,python-pytest))) | |
| 262 | + | (home-page "http://github.com/neithere/argh/") | |
| 263 | + | (synopsis | |
| 264 | + | "An unobtrusive argparse wrapper with natural syntax") | |
| 265 | + | (description | |
| 266 | + | "An unobtrusive argparse wrapper with natural syntax") | |
| 267 | + | (license #f))) | |
| 268 | + | ||
| 269 | + | (define-public python-watchdog | |
| 270 | + | (package | |
| 271 | + | (name "python-watchdog") | |
| 272 | + | (version "0.9.0") | |
| 273 | + | (source | |
| 274 | + | (origin | |
| 275 | + | (method url-fetch) | |
| 276 | + | (uri (pypi-uri "watchdog" version)) | |
| 277 | + | (sha256 | |
| 278 | + | (base32 | |
| 279 | + | "07cnvvlpif7a6cg4rav39zq8fxa5pfqawchr46433pij0y6napwn")))) | |
| 280 | + | (build-system python-build-system) | |
| 281 | + | (arguments | |
| 282 | + | `(#:phases | |
| 283 | + | (modify-phases %standard-phases | |
| 284 | + | (add-before 'check 'remove-failing | |
| 285 | + | (lambda _ | |
| 286 | + | (delete-file "tests/test_inotify_buffer.py") | |
| 287 | + | (delete-file "tests/test_snapshot_diff.py") | |
| 288 | + | #t))))) | |
| 289 | + | (propagated-inputs | |
| 290 | + | `(("python-argh" ,python-argh) | |
| 291 | + | ("python-pathtools" ,python-pathtools) | |
| 292 | + | ("python-pyyaml" ,python-pyyaml))) | |
| 293 | + | (native-inputs | |
| 294 | + | `(("python-pytest-cov" ,python-pytest-cov) | |
| 295 | + | ("python-pytest-timeout" ,python-pytest-timeout))) | |
| 296 | + | (home-page | |
| 297 | + | "http://github.com/gorakhargosh/watchdog") | |
| 298 | + | (synopsis "Filesystem events monitoring") | |
| 299 | + | (description "Filesystem events monitoring") | |
| 300 | + | (license #f))) | |
| 301 | + | ||
| 207 | 302 | (define-public python-altgraph | |
| 208 | 303 | (package | |
| 209 | 304 | (name "python-altgraph") | |
… | |||
| 276 | 371 | ("python-pyqt" ,python-pyqt) | |
| 277 | 372 | ("python-requests" ,python-requests) | |
| 278 | 373 | ("python-ruamel.yaml" ,python-ruamel.yaml) | |
| 279 | - | ("python-translation-finder" ,python-translation-finder))) | |
| 374 | + | ("python-translation-finder" ,python-translation-finder) | |
| 375 | + | ("python-watchdog" ,python-watchdog))) | |
| 280 | 376 | (native-inputs | |
| 281 | 377 | `(("python-pyinstaller" ,python-pyinstaller))) | |
| 282 | 378 | (home-page | |
offlate/formats/androidstrings.py
| 23 | 23 | ||
| 24 | 24 | class AndroidStringsFormat: | |
| 25 | 25 | def __init__(self, conf): | |
| 26 | + | self.conf = conf | |
| 26 | 27 | self.translationfilename = conf["file"] | |
| 27 | 28 | self.enfilename = conf["template"] | |
| 28 | 29 | if not os.path.isfile(self.translationfilename): | |
… | |||
| 30 | 31 | Path(self.translationfilename).parent.mkdir(parents=True, exist_ok=True) | |
| 31 | 32 | with open(self.translationfilename, 'w') as f: | |
| 32 | 33 | f.write('<resource></resource>') | |
| 33 | - | self.translation = androidstringslib.android(conf["template"], conf["file"]) | |
| 34 | 34 | self.conf = conf | |
| 35 | + | self.reload() | |
| 35 | 36 | ||
| 36 | 37 | def content(self): | |
| 37 | 38 | aresources = [] | |
… | |||
| 71 | 72 | entry.dst = callback(envalue, otranslated, ntranslated) | |
| 72 | 73 | self.translation.save() | |
| 73 | 74 | ||
| 75 | + | def getExternalFiles(self): | |
| 76 | + | return [self.enfilename, self.translationfilename] | |
| 77 | + | ||
| 78 | + | def reload(self): | |
| 79 | + | self.translation = androidstringslib.android(self.conf["template"], self.conf["file"]) | |
offlate/formats/gettext.py
| 24 | 24 | class GettextFormat: | |
| 25 | 25 | def __init__(self, conf): | |
| 26 | 26 | self.pofilename = conf["file"] | |
| 27 | - | if os.path.isfile(conf["file"]): | |
| 28 | - | self.po = polib.pofile(conf["file"]) | |
| 29 | - | else: | |
| 30 | - | self.po = polib.pofile(conf["pot"]) | |
| 31 | 27 | self.pot = polib.pofile(conf["pot"]) | |
| 32 | 28 | self.conf = conf | |
| 29 | + | self.reload() | |
| 33 | 30 | ||
| 34 | 31 | def content(self): | |
| 35 | 32 | po = [POEntry(x) for x in self.po] | |
… | |||
| 61 | 58 | break | |
| 62 | 59 | self.po.save() | |
| 63 | 60 | ||
| 61 | + | def getExternalFiles(self): | |
| 62 | + | return [self.pofilename] | |
| 63 | + | ||
| 64 | + | def reload(self): | |
| 65 | + | if os.path.isfile(self.conf["file"]): | |
| 66 | + | self.po = polib.pofile(self.conf["file"]) | |
| 67 | + | else: | |
| 68 | + | self.po = polib.pofile(self.conf["pot"]) | |
offlate/formats/ts.py
| 200 | 200 | self.tsfilename = conf["file"] | |
| 201 | 201 | if not os.path.isfile(conf["file"]): | |
| 202 | 202 | self.createNewTS() | |
| 203 | - | self.tscontent = self.parse(self.tsfilename) | |
| 204 | - | self.savedcontent = [TSEntry(x) for x in self.tscontent] | |
| 203 | + | self.reload() | |
| 205 | 204 | ||
| 206 | 205 | def createNewTS(self): | |
| 207 | 206 | template = ET.parse(self.conf["template"]) | |
… | |||
| 305 | 304 | oentry.msgstrs[i], entry.msgstrs[i])) | |
| 306 | 305 | break | |
| 307 | 306 | self.save() | |
| 307 | + | ||
| 308 | + | def getExternalFiles(self): | |
| 309 | + | return [self.tsfilename] | |
| 310 | + | ||
| 311 | + | def reload(self): | |
| 312 | + | self.tscontent = self.parse(self.tsfilename) | |
| 313 | + | self.savedcontent = [TSEntry(x) for x in self.tscontent] | |
offlate/formats/yaml.py
| 61 | 61 | self.conf = conf | |
| 62 | 62 | self.source = conf['source'] | |
| 63 | 63 | self.dest = conf['dest'] | |
| 64 | - | with open(self.source, 'rb') as sf: | |
| 65 | - | with open(self.dest, 'rb') as df: | |
| 66 | - | source = yaml.safe_load(sf) | |
| 67 | - | dest = yaml.safe_load(df) | |
| 68 | - | # TODO: check that Yaml files always are rooted with the language name | |
| 69 | - | lang1 = list(source.keys())[0] | |
| 70 | - | lang2 = list(dest.keys())[0] | |
| 71 | - | self.contents = yaml_rec_load([lang2], source[lang1], dest[lang2]) | |
| 64 | + | self.reload() | |
| 72 | 65 | ||
| 73 | 66 | def content(self): | |
| 74 | 67 | return [YAMLEntry(x) for x in self.contents] | |
… | |||
| 98 | 91 | merged = yaml_rec_update(callback, source, old, new) | |
| 99 | 92 | with open(self.dest, 'wb') as f: | |
| 100 | 93 | f.write(yaml.dump(merged, allow_unicode=True, Dumper=yaml.RoundTripDumper).encode('utf8')) | |
| 94 | + | ||
| 95 | + | def getExternalFiles(self): | |
| 96 | + | return [self.source, self.dest] | |
| 97 | + | ||
| 98 | + | def reload(self): | |
| 99 | + | with open(self.source, 'rb') as sf: | |
| 100 | + | with open(self.dest, 'rb') as df: | |
| 101 | + | source = yaml.safe_load(sf) | |
| 102 | + | dest = yaml.safe_load(df) | |
| 103 | + | # TODO: check that Yaml files always are rooted with the language name | |
| 104 | + | lang1 = list(source.keys())[0] | |
| 105 | + | lang2 = list(dest.keys())[0] | |
| 106 | + | self.contents = yaml_rec_load([lang2], source[lang1], dest[lang2]) | |
offlate/systems/git.py
| 157 | 157 | def isConfigured(conf): | |
| 158 | 158 | return 'fullname' in conf and conf['fullname'] != '' and \ | |
| 159 | 159 | conf['fullname'] != None | |
| 160 | + | ||
| 161 | + | def getExternalFiles(self): | |
| 162 | + | return [x['format'].getExternalFiles() for x in self.translationfiles] | |
| 163 | + | ||
| 164 | + | def reload(self): | |
| 165 | + | for x in self.translationfiles: | |
| 166 | + | x.reload() |
offlate/systems/tp.py
| 153 | 153 | res = res and 'user' in conf and conf['user'] != '' and \ | |
| 154 | 154 | conf['user'] != None | |
| 155 | 155 | return res | |
| 156 | + | ||
| 157 | + | def getExternalFiles(self): | |
| 158 | + | return [self.po.getExternalFiles()] | |
| 159 | + | ||
| 160 | + | def reload(self): | |
| 161 | + | self.po.reload() |
offlate/systems/transifex.py
| 139 | 139 | @staticmethod | |
| 140 | 140 | def isConfigured(conf): | |
| 141 | 141 | return 'token' in conf and conf['token'] != '' and conf['token'] != None | |
| 142 | + | ||
| 143 | + | def getExternalFiles(self): | |
| 144 | + | return [x.getExternalFiles() for x in self.slugs] | |
| 145 | + | ||
| 146 | + | def reload(self): | |
| 147 | + | for x in self.slugs: | |
| 148 | + | x.reload() |
offlate/ui/editor.py
| 23 | 23 | from .parallel import RunnableCallback, RunnableSignals | |
| 24 | 24 | ||
| 25 | 25 | import math | |
| 26 | + | import platform | |
| 27 | + | import sys | |
| 28 | + | import subprocess | |
| 29 | + | from watchdog.observers import Observer | |
| 30 | + | from watchdog.events import FileSystemEventHandler | |
| 31 | + | from pathlib import Path | |
| 32 | + | from time import sleep | |
| 26 | 33 | ||
| 27 | 34 | class ProjectTab(QTabWidget): | |
| 28 | 35 | def __init__(self, parent = None): | |
… | |||
| 68 | 75 | self.monospace = monospace | |
| 69 | 76 | self.fuzzyColor = QBrush(QColor(255, 127, 80)) | |
| 70 | 77 | self.emptyColor = QBrush(QColor(255, 240, 235)) | |
| 78 | + | self.threadpool = QThreadPool() | |
| 71 | 79 | self.initUI() | |
| 72 | 80 | ||
| 73 | 81 | def updateContent(self): | |
… | |||
| 112 | 120 | for project in list(self.content.keys()): | |
| 113 | 121 | self.filechooser.addItem(project) | |
| 114 | 122 | self.filechooser.currentIndexChanged.connect(self.changefile) | |
| 123 | + | self.openExternal = QPushButton(self.tr("Open in external editor")) | |
| 124 | + | self.openExternal.clicked.connect(self.editExternal) | |
| 115 | 125 | ||
| 116 | 126 | self.buttons = QVBoxLayout() | |
| 117 | 127 | self.copyButton = QPushButton(self.tr("Copy")) | |
… | |||
| 121 | 131 | if self.filechooser.count() > 1: | |
| 122 | 132 | vbox.addWidget(self.filechooser) | |
| 123 | 133 | ||
| 134 | + | vbox.addWidget(self.openExternal) | |
| 135 | + | ||
| 124 | 136 | self.updateContent() | |
| 125 | 137 | vbox.addWidget(self.treeWidget, 4) | |
| 126 | 138 | self.hbox = QHBoxLayout() | |
… | |||
| 132 | 144 | self.treeWidget.setColumnWidth(0, size.width()/2) | |
| 133 | 145 | self.treeWidget.currentItemChanged.connect(self.selectItem) | |
| 134 | 146 | ||
| 147 | + | def editExternal(self): | |
| 148 | + | self.project.save() | |
| 149 | + | self.treeWidget.setEnabled(False) | |
| 150 | + | mfiles = self.project.getExternalFiles() | |
| 151 | + | files = [] | |
| 152 | + | for f in mfiles: | |
| 153 | + | files.extend(f) | |
| 154 | + | ||
| 155 | + | worker = [] | |
| 156 | + | for f in files: | |
| 157 | + | worker.append(ExternalRunnable(f)) | |
| 158 | + | ||
| 159 | + | worker2 = WatchRunnable(self.project, len(worker)) | |
| 160 | + | worker2.signals.finished.connect(self.editedExternals) | |
| 161 | + | worker2.signals.progress.connect(self.intermediateReloadContent) | |
| 162 | + | for i in range(0, len(worker)): | |
| 163 | + | worker[i].signals.finished.connect(worker2.stop) | |
| 164 | + | self.threadpool.start(worker2) | |
| 165 | + | for i in range(0, len(worker)): | |
| 166 | + | self.threadpool.start(worker[i]) | |
| 167 | + | ||
| 168 | + | def editedExternals(self, name): | |
| 169 | + | self.reloadContent() | |
| 170 | + | self.treeWidget.setEnabled(True) | |
| 171 | + | ||
| 172 | + | def intermediateReloadContent(self, name, progress): | |
| 173 | + | self.reloadContent() | |
| 174 | + | ||
| 175 | + | def reloadContent(self): | |
| 176 | + | self.project.reload() | |
| 177 | + | self.content = self.project.content() | |
| 178 | + | self.updateContent() | |
| 179 | + | ||
| 135 | 180 | def changefile(self): | |
| 136 | 181 | self.currentContent = list(self.content.keys())[self.filechooser.currentIndex()] | |
| 137 | 182 | self.updateContent() | |
… | |||
| 529 | 574 | def run(self): | |
| 530 | 575 | self.widget.update(self) | |
| 531 | 576 | self.signals.finished.emit(self.name) | |
| 577 | + | ||
| 578 | + | class ExternalRunnable(QRunnable, RunnableCallback): | |
| 579 | + | def __init__(self, mfile): | |
| 580 | + | super().__init__() | |
| 581 | + | self.mfile = mfile | |
| 582 | + | self.signals = RunnableSignals() | |
| 583 | + | self.oldamount = -1 | |
| 584 | + | self.error = None | |
| 585 | + | self.name = "" | |
| 586 | + | ||
| 587 | + | def run(self): | |
| 588 | + | # Try to open file with the default application | |
| 589 | + | try: | |
| 590 | + | if platform.system() == 'Darwin': | |
| 591 | + | subprocess.run(['open', self.mfile]) | |
| 592 | + | elif platform.system() == 'Windows': | |
| 593 | + | subprocess.run(['start', self.mfile]) | |
| 594 | + | else: | |
| 595 | + | subprocess.run(['xdg-open', self.mfile]) | |
| 596 | + | except: | |
| 597 | + | print(sys.exc_info()) | |
| 598 | + | pass | |
| 599 | + | self.signals.finished.emit("") | |
| 600 | + | ||
| 601 | + | class MyHandler(FileSystemEventHandler): | |
| 602 | + | def __init__(self, parent): | |
| 603 | + | super().__init__() | |
| 604 | + | self.parent = parent | |
| 605 | + | ||
| 606 | + | def on_modified(self, event): | |
| 607 | + | self.parent.signals.progress.emit("", 0) | |
| 608 | + | ||
| 609 | + | class WatchRunnable(QRunnable, RunnableCallback): | |
| 610 | + | def __init__(self, project, size): | |
| 611 | + | super().__init__() | |
| 612 | + | self.project = project | |
| 613 | + | self.signals = RunnableSignals() | |
| 614 | + | self.oldamount = -1 | |
| 615 | + | self.error = None | |
| 616 | + | self.name = self.project.name | |
| 617 | + | self.size = size | |
| 618 | + | self.observer = Observer() | |
| 619 | + | event_handler = MyHandler(self) | |
| 620 | + | mfiles = self.project.getExternalFiles() | |
| 621 | + | files = [] | |
| 622 | + | for f in mfiles: | |
| 623 | + | files.extend(f) | |
| 624 | + | for f in files: | |
| 625 | + | self.observer.schedule(event_handler, str(Path(f).parent), recursive=True) | |
| 626 | + | ||
| 627 | + | def run(self): | |
| 628 | + | self.observer.start() | |
| 629 | + | while self.size > 0: | |
| 630 | + | sleep(0.2) | |
| 631 | + | self.observer.stop() | |
| 632 | + | self.observer.join() | |
| 633 | + | self.signals.finished.emit("") | |
| 634 | + | ||
| 635 | + | def stop(self, name): | |
| 636 | + | self.size -= 1 | |
setup.py
| 13 | 13 | packages=find_packages(exclude=['.guix-profile*']), | |
| 14 | 14 | python_requires = '>=3', | |
| 15 | 15 | install_requires=['polib', 'ruamel.yaml', 'python-dateutil', 'PyQt5', 'pygit2', | |
| 16 | - | 'python-gitlab', 'translation-finder', 'android-stringslib'], | |
| 16 | + | 'python-gitlab', 'translation-finder', 'android-stringslib', 'watchdog'], | |
| 17 | 17 | entry_points={ | |
| 18 | 18 | 'gui_scripts': [ | |
| 19 | 19 | 'offlate=offlate.ui.main:main', |