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', |