Allow opening in external editor

Julien LepillerSat Nov 09 18:54:05+0100 2019

ca8f02c

Allow opening in external editor

CHANGELOG.md

88
* A binary bundle for running the application under Linux is now available.
99
  Users do not need to compile and install dependencies by themselves.
1010
* 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.
1115
1216
### UI Changes ###
1317

guix.scm

111111
    (arguments
112112
     `(#:phases
113113
       (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)))))
118118
    (propagated-inputs
119119
      `(("python-chardet" ,python-chardet)
120120
        ("python-pathlib2" ,python-pathlib2)
121-
	("python-ruamel.yaml" ,python-ruamel.yaml)
121+
        ("python-ruamel.yaml" ,python-ruamel.yaml)
122122
        ("python-six" ,python-six)))
123123
    (native-inputs
124124
     `(("python-codecov" ,python-codecov)

187187
      (origin
188188
        (method git-fetch)
189189
        (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))
193193
        (sha256
194194
          (base32
195195
            "0icalva7a5w8a6qcrgkxkywckdqv8r5j5y9d5m6ln8bbk9s9fbls"))))

204204
      "Android Strings Lib provides support for android's strings.xml files.  These files are used to translate strings in android apps.")
205205
    (license license:expat)))
206206
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+
207302
(define-public python-altgraph
208303
  (package
209304
    (name "python-altgraph")

276371
      ("python-pyqt" ,python-pyqt)
277372
      ("python-requests" ,python-requests)
278373
      ("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)))
280376
  (native-inputs
281377
   `(("python-pyinstaller" ,python-pyinstaller)))
282378
  (home-page

offlate/formats/androidstrings.py

2323
2424
class AndroidStringsFormat:
2525
    def __init__(self, conf):
26+
        self.conf = conf
2627
        self.translationfilename = conf["file"]
2728
        self.enfilename = conf["template"]
2829
        if not os.path.isfile(self.translationfilename):

3031
            Path(self.translationfilename).parent.mkdir(parents=True, exist_ok=True)
3132
            with open(self.translationfilename, 'w') as f:
3233
                f.write('<resource></resource>')
33-
        self.translation = androidstringslib.android(conf["template"], conf["file"])
3434
        self.conf = conf
35+
        self.reload()
3536
3637
    def content(self):
3738
        aresources = []

7172
            entry.dst = callback(envalue, otranslated, ntranslated)
7273
        self.translation.save()
7374
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

2424
class GettextFormat:
2525
    def __init__(self, conf):
2626
        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"])
3127
        self.pot = polib.pofile(conf["pot"])
3228
        self.conf = conf
29+
        self.reload()
3330
3431
    def content(self):
3532
        po = [POEntry(x) for x in self.po]

6158
                    break
6259
        self.po.save()
6360
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

200200
        self.tsfilename = conf["file"]
201201
        if not os.path.isfile(conf["file"]):
202202
            self.createNewTS()
203-
        self.tscontent = self.parse(self.tsfilename)
204-
        self.savedcontent = [TSEntry(x) for x in self.tscontent]
203+
        self.reload()
205204
206205
    def createNewTS(self):
207206
        template = ET.parse(self.conf["template"])

305304
                                    oentry.msgstrs[i], entry.msgstrs[i]))
306305
                    break
307306
        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

6161
        self.conf = conf
6262
        self.source = conf['source']
6363
        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()
7265
7366
    def content(self):
7467
        return [YAMLEntry(x) for x in self.contents]

9891
                    merged = yaml_rec_update(callback, source, old, new)
9992
                    with open(self.dest, 'wb') as f:
10093
                        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

157157
    def isConfigured(conf):
158158
        return 'fullname' in conf and conf['fullname'] != '' and \
159159
                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

153153
        res = res and 'user' in conf and conf['user'] != '' and \
154154
                conf['user'] != None
155155
        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

139139
    @staticmethod
140140
    def isConfigured(conf):
141141
        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

2323
from .parallel import RunnableCallback, RunnableSignals
2424
2525
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
2633
2734
class ProjectTab(QTabWidget):
2835
    def __init__(self, parent = None):

6875
        self.monospace = monospace
6976
        self.fuzzyColor = QBrush(QColor(255, 127, 80))
7077
        self.emptyColor = QBrush(QColor(255, 240, 235))
78+
        self.threadpool = QThreadPool()
7179
        self.initUI()
7280
7381
    def updateContent(self):

112120
        for project in list(self.content.keys()):
113121
            self.filechooser.addItem(project)
114122
        self.filechooser.currentIndexChanged.connect(self.changefile)
123+
        self.openExternal = QPushButton(self.tr("Open in external editor"))
124+
        self.openExternal.clicked.connect(self.editExternal)
115125
116126
        self.buttons = QVBoxLayout()
117127
        self.copyButton = QPushButton(self.tr("Copy"))

121131
        if self.filechooser.count() > 1:
122132
            vbox.addWidget(self.filechooser)
123133
134+
        vbox.addWidget(self.openExternal)
135+
124136
        self.updateContent()
125137
        vbox.addWidget(self.treeWidget, 4)
126138
        self.hbox = QHBoxLayout()

132144
        self.treeWidget.setColumnWidth(0, size.width()/2)
133145
        self.treeWidget.currentItemChanged.connect(self.selectItem)
134146
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+
135180
    def changefile(self):
136181
        self.currentContent = list(self.content.keys())[self.filechooser.currentIndex()]
137182
        self.updateContent()

529574
    def run(self):
530575
        self.widget.update(self)
531576
        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

1313
    packages=find_packages(exclude=['.guix-profile*']),
1414
    python_requires = '>=3',
1515
    install_requires=['polib', 'ruamel.yaml', 'python-dateutil', 'PyQt5', 'pygit2',
16-
        'python-gitlab', 'translation-finder', 'android-stringslib'],
16+
        'python-gitlab', 'translation-finder', 'android-stringslib', 'watchdog'],
1717
    entry_points={
1818
        'gui_scripts': [
1919
            'offlate=offlate.ui.main:main',