Files
UmbraEdit/umbraedit/core/project.py
B.D. Murphy cd3bf1b97a
Some checks failed
Syntax / Linting and Type Checking (push) Has been cancelled
Tests / Linux Python 3.11 (push) Has been cancelled
Tests / Linux Python 3.12 (push) Has been cancelled
Tests / Linux Python 3.13 (push) Has been cancelled
Tests / Linux Python 3.14 (push) Has been cancelled
Tests / Linux Python 3.15 (push) Has been cancelled
Tests / MacOS Python 3.14 (push) Has been cancelled
Tests / Windows Python 3.14 (push) Has been cancelled
Tests / PR Coverage Report (push) Has been cancelled
Tests / Action Coverage Report (push) Has been cancelled
Tests / Ubuntu 24.04 & Sys Packages (push) Has been cancelled
Tests / Fedora 44 & Sys Packages (push) Has been cancelled
feat: add Save Project As (Ctrl+Alt+S) to Project menu
- Add Save Project As menu item under Project menu with Ctrl+Alt+S shortcut
- SaveProjectAs dialog prompts for destination folder and new project name
- Copies all document files, metadata, and project XML to new location
- New location added to recent projects as a separate entry
- Original project location remains in recent projects unchanged
- Generate new UUID for the copied project
2026-11-05 11:40:38 -08:00

802 lines
29 KiB
Python

"""
UmbraEdit - Project Wrapper
=============================
This file is a part of UmbraEdit.
Copyright (C) 2018 Veronica Berglyd Olsen and UmbraEdit contributors.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import json
import logging
import shutil
from enum import Enum
from functools import partial
from pathlib import Path
from time import time
from typing import TYPE_CHECKING
from PyQt6.QtCore import QCoreApplication
from umbraedit import CONFIG, SHARED, __hexversion__, __version__
from umbraedit.common import (
checkStringNone,
formatInt,
formatTimeStamp,
getFileSize,
hexToInt,
makeFileNameSafe,
minmax,
safeIsFile,
)
from umbraedit.constants import nwFiles, nwLabels, nwStyles, trConst
from umbraedit.core.index import Index
from umbraedit.core.options import OptionState
from umbraedit.core.projectdata import ProjectData
from umbraedit.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from umbraedit.core.sessions import SessionLog
from umbraedit.core.storage import ProjectStorage, ProjectStorageCreate, ProjectStorageOpen
from umbraedit.core.tree import ProjectTree
from umbraedit.enum import nwItemClass, nwItemLayout, nwItemType, nwProjectKind
from umbraedit.error import logException
if TYPE_CHECKING:
from umbraedit.core.status import T_StatusKind, T_UpdateEntry
logger = logging.getLogger(__name__)
class NWProjectState(Enum):
"""The state of the loaded project."""
UNKNOWN = 0
LOCKED = 1
RECOVERY = 2
READY = 3
class NWProject:
"""Core: UmbraEdit Project Class.
This class is the parent class of the project, and holds instances
of project data, the project tree, and the project index.
"""
__slots__ = (
"_changed",
"_countsDirty",
"_data",
"_index",
"_langData",
"_options",
"_session",
"_state",
"_storage",
"_tree",
"_valid",
"tr",
)
def __init__(self) -> None:
# Core Elements
self._options = OptionState(self) # Project-specific GUI options
self._storage = ProjectStorage(self) # The project storage handler
self._data = ProjectData(self) # The project settings
self._tree = ProjectTree(self) # The project tree
self._index = Index(self) # The project index
self._session = SessionLog(self) # The session record
# Project Status
self._langData = {} # Localisation data
self._changed = False # The project has unsaved changes
self._valid = False # The project was successfully loaded
self._state = NWProjectState.UNKNOWN
self._countsDirty = False # The word counts need to be recalculated
# Internal Mapping
self.tr = partial(QCoreApplication.translate, "NWProject")
logger.debug("Ready: NWProject")
def __del__(self) -> None: # pragma: no cover
"""Class destructor."""
logger.debug("Delete: NWProject")
def clear(self) -> None:
"""Clear the project."""
self._tree.clear()
self._index.clear()
##
# Properties
##
@property
def options(self) -> OptionState:
return self._options
@property
def storage(self) -> ProjectStorage:
return self._storage
@property
def data(self) -> ProjectData:
return self._data
@property
def tree(self) -> ProjectTree:
return self._tree
@property
def index(self) -> Index:
return self._index
@property
def session(self) -> SessionLog:
return self._session
@property
def projOpened(self) -> float:
return self._session.start
@property
def projChanged(self) -> bool:
return self._changed
@property
def isValid(self) -> bool:
"""Return True if a project is loaded."""
return self._valid
@property
def state(self) -> NWProjectState:
"""Return the current project state."""
return self._state
@property
def lockStatus(self) -> list | None:
"""Return the project lock information."""
return self._storage.lockStatus
@property
def currentTotalCount(self) -> int:
"""Return the current total word count from the tree."""
return self._tree.model.root.count
@property
def countsDirty(self) -> bool:
"""Return whether the word counts need to be recalculated."""
return self._countsDirty
##
# Item Methods
##
def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str:
"""Add a new root folder to the project. If label is not set,
use the class label.
"""
label = trConst(nwLabels.CLASS_NAME[itemClass])
return self._tree.create(label, None, nwItemType.ROOT, itemClass=itemClass, pos=pos)
def newFolder(self, label: str, parent: str, pos: int = -1) -> str | None:
"""Add a new folder with a given label and parent item."""
return self._tree.create(label, parent, nwItemType.FOLDER, pos=pos)
def newFile(self, label: str, parent: str, pos: int = -1) -> str | None:
"""Add a new file with a given label and parent item."""
return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
def removeItem(self, tHandle: str) -> bool:
"""Remove an item from the project. This will delete both the
project entry and a document file if it exists.
"""
if self._tree.checkType(tHandle, nwItemType.FILE):
SHARED.closeDocument(tHandle)
doc = self._storage.getDocument(tHandle)
if not doc.deleteDocument():
SHARED.error(self.tr("Could not delete document file."), info=doc.error)
return False
self._index.deleteHandle(tHandle)
self._tree.remove(tHandle)
return True
def writeNewFile(
self,
tHandle: str,
hLevel: int,
isDocument: bool,
text: str = "",
*,
addHeading: bool = True,
) -> bool:
"""Write content to a new document after it is created. This
will not run if the file exists and is not empty.
"""
if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
return False
if self._storage.getDocumentText(tHandle).strip():
return False
if addHeading:
indent = "#" * minmax(hLevel, 1, 4)
text = f"{indent} {tItem.itemName}\n\n{text}"
if tItem.isNovelLike() and isDocument:
tItem.setLayout(nwItemLayout.DOCUMENT)
else:
tItem.setLayout(nwItemLayout.NOTE)
self._storage.getDocument(tHandle).writeDocument(text)
self._index.scanText(tHandle, text)
return True
def copyFileContent(self, tHandle: str, sHandle: str, newTitle: str | None = None) -> bool:
"""Copy content to a new document after it is created. This
will not run if the file exists and is not empty.
"""
if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
return False
if not ((sItem := self._tree[sHandle]) and sItem.isFileType()):
return False
if self._storage.getDocumentText(tHandle).strip():
return False
logger.debug("Populating '%s' with text from '%s'", tHandle, sHandle)
text = self._storage.getDocumentText(sHandle)
if newTitle and (lines := text.split("\n")) and lines and lines[0].startswith(nwStyles.H_MARKERS):
prefix, _, _ = lines[0].partition(" ")
lines[0] = f"{prefix} {newTitle}"
text = "\n".join(lines)
self._storage.getDocument(tHandle).writeDocument(text)
sItem.setLayout(tItem.itemLayout)
self._index.reIndexHandle(tHandle)
return True
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Create a new note. This function is used by the document
editor to create note files for unknown tags.
"""
if itemClass != nwItemClass.NO_CLASS:
if not (rHandle := self._tree.findRoot(itemClass)):
rHandle = self.newRoot(itemClass)
if rHandle and (tHandle := self.newFile(tag.title(), rHandle)):
self.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
self._tree.refreshItems([tHandle])
##
# Project Methods
##
def openProject(self, projPath: str | Path, clearLock: bool = False) -> bool:
"""Open the project file provided. If it doesn't exist, assume
it is a folder and look for the file within it. If successful,
parse the XML of the file and populate the project variables and
build the tree of project items.
"""
logger.info("Opening project: %s", projPath)
status = self._storage.initProjectStorage(projPath, clearLock)
if status != ProjectStorageOpen.READY:
if status == ProjectStorageOpen.UNKOWN:
SHARED.error(
self.tr("Not a known project file format."),
info=self.tr("Path: {0}").format(str(projPath)),
)
elif status == ProjectStorageOpen.NOT_FOUND:
SHARED.error(
self.tr("Project file not found."),
info=self.tr("Path: {0}").format(str(projPath)),
)
elif status == ProjectStorageOpen.FAILED:
SHARED.error(
self.tr("Failed to open project."),
info=self.tr("Path: {0}").format(str(projPath)),
exc=self._storage.exc,
)
elif status == ProjectStorageOpen.LOCKED:
self._state = NWProjectState.LOCKED
else: # pragma: no cover
pass
return False
# Read Project XML
# ================
xmlReader = self._storage.getXmlReader()
if not isinstance(xmlReader, ProjectXMLReader):
return False
self._data = ProjectData(self)
projContent = []
xmlParsed = xmlReader.read(self._data, projContent)
appVersion = xmlReader.appVersion or self.tr("Unknown")
if not xmlParsed:
if xmlReader.state == XMLReadState.NOT_NWX_FILE:
SHARED.error(self.tr("Project file does not appear to be a UmbraEditXML file."))
elif xmlReader.state == XMLReadState.UNKNOWN_VERSION:
SHARED.error(
self.tr(
"Unknown or unsupported UmbraEdit project file format. "
"The project cannot be opened by this version of UmbraEdit. "
"The file was saved with UmbraEdit version {0}."
).format(appVersion)
)
else:
SHARED.error(self.tr("Failed to parse project xml."))
return False
# Check Legacy Upgrade
# ====================
if (xmlReader.state == XMLReadState.WAS_LEGACY or self._storage.hasBreakingChanges()) and not SHARED.question(
self.tr(
"The file format of your project is about to be updated. "
"If you proceed, older versions of UmbraEdit will no "
"longer be able to open this project. Continue?"
)
):
return False
# Check UmbraEdit Version
# =========================
if xmlReader.hexVersion > hexToInt(__hexversion__) and not SHARED.question(
self.tr(
"This project was saved by a newer version of "
"UmbraEdit, version {0}. This is version {1}. If you "
"continue to open the project, some attributes and "
"settings may not be preserved, but the overall project "
"should be fine. Continue opening the project?"
).format(appVersion, __version__),
warn=True,
):
return False
# Post XML Loading
self._storage.runPostXMLTasks()
# Extract Data
# ============
self._tree.unpack(projContent)
self._options.loadSettings()
self._loadProjectLocalisation()
# Check the project tree consistency
# This also handles any orphaned files found
orphans, recovered = self._tree.checkConsistency(self.tr("Recovered"))
if orphans > 0:
SHARED.warn(
self.tr("Found {0} orphaned file(s) in the project. {1} file(s) were recovered.").format(
orphans, recovered
)
)
self._index.loadIndex()
if xmlReader.state == XMLReadState.WAS_LEGACY:
# Often, the index needs to be rebuilt when updating format
self._index.rebuild()
self.updateCounts()
self._session.startSession()
self.setProjectChanged(False)
# Check for copies and update recent projects
if storePath := self._storage.storagePath:
if self._storage.kind == nwProjectKind.BACKUP:
stamp = self._storage.timeStamp
if SHARED.question(
self.tr(
"This project is a backup copy. Do you want to make it a separate "
"project so UmbraEdit can tell it apart from the original?"
),
info=self.tr("Backup from: {0}").format(CONFIG.localDateTime(stamp)) if stamp else "",
):
self._data.setUuid("")
# The manifest must be saved as a regular project
self.setProjectChanged(True)
elif (other := CONFIG.recentProjects.findCopy(storePath, self._data.uuid)) and SHARED.question(
self.tr(
"This project appears to be a copy of another project. "
"It was either copied outside of UmbraEdit, or is a backup copy. "
"Do you want to make it a separate project so UmbraEdit can tell them apart?"
),
info=self.tr("Path: {0}").format(other),
):
self._data.setUuid("")
self.setProjectChanged(True)
CONFIG.recentProjects.update(storePath, self._data, time())
self._valid = True
self._state = NWProjectState.READY
self._storage.lockSession() # Lock only after a successful open. See issue #1977.
SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))
return True
def saveProject(self, autoSave: bool = False) -> bool:
"""Save the project main XML file. The saving command itself
uses a temporary filename, and the file is replaced afterwards
to make sure if the save fails, we're not left with a truncated
file.
"""
if not self._storage.isOpen():
SHARED.error(self.tr("There is no project open."))
return False
saveTime = time()
logger.info("Saving project: %s", self._storage.storagePath)
if autoSave:
self._data.incAutoCount()
else:
self._data.incSaveCount()
self.updateCounts()
self.countStatus()
xmlWriter = self._storage.getXmlWriter()
if not isinstance(xmlWriter, ProjectXMLWriter):
return False
saveTime = time()
SHARED.clearErrorCache()
self._data.setIndexRevision(self._index.indexRevision)
editTime = self._data.editTime + max(round(saveTime - self._session.start), 0)
content = self._tree.pack()
if not xmlWriter.write(self._data, content, saveTime, editTime):
self._reportErrors(self.tr("Issues encountered when saving project:"))
return False
# Save other project data
if not autoSave:
self._options.saveSettings()
self._index.saveIndex()
self._storage.runPostSaveTasks(saveTime=saveTime, autoSave=autoSave)
# Update recent projects
if storagePath := self._storage.storagePath:
CONFIG.recentProjects.update(storagePath, self._data, saveTime)
self._reportErrors(self.tr("Issues encountered when saving project:"))
SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name))
self.setProjectChanged(False)
return True
def saveProjectAs(self, newPath: str | Path, newName: str) -> bool:
"""Save the current project to a new location with a new name.
Creates a new project folder at the given path, copies all document
and metadata files from the current project, and writes the project
XML with the new name. The new location is added to the recent
projects list as a separate entry from the original location.
"""
if not self._storage.isOpen():
SHARED.error(self.tr("There is no project open."))
return False
newPath = Path(newPath).resolve()
# First save all current documents to make sure everything is current
saveTime = time()
self.updateCounts()
self.countStatus()
for docHandle in self._tree.allDocs():
doc = self._storage.getDocument(docHandle)
if doc and doc.nwItem:
text = doc.readDocument()
doc.writeDocument(text)
# Save old paths before creating new storage (createNewProject
# overwrites _storagePath, _runtimePath, etc.)
oldContentPath = self._storage.contentPath
oldRuntimePath = self._storage._runtimePath
oldMetaPath = oldRuntimePath / "meta" if oldRuntimePath else None
# Create new project storage at the new location
status = self._storage.createNewProject(newPath)
if status != ProjectStorageCreate.READY:
if status == ProjectStorageCreate.NOT_EMPTY:
SHARED.error(
self.tr("The target folder is not empty. Please choose another folder.")
)
else:
SHARED.error(self.tr("Failed to create project storage."))
return False
# Update project data: new UUID and name
self._data.setUuid("")
self._data.setName(newName)
# Copy all document files from old content to new content
newContentPath = self._storage.contentPath
if oldContentPath and newContentPath:
for mdFile in oldContentPath.glob("*.md"):
destFile = newContentPath / mdFile.name
try:
shutil.copy2(mdFile, destFile)
except Exception as exc:
logger.error(
"Failed to copy document file %s: %s", mdFile.name, exc
)
SHARED.error(
self.tr("Failed to copy document file: {0}").format(mdFile.name)
)
return False
# Copy all meta files from old meta to new meta
newMetaPath = self._storage._runtimePath / "meta" if self._storage._runtimePath else None
if oldMetaPath and newMetaPath:
if oldMetaPath.is_dir():
newMetaPath.mkdir(exist_ok=True)
for metaFile in oldMetaPath.glob("*"):
if metaFile.is_file():
destFile = newMetaPath / metaFile.name
try:
shutil.copy2(metaFile, destFile)
except Exception as exc:
logger.error(
"Failed to copy meta file %s: %s", metaFile.name, exc
)
# Copy manifest.json if it exists
oldManifestPath = (
self._storage._runtimePath / nwFiles.MANIFEST_FILE
if oldRuntimePath
else None
)
newManifestPath = (
self._storage._runtimePath / nwFiles.MANIFEST_FILE
if self._storage._runtimePath
else None
)
if oldManifestPath and newManifestPath and oldManifestPath.exists():
try:
shutil.copy2(oldManifestPath, newManifestPath)
except Exception as exc:
logger.error("Failed to copy manifest file: %s", exc)
# Write project XML
self._data.setIndexRevision(self._index.indexRevision)
saveTime = time()
editTime = self._data.editTime + max(round(saveTime - self._session.start), 0)
content = self._tree.pack()
xmlWriter = self._storage.getXmlWriter()
if not isinstance(xmlWriter, ProjectXMLWriter):
return False
if not xmlWriter.write(self._data, content, saveTime, editTime):
self._reportErrors(self.tr("Issues encountered when saving project:"))
return False
# Save other project data
self._options.saveSettings()
self._index.saveIndex()
self._storage.runPostSaveTasks(saveTime=saveTime, autoSave=False)
# Update recent projects with the new location (separate from original)
CONFIG.recentProjects.update(str(newPath), self._data, saveTime)
self._reportErrors(self.tr("Issues encountered when saving project:"))
SHARED.newStatusMessage(self.tr("Saved Project As: {0}").format(newName))
self.setProjectChanged(False)
return True
def closeProject(self, idleTime: float = 0.0) -> None:
"""Close the project."""
logger.info("Closing project")
SHARED.clearErrorCache()
self._index.clear() # Triggers clear signal, see #1718
self._options.saveSettings()
self._tree.writeToCFile()
self._session.appendSession(idleTime)
self._storage.closeSession()
self._reportErrors(self.tr("Issues encountered when closing project:"))
def backupProject(self, doNotify: bool) -> bool:
"""Create a zip file of the entire project."""
if not self._storage.isOpen():
logger.error("No project open")
return False
logger.info("Backing up project")
SHARED.newStatusMessage(self.tr("Backing up project ..."))
if not self._data.name:
SHARED.error(
self.tr(
"Cannot backup project because no project name is set. "
"Please set a Project Name in Project Settings."
)
)
return False
cleanName = makeFileNameSafe(self._data.name)
backupPath = CONFIG.backupPath()
baseDir = backupPath / cleanName
try:
baseDir.mkdir(exist_ok=True, parents=True)
except Exception as exc:
SHARED.error(self.tr("Could not create backup folder."), exc=exc)
return False
saveTime = time()
match CONFIG.backupInterval:
case "day":
timeStamp = formatTimeStamp(saveTime, fileSafe=True, fmt="%Y-%m-%d")
case "week":
timeStamp = formatTimeStamp(saveTime, fileSafe=True, fmt="%G-W%V")
case "month":
timeStamp = formatTimeStamp(saveTime, fileSafe=True, fmt="%Y-%m")
case _: # Option "session" falls through here
timeStamp = formatTimeStamp(saveTime, fileSafe=True)
archName = baseDir / f"{cleanName} {timeStamp}.zip"
if self._storage.zipIt(archName, saveTime, compression=2):
if doNotify:
size = formatInt(getFileSize(archName))
SHARED.info(
self.tr("Created a backup of your project of size {0}B.").format(size),
info=self.tr("Path: {0}").format(str(backupPath)),
)
else:
SHARED.error(self.tr("Could not write backup archive."))
return False
SHARED.newStatusMessage(self.tr("Project backed up to '{0}'").format(str(archName)))
return True
##
# Setters
##
def setDefaultStatusImport(self) -> None:
"""Set the default status and importance values."""
self._data.itemStatus.add(None, self.tr("New"), "faded", "STAR", 0)
self._data.itemStatus.add(None, self.tr("Note"), "red", "TRIANGLE", 0)
self._data.itemStatus.add(None, self.tr("Draft"), "yellow", "CIRCLE_T", 0)
self._data.itemStatus.add(None, self.tr("Finished"), "green", "STAR", 0)
self._data.itemImport.add(None, self.tr("New"), "purple", "SQUARE", 0)
self._data.itemImport.add(None, self.tr("Minor"), "purple", "BLOCK_2", 0)
self._data.itemImport.add(None, self.tr("Major"), "purple", "BLOCK_3", 0)
self._data.itemImport.add(None, self.tr("Main"), "purple", "BLOCK_4", 0)
def setProjectLang(self, language: str | None, rtl: bool) -> None:
"""Set the project-specific language."""
language = checkStringNone(language, None)
reload = self._data.language != language
self._data.setLanguage(language, rtl)
if reload:
self._loadProjectLocalisation()
def setProjectChanged(self, status: bool) -> bool:
"""Toggle the project changed flag, and propagate the
information to the GUI statusbar.
"""
if isinstance(status, bool):
self._changed = status
SHARED.setGlobalProjectState(self._changed)
return self._changed
##
# Class Methods
##
def updateCounts(self) -> None:
"""Update the total word and character count values."""
wNovel, wNotes, cNovel, cNotes, wSession, wTarget = self._tree.sumCounts()
self._data.setCurrCounts(wNovel=wNovel, wNotes=wNotes, cNovel=cNovel, cNotes=cNotes)
self._data.setDailyProgress(wSession, wTarget)
self._countsDirty = False
def markCountsDirty(self) -> None:
"""Flag that an item's goal eligibility has changed, so the word
counts are stale and must be recalculated.
"""
self._countsDirty = True
def countStatus(self) -> None:
"""Count how many times the various status flags are used in the
project tree. The counts themselves are kept in the ItemStatus
objects. This is essentially a refresh.
"""
self._data.itemStatus.resetCounts()
self._data.itemImport.resetCounts()
for nwItem in self._tree:
if nwItem.isNovelLike():
self._data.itemStatus.increment(nwItem.itemStatus)
else:
self._data.itemImport.increment(nwItem.itemImport)
def updateStatus(self, kind: T_StatusKind, update: T_UpdateEntry) -> None:
"""Update status or import entries."""
if kind == "s":
self._data.itemStatus.update(update)
SHARED.emitStatusLabelsChanged(self, kind)
self._tree.refreshAllItems()
elif kind == "i":
self._data.itemImport.update(update)
SHARED.emitStatusLabelsChanged(self, kind)
self._tree.refreshAllItems()
else: # pragma: no cover
pass
def updateTheme(self) -> None:
"""Update theme elements."""
logger.debug("Theme Update: NWProject")
self._data.itemStatus.refreshIcons()
self._data.itemImport.refreshIcons()
def localLookup(self, word: str | int) -> str:
"""Look up a word or number in the translation map for the
project and return it. The variable is cast to a string before
lookup. If the word does not exist, it returns itself.
"""
return self._langData.get(str(word), str(word))
##
# Internal Functions
##
def _reportErrors(self, title: str) -> None:
"""Report any errors from the error cache."""
if errors := SHARED.errorCache():
SHARED.error(title, "<br><br>".join(errors))
def _loadProjectLocalisation(self) -> bool:
"""Load the language data for the current project language."""
if self._data.language is None or CONFIG.nwLangPath is None:
self._langData = {}
return False
langFile = Path(CONFIG.nwLangPath) / f"project_{self._data.language}.json"
if not safeIsFile(langFile):
langFile = Path(CONFIG.nwLangPath) / "project_en_GB.json"
try:
with open(langFile, mode="r", encoding="utf-8") as inFile:
self._langData = json.load(inFile)
logger.debug("Loaded project language file: %s", langFile.name)
except Exception:
logger.error("Failed to load project language file")
logException()
return False
return True