diff --git a/BuildZip.bat b/BuildZip.bat new file mode 100644 index 0000000..e5e056a --- /dev/null +++ b/BuildZip.bat @@ -0,0 +1,35 @@ +@ECHO OFF +REM A Windows 'build' script. + +IF NOT EXIST "C:\Program Files/7-Zip/7z.exe" ( + ECHO This script uses 7-Zip to build the .zip file, but 7-Zip does not seem to be installed, so the script will fail. + ECHO The script also assumes that 7-Zip is installed in its default location, C:\Program Files/7-Zip/7z.exe + ECHO So please do not change the default install location of 7-zip when you install it. + EXIT /B 1 +) + +REM Delete and recreate temporary folder so we don't end up copying old files +RMDIR /S /Q %TEMP%\FlashGrabBuild +MKDIR %TEMP%\FlashGrabBuild + +REM Copy files into folder +xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt xml\*.* %TEMP%\FlashGrabBuild\xml\ +xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt xpath\*.* %TEMP%\FlashGrabBuild\xpath\ +REM No /E for syncxml since we don't want to include docsrc or samples directories +xcopy /D /Y /exclude:ExcludedFilesWindows.txt syncxml\*.* %TEMP%\FlashGrabBuild\syncxml\ +REM We do want one file from samples, but it should be in a top-level folder in the zip file +xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt syncxml\samples\lift-dictionary.apkg %TEMP%\FlashGrabBuild\samples\ +REM The default config also needs to live in the top-level folder +move %TEMP%\FlashGrabBuild\syncxml\SyncFromXML_config_default.txt %TEMP%\FlashGrabBuild\ +xcopy /D /Y /exclude:ExcludedFilesWindows.txt __init__.py %TEMP%\FlashGrabBuild\ +xcopy /D /Y /exclude:ExcludedFilesWindows.txt manifest.json %TEMP%\FlashGrabBuild\ + +PUSHD %TEMP%\FlashGrabBuild +"C:\Program Files/7-Zip/7z.exe" a FlashGrab.zip syncxml samples xml xpath __init__.py manifest.json SyncFromXML_config_default.txt +POPD + +MOVE %TEMP%\FlashGrabBuild\FlashGrab.zip . + +ECHO FlashGrab.zip file created! +DIR FlashGrab.zip +PAUSE diff --git a/BuildZip.sh b/BuildZip.sh new file mode 100755 index 0000000..9a438cd --- /dev/null +++ b/BuildZip.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# A Linux build script + +which 7z || ( + echo 7zip not found. Please install the 7zip package, e.g. sudo apt install 7zip or sudo pacman -S 7zip + exit 1 +) + +# Delete and recreate temporary folder so we don't end up copying old files +rm -rf /tmp/FlashGrabBuild +mkdir /tmp/FlashGrabBuild + +# Copy files into folder +rsync -r -u -v --exclude-from ExcludedFilesLinux.txt xml /tmp/FlashGrabBuild +rsync -r -u -v --exclude-from ExcludedFilesLinux.txt xpath /tmp/FlashGrabBuild +rsync -r -u -v --exclude-from ExcludedFilesLinux.txt syncxml /tmp/FlashGrabBuild + +cp -u -v __init__.py /tmp/FlashGrabBuild +cp -u -v manifest.json /tmp/FlashGrabBuild + +# Some files under syncxml need to live in top-level directory +mv /tmp/FlashGrabBuild/syncxml/SyncFromXML_config_default.txt /tmp/FlashGrabBuild +mkdir -p /tmp/FlashGrabBuild/samples +mv /tmp/FlashGrabBuild/syncxml/samples/lift-dictionary.apkg /tmp/FlashGrabBuild/samples + +# Rest of samples and docsrc from syncxml dir doesn't need to be in the addon, to save file size +rm -rf /tmp/FlashGrabBuild/syncxml/docsrc +rm -rf /tmp/FlashGrabBuild/syncxml/samples + +pushd /tmp/FlashGrabBuild +7z a FlashGrab.zip syncxml samples xml xpath __init__.py manifest.json SyncFromXML_config_default.txt +popd + +mv /tmp/FlashGrabBuild/FlashGrab.zip . +ls -l FlashGrab.zip diff --git a/CopyFilesToAddons.bat b/CopyFilesToAddons.bat index dd68b5b..e70fcf6 100644 --- a/CopyFilesToAddons.bat +++ b/CopyFilesToAddons.bat @@ -2,15 +2,24 @@ ECHO OFF REM A Windows 'build' script. Run this after editing the addon's files. Then restart Anki. REM Don't make edits over there in addons directly, as VCS won't see them, and an Anki or addon upgrade REM might destroy them. + +REM IMPORTANT: If you change the plugin package in manifest.json, make the same change to PLUGIN_NAME below ECHO ON +SET PLUGIN_NAME=FlashGrab +SET ROOTFOLDER=%APPDATA%\Anki2\addons21\%PLUGIN_NAME% + REM copy all modified (D) files and folders (even E, empty ones), and overwrite (Y) without prompting REM (Like Anki's own deployment, this does NOT remove any files. The simplest way to do so manually here is to delete the addons folder, then re-run.) -xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt xml\*.* %USERPROFILE%\documents\Anki\addons\xml\ -xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt xpath\*.* %USERPROFILE%\documents\Anki\addons\xpath\ -xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt distutils\*.* %USERPROFILE%\documents\Anki\addons\distutils\ -xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt syncxml\*.* %USERPROFILE%\documents\Anki\addons\syncxml\ -xcopy /D /Y syncx.py %USERPROFILE%\documents\Anki\addons\ +xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt xml\*.* "%ROOTFOLDER%\xml\" +xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt xpath\*.* "%ROOTFOLDER%\xpath\" +REM No /E for syncxml since we don't want to include docsrc or samples directories +xcopy /D /Y /exclude:ExcludedFilesWindows.txt syncxml\*.* "%ROOTFOLDER%\syncxml\" +REM We do want one file from samples, but it should be in a top-level "samples" folder in the addons directory +xcopy /D /E /Y /exclude:ExcludedFilesWindows.txt syncxml\samples\lift-dictionary.apkg "%ROOTFOLDER%\samples\" +xcopy /D /Y __init__.py "%ROOTFOLDER%\" +xcopy /D /Y manifest.json "%ROOTFOLDER%\" +xcopy /D /Y syncxml\SyncFromXML_config_default.txt "%ROOTFOLDER%\" PAUSE diff --git a/CopyFilesToAddons.sh b/CopyFilesToAddons.sh index 8fcacc0..2272583 100644 --- a/CopyFilesToAddons.sh +++ b/CopyFilesToAddons.sh @@ -1,15 +1,31 @@ #!/usr/bin/env bash # To run this script from the terminal command line: ./CopyFilesToAddons.sh -# A Linux 'build' script. Run this after editing the addon's files. Then restart Anki. +# A Linux 'build' script. Run this after editing the addon's files. Then restart Anki. # Don't make edits over there in addons directly, as VCS won't see them, and an Anki or addon upgrade might destroy them. +# IMPORTANT: If you change the plugin package in manifest.json, make the same change to PLUGIN_NAME below +PLUGIN_NAME=FlashGrab + # Copy recursively (-r) all modified files and folders, except the excluded ones # (Like Anki's own deployment, this does NOT remove any files. The simplest way to do so manually here is to delete the addons folder, then re-run.) -mkdir -p ~/Anki/addons -rsync -r -u -v --exclude-from ExcludedFilesLinux.txt xml ~/Anki/addons/ -rsync -r -u -v --exclude-from ExcludedFilesLinux.txt xpath ~/Anki/addons/ -rsync -r -u -v --exclude-from ExcludedFilesLinux.txt distutils ~/Anki/addons/ -rsync -r -u -v --exclude-from ExcludedFilesLinux.txt syncxml ~/Anki/addons/ -cp -r -u -v syncx.py ~/Anki/addons/ +# Use XDG_DATA_HOME if present, otherwise default to ~/.local/share +DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}" +PLUGIN_HOME="${DATA_HOME}/Anki2/addons21/${PLUGIN_NAME}" + +mkdir -p "${PLUGIN_HOME}" +rsync -r -u -v --exclude-from ExcludedFilesLinux.txt xml "${PLUGIN_HOME}/" +rsync -r -u -v --exclude-from ExcludedFilesLinux.txt xpath "${PLUGIN_HOME}/" +rsync -r -u -v --exclude-from ExcludedFilesLinux.txt syncxml "${PLUGIN_HOME}/" +# No -r for syncxml since we don't want to include docsrc or samples directories +# syncxml samples dir should be at the top level of the plugin directory and only contain one file +mkdir -p "${PLUGIN_HOME}/samples" +mv "${PLUGIN_HOME}/syncxml/samples/lift-dictionary.apkg" "${PLUGIN_HOME}/samples/" +# Default config should also be in plugin root +mv "${PLUGIN_HOME}/syncxml/SyncFromXML_config_default.txt" "${PLUGIN_HOME}/" +# Rest of samples, as well as docsrc folder, not needed +rm -rf "${PLUGIN_HOME}/syncxml/samples" +rm -rf "${PLUGIN_HOME}/syncxml/docsrc" +cp -u -v __init__.py "${PLUGIN_HOME}/" +cp -u -v manifest.json "${PLUGIN_HOME}/" diff --git a/ExcludedFilesLinux.txt b/ExcludedFilesLinux.txt index 3bd6463..98dd645 100644 --- a/ExcludedFilesLinux.txt +++ b/ExcludedFilesLinux.txt @@ -1,6 +1,7 @@ - *.pyc +- __pycache__/ - local_launch.py - syncx_local.py - SyncFromXML_config.txt - SyncFromXML_log.txt -- SyncFromXML_configB* \ No newline at end of file +- SyncFromXML_configB* diff --git a/ExcludedFilesWindows.txt b/ExcludedFilesWindows.txt index 7477bc4..5d7a767 100644 --- a/ExcludedFilesWindows.txt +++ b/ExcludedFilesWindows.txt @@ -1,6 +1,7 @@ .pyc\ +\__pycache__\ \local_launch.py\ \syncx_local.py\ \SyncFromXML_config.txt\ \SyncFromXML_log.txt\ -\SyncFromXML_configB \ No newline at end of file +\SyncFromXML_configB diff --git a/__init__.py b/__init__.py index 89cc784..4f8fc3a 100644 --- a/__init__.py +++ b/__init__.py @@ -3,5 +3,9 @@ from the source XML file(s) as specified in the config file. """ +# Make package-local imports work in Python 3.6 and later +import os, sys +sys.path.append(os.path.dirname(os.path.realpath(__file__))) + from syncxml import SyncFromXML diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..963933b --- /dev/null +++ b/manifest.json @@ -0,0 +1,4 @@ +{ + "package": "FlashGrab", + "name": "FlashGrab: import LIFT XML from FieldWorks or WeSay" +} diff --git a/syncxml/SyncFromXML.py b/syncxml/SyncFromXML.py index 110941a..605006c 100644 --- a/syncxml/SyncFromXML.py +++ b/syncxml/SyncFromXML.py @@ -31,8 +31,14 @@ from anki.notes import Note # from anki.utils import isMac #obsolete now, I think # from PyQt4.QtGui import QMessageBox #done for us already? - QUESTION = QMessageBox.Question - CRITICAL = QMessageBox.Critical + try: + QUESTION = QMessageBox.Icon.Question + except AttributeError: + QUESTION = QMessageBox.Question + try: + CRITICAL = QMessageBox.Icon.Critical + except AttributeError: + CRITICAL = QMessageBox.Critical else: # crash-prevention dummies QUESTION = 0 @@ -59,7 +65,11 @@ def dialogbox(text, buttons, icon=4, log=True): def hourglass(): if A.IN_ANKI: - mw.app.setOverrideCursor(QCursor(Qt.WaitCursor)) # display an hourglass cursor + try: + mw.app.setOverrideCursor(QCursor(Qt.WaitCursor)) # display an hourglass cursor + except AttributeError: + # Qt6 has moved this to CursorShape + mw.app.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) # display an hourglass cursor def no_hourglass(): if A.IN_ANKI: mw.app.restoreOverrideCursor() diff --git a/syncxml/anki_util.py b/syncxml/anki_util.py index d84e8ec..da0d914 100644 --- a/syncxml/anki_util.py +++ b/syncxml/anki_util.py @@ -18,9 +18,9 @@ ANKI_MEDIA_FOLDER = 'collection.media' -ADDON_FOLDER = 'syncxml' +ADDON_FOLDER = 'FlashGrab' APKG_PATH = os.path.join("samples", "lift-dictionary.apkg") -NO_MODEL_INSTR = "\nSteps: Restart Anki and make sure the target deck exists. Use Tools, Manage Note Types, or go to File Import, and import Anki/addons/syncxml/{}. Or, reconfigure.)".format(APKG_PATH) +NO_MODEL_INSTR = "\nSteps: Restart Anki and make sure the target deck exists. Use Tools, Manage Note Types, or go to File Import, and import Anki/addons/{}/{}. Or, reconfigure.)".format(ADDON_FOLDER, APKG_PATH) NO_MODEL_MSG = "The target model with the fields we needed was missing; have attempted to re-import the default APKG but an error occurred. \nPlease try again, or create it manually. \n{}".format(NO_MODEL_INSTR) TARGET_DECK = "lift-dictionary" @@ -90,7 +90,11 @@ def import_apkg_model(path, delete=False): # assert deck.cardCount() > 0 # assert deck.noteCount() > 0 ids = mw.col.findCards("deck:{}".format(TARGET_DECK)) - mw.col.remCards(ids, True) + try: + mw.col.remCards(ids, True) + except TypeError: + # Newer versions of Anki renamed remCards and removed the second argument + mw.col.remove_cards_and_orphaned_notes(ids) # assert deck.cardCount() == 0 # assert deck.noteCount() == 0 except: diff --git a/syncxml/file_util.py b/syncxml/file_util.py index 7b73656..f767379 100644 --- a/syncxml/file_util.py +++ b/syncxml/file_util.py @@ -12,9 +12,7 @@ __revision__ = "$Id$" -import os -from distutils.errors import DistutilsFileError -from distutils import log +import os, sys # for generating verbose output in 'copy_file()' _copy_action = {None: 'copying', @@ -22,11 +20,21 @@ 'sym': 'symbolically linking'} +# from distutils.dep_util import newer +# distutils deprecated in Python 3.12 and later, so reimplement newer() function +def newer(source, target): + if not os.path.exists(source): + raise OSError("file %s does not exist" % (os.path.abspath(source))) + return not os.path.exists(target) or ( + os.path.getmtime(source) > os.path.getmtime(target) + ) + + def _copy_file_contents(src, dst, buffer_size=16*1024): """Copy the file 'src' to 'dst'. Both must be filenames. Any error opening either file, reading from - 'src', or writing to 'dst', raises DistutilsFileError. Data is + 'src', or writing to 'dst', raises OSError. Data is read/written in chunks of 'buffer_size' bytes (default 16k). No attempt is made to handle anything apart from regular files. """ @@ -39,30 +47,27 @@ def _copy_file_contents(src, dst, buffer_size=16*1024): fsrc = open(src, 'rb') except os.error as xxx_todo_changeme3: (errno, errstr) = xxx_todo_changeme3.args - raise DistutilsFileError("could not open '%s': %s" % (src, errstr)) + raise OSError("could not open '%s': %s" % (src, errstr)) if os.path.exists(dst): try: os.unlink(dst) except os.error as xxx_todo_changeme: (errno, errstr) = xxx_todo_changeme.args - raise DistutilsFileError( - "could not delete '%s': %s" % (dst, errstr)) + raise OSError("could not delete '%s': %s" % (dst, errstr)) try: fdst = open(dst, 'wb') except os.error as xxx_todo_changeme4: (errno, errstr) = xxx_todo_changeme4.args - raise DistutilsFileError( - "could not create '%s': %s" % (dst, errstr)) + raise OSError("could not create '%s': %s" % (dst, errstr)) while 1: try: buf = fsrc.read(buffer_size) except os.error as xxx_todo_changeme1: (errno, errstr) = xxx_todo_changeme1.args - raise DistutilsFileError( - "could not read from '%s': %s" % (src, errstr)) + raise OSError("could not read from '%s': %s" % (src, errstr)) if not buf: break @@ -71,8 +76,7 @@ def _copy_file_contents(src, dst, buffer_size=16*1024): fdst.write(buf) except os.error as xxx_todo_changeme2: (errno, errstr) = xxx_todo_changeme2.args - raise DistutilsFileError( - "could not write to '%s': %s" % (dst, errstr)) + raise OSError("could not write to '%s': %s" % (dst, errstr)) finally: if fdst: @@ -113,12 +117,10 @@ def copy_file(src, dst, preserve_mode=1, preserve_times=1, update=0, # changing it (ie. it's not already a hard/soft link to src OR # (not update) and (src newer than dst). - from distutils.dep_util import newer from stat import ST_ATIME, ST_MTIME, ST_MODE, S_IMODE if not os.path.isfile(src): - raise DistutilsFileError( - "can't copy '%s': doesn't exist or not a regular file" % src) + raise OSError("can't copy '%s': doesn't exist or not a regular file" % src) if os.path.isdir(dst): dir = dst @@ -128,7 +130,7 @@ def copy_file(src, dst, preserve_mode=1, preserve_times=1, update=0, if update and not newer(src, dst): if verbose >= 1: - log.debug("not copying %s (output up-to-date)", src) + print("not copying %s (output up-to-date)".format(src), file=sys.stderr) return dst, 0 try: @@ -138,9 +140,9 @@ def copy_file(src, dst, preserve_mode=1, preserve_times=1, update=0, if verbose >= 1: if os.path.basename(dst) == os.path.basename(src): - log.info("%s %s -> %s", action, src, dir) + print("%s %s -> %s".format(action, src, dir)) else: - log.info("%s %s -> %s", action, src, dst) + print("%s %s -> %s".format(action, src, dst)) if dry_run: return (dst, 1) @@ -185,25 +187,21 @@ def move_file (src, dst, verbose=1, dry_run=0): import errno if verbose >= 1: - log.info("moving %s -> %s", src, dst) + print("moving %s -> %s".format(src, dst)) if dry_run: return dst if not isfile(src): - raise DistutilsFileError("can't move '%s': not a regular file" % src) + raise OSError("can't move '%s': not a regular file" % src) if isdir(dst): dst = os.path.join(dst, basename(src)) elif exists(dst): - raise DistutilsFileError( - "can't move '%s': destination '%s' already exists" % - (src, dst)) + raise OSError("can't move '%s': destination '%s' already exists" % (src, dst)) if not isdir(dirname(dst)): - raise DistutilsFileError( - "can't move '%s': destination '%s' not a valid path" % \ - (src, dst)) + raise OSError("can't move '%s': destination '%s' not a valid path" % (src, dst)) copy_it = 0 try: @@ -213,8 +211,7 @@ def move_file (src, dst, verbose=1, dry_run=0): if num == errno.EXDEV: copy_it = 1 else: - raise DistutilsFileError( - "couldn't move '%s' to '%s': %s" % (src, dst, msg)) + raise OSError("couldn't move '%s' to '%s': %s" % (src, dst, msg)) if copy_it: copy_file(src, dst, verbose=verbose) @@ -226,7 +223,7 @@ def move_file (src, dst, verbose=1, dry_run=0): os.unlink(dst) except os.error: pass - raise DistutilsFileError( + raise OSError( ("couldn't move '%s' to '%s' by copy/delete: " + "delete '%s' failed: %s") % (src, dst, src, msg)) diff --git a/syncxml/flex_util.py b/syncxml/flex_util.py index de8c97b..fb320c0 100644 --- a/syncxml/flex_util.py +++ b/syncxml/flex_util.py @@ -22,9 +22,9 @@ def flex_dir(): return d def flex_media(f, dir=""): - """Given an absolute filepath such as c:\files\Catalan.lift, and the FLEx project folder, + """Given an absolute filepath such as c:\\files\\Catalan.lift, and the FLEx project folder, check whether audio and image folders are likely to be available under, say, - C:\ProgramData\SIL\FieldWorks\Projects\Catalan\LinkedFiles + C:\\ProgramData\\SIL\\FieldWorks\\Projects\\Catalan\\LinkedFiles Or, given just an absolute path, assume it's an fwdata file and deduce accordingly. """ media_dir = "" diff --git a/syncxml/xml_util.py b/syncxml/xml_util.py index 8523e5b..14b43ae 100644 --- a/syncxml/xml_util.py +++ b/syncxml/xml_util.py @@ -200,8 +200,8 @@ def __init__(self, file_path, source_file='', source_audio=None, source_image=No It only really stores the minidom, but it (and XmlSource) provide more convenient read access via get methods that return string dictionaries. If the optional parameters are provided, they will overwrite whatever was in those - attributes for *every* source element. If just source_file is provided, e.g. C:\mylift\Catalan.LIFT, - then audio and image will be deduced, e.g. C:\mylift\audio and C:\mylift\pictures . + attributes for *every* source element. If just source_file is provided, e.g. C:\\mylift\\Catalan.LIFT, + then audio and image will be deduced, e.g. C:\\mylift\\audio and C:\\mylift\\pictures . NOTE: if source_file is not provided, the other source_ parameters will be ignored. """ @@ -693,12 +693,11 @@ def replace_all(fname, to_replace, target=None): # while os.path.exists(fname2): # fname2 += ".tmp" - # using python 2.x decode()/encode() syntax below, since its open() function doesn't support this v3 parameter: encoding='utf-8' - with open(fname, 'r') as infile: + with open(fname, 'r', encoding='utf-8') as infile: data = infile.read() for pair in to_replace: data = re.sub(pair[0], pair[1], data) - with open (target, 'w') as outfile: + with open (target, 'w', encoding='utf-8') as outfile: outfile.write(data) def _workaround_px(fname):