diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d5d0018 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "files.insertFinalNewline": true +} diff --git a/DependencyControl.json b/DependencyControl.json index 90690ce..6fe2d04 100644 --- a/DependencyControl.json +++ b/DependencyControl.json @@ -32,28 +32,26 @@ "fileBaseUrl": "@{fileBaseUrl}macros-v@{version}-@{channel}/macros/@{namespace}", "channels": { "alpha": { - "version": "0.1.3", - "released": "2016-01-27", + "version": "0.2.0", + "released": "2026-05-23", "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "3677B2817C3D1FFE86981C8ABCC092B3D2CCEE7B" + "sha1": "5F7E0EEFC89E71F427819EEF69630455C0CC2304" } ], "requiredModules": [ { "moduleName": "l0.DependencyControl", - "version": "0.6.1" + "version": "0.7.0" } ] } }, "changelog": { - "0.1.0": [ - "initial release" - ], + "0.1.0": ["initial release"], "0.1.1": [ "The Install/Uninstall/Update dialogs now sort scripts by name.", "DependencyControl and its requirements no longer appear in the uninstall menu." @@ -63,6 +61,9 @@ ], "0.1.3": [ "Fixed an issue where trying to uninstall an unmanaged script resulted in an error unrelated to the intended error message." + ], + "0.2.0": [ + "Now registers the DepCtrl-internal test suite as a macro." ] } } @@ -76,44 +77,70 @@ "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", "channels": { "alpha": { - "version": "0.6.3", - "released": "2016-02-06", + "version": "0.7.0", + "released": "2026-05-23", "default": true, "files": [ { "name": ".moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "76C22149258CB1189265A367C1B28046F54F8FB3" + "sha1": "36104C47B776412EBF36AAA00D583180BF4507D5" + }, + { + "name": "/Common.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "7262886AEB9F106E95697E86FF0D44738415DBA6" }, { "name": "/ConfigHandler.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "97BCD3207FE8158261FA7851057464535FCEFBC6" + "sha1": "1FEC3583C37E4A997E806D5B17A338390657BA53" }, { "name": "/FileOps.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "D999D34DB93BA76EF0E991CEB1CD63F5CC5F8E68" + "sha1": "5A54D4B942F34C005ABC977B7655C2B849EC8889" }, { "name": "/Logger.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1E479FE95F0DFBEE8B098302AB589F32D0C40A00" + "sha1": "C4980A42A5AE9C8E24BE04DD12006D118606DBA1" + }, + { + "name": "/ModuleLoader.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "F35D88A9902FF9BC912D34299733D37FC15A36DF" + }, + { + "name": "/Record.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "796A430D14CACA3E2E15DBDD23F01DC4DC9E4B19" + }, + { + "name": "/SemanticVersioning.moon", + "url": "@{fileBaseUrl}@{fileName}", + "sha1": "C8DE63A2BE75B1135CEED3ED4ADF7025C927706C" }, { "name": "/UnitTestSuite.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "ADAB6EFB05E08A7828DCA01BC1FC43D6482979A1" + "sha1": "BF316812E9ACF6C73570337C2FCA89FD33189A2B" }, { "name": "/UpdateFeed.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "1EE16D9D551FF82C2D7E448F2CD980E528874108" + "sha1": "7B64A01259AAA32E963708AE26BCF090AFC1E0DD" }, { "name": "/Updater.moon", "url": "@{fileBaseUrl}@{fileName}", - "sha1": "A4AE061724E68B2EFBB7495A477263E1746E228A" + "sha1": "6647D7CAB70637E2B961EF334153718B06EA1027" + }, + { + "name": ".moon", + "type": "test", + "url": "@{fileBaseUrl}/Tests.moon", + "sha1": "1ED8961CAFCADA7E4C04778227EECDC18E509B8D" } ], "requiredModules": [ @@ -141,6 +168,21 @@ } }, "changelog": { + "0.7.0": [ + "The previously monolithic `DependencyControl.moon` has been broken up into focused sub-modules as groundwork for a future SQLite-based script registry backend: `Record` (version record management), `ModuleLoader` (module loading and dependency resolution), `SemanticVersioning` (version number handling), and `Common` (shared enums and utilities).", + "Script types (automation macros vs. modules) and record types are now represented by proper enums (`ScriptType`, `RecordType`) instead of bare booleans, making the API more explicit and extensible.", + "UpdateFeed: Fixed two regressions caused by the refactoring, both of which caused the update process to fail.", + "Global initialization has been moved into a dedicated setup method, reducing implicit global state for loggers and configuration.", + "DepCtrl now refuses to load if the installed Moonscript is below the minimum required version with a helpful error message directing users to update their Aegisub build.", + "ModuleLoader: Fixed a regression where DepCtrl init hooks were called again on already-initialized modules, causing errors in modules that mutate their exported state on first call (e.g. BadMutex).", + "Common: Fixed a long-standing bug that guaranteed the `capitalize()` function to fail, that was never caught because it was unused until the refactoring.", + "Updater: Fixed a potential issue where a multi-assignment statement could corrupt record fields after an unsuccessful update." + ], + "0.6.4": [ + "Logger: Fixed a crash when `logEx()` is called without format arguments — `msg:format(...)` is now skipped when no varargs are supplied.", + "Logger: `fileBaseName` now falls back to `\"UNKNOWN\"` when `script_namespace` is nil, preventing errors during Logger initialization in contexts where no namespace is available.", + "Logger/UpdateFeed: Fixed chained method calls on file handles (`handle:write():flush()` and `handle:write():close()`) that could silently swallow errors" + ], "0.6.3": [ "Fixed a v0.6.2 regression that caused DependencyControl to fail loading the first time after a scheduled self-update." ], diff --git a/macros/l0.DependencyControl.Toolbox.moon b/macros/l0.DependencyControl.Toolbox.moon index d66c5f6..7e674ef 100644 --- a/macros/l0.DependencyControl.Toolbox.moon +++ b/macros/l0.DependencyControl.Toolbox.moon @@ -1,6 +1,6 @@ export script_name = "DependencyControl Toolbox" export script_description = "Provides DependencyControl maintenance and configuration tools." -export script_version = "0.1.3" +export script_version = "0.2.0" export script_author = "line0" export script_namespace = "l0.DependencyControl.Toolbox" @@ -11,11 +11,12 @@ logger.usePrefixWindow = false msgs = { install: { - scanning: "Scanning %d available feeds..." + scanning: "Scanning %d available feeds...", + createScriptUpdateRecordFailed: "Failed to create an update record for %s '%s' from feed %s: %s" } uninstall: { running: "Uninstalling %s '%s'..." - success: "%s '%s' was removed sucessfully. Reload your automation scripts or restart Aegisub for the changes to take effect." + success: "%s '%s' was removed successfully. Reload your automation scripts or restart Aegisub for the changes to take effect." lockedFiles: "%s Some script files are still in use and will be deleted during the next restart/reload:\n%s" error: "Error: %s" } @@ -37,7 +38,7 @@ buildInstalledDlgList = (scriptType, config, isUninstall) -> for namespace, script in pairs config.c[scriptType] continue if protectedModules[namespace] - item = "%s v%s%s"\format script.name, depRec\getVersionString(script.version), + item = "%s v%s%s"\format script.name, DepCtrl.SemanticVersioning\toString(script.version), script.activeChannel and " [#{script.activeChannel}]" or "" list[#list+1] = item table.sort list, (a, b) -> a\lower! < b\lower! @@ -77,12 +78,14 @@ getScriptListDlg = (macros, modules) -> {name: "module", class: "dropdown", x: 1, y: 1, width: 1, height: 1, items: modules, value: "" } } -runUpdaterTask = (scriptData, exhaustive) -> +runUpdaterTask = (scriptData, exhaustive, isInstall) -> return unless scriptData - task, err = DepCtrl.updater\addTask scriptData, nil, nil, exhaustive, scriptData.channel - if task then task\run! - else logger\log err - + + task, code, extErr = DepCtrl.updater\addTask scriptData, nil, nil, exhaustive, scriptData.channel + return task\run! if task + with scriptData + logger\log DepCtrl.updater\getUpdaterErrorMsg code, .moduleName or .name, + .moduleName and DepCtrl.ScriptType.Module or DepCtrl.ScriptType.Automation, isInstall, extErr -- Macros @@ -90,16 +93,22 @@ install = -> config = getConfig! addAvailableToInstall = (tbl, feed, scriptType) -> - for namespace, data in pairs feed.data[scriptType] - scriptData = feed\getScript namespace, scriptType == "modules", nil, false + scriptTypeConfigAndFeedKeyName = DepCtrl.ScriptType.name.legacy[scriptType] + + for namespace, data in pairs feed.data[scriptTypeConfigAndFeedKeyName] + scriptData, err = feed\getScript namespace, scriptType, nil, false + if err + logger\warn msgs.install.createScriptUpdateRecordFailed\format DepCtrl.terms.scriptType.singular[scriptType], namespace, feed.url, err + continue + channels, defaultChannel = scriptData\getChannels! tbl[namespace] or= {} for channel in *channels record = scriptData.data.channels[channel] - verNum = depRec\getVersionNumber record.version - unless config.c[scriptType][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) + verNum = DepCtrl.SemanticVersioning\toNumber record.version + unless config.c[scriptTypeConfigAndFeedKeyName][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) tbl[namespace][channel] = { name: scriptData.name, version: record.version, verNum: verNum, feed: feed.url, - default: defaultChannel == channel, moduleName: scriptType == "modules" and namespace } + default: defaultChannel == channel, moduleName: scriptType == DepCtrl.ScriptType.Module and namespace } return tbl buildDlgList = (tbl) -> @@ -120,8 +129,8 @@ install = -> logger\log msgs.install.scanning, #feeds for feed in *feeds - macros = addAvailableToInstall macros, feed, "macros" - modules = addAvailableToInstall modules, feed, "modules" + macros = addAvailableToInstall macros, feed, DepCtrl.ScriptType.Automation + modules = addAvailableToInstall modules, feed, DepCtrl.ScriptType.Module -- build macro and module lists as well as reverse mappings moduleList, moduleMap = buildDlgList modules @@ -132,8 +141,8 @@ install = -> -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, false - runUpdaterTask macro, false + runUpdaterTask mdl, false, true + runUpdaterTask macro, false, true uninstall = -> doUninstall = (script) -> @@ -183,8 +192,8 @@ update = -> -- create and run the update tasks macro, mdl = macroMap[res.macro], moduleMap[res.module] - runUpdaterTask mdl, res.exhaustive - runUpdaterTask macro, res.exhaustive + runUpdaterTask mdl, res.exhaustive, false + runUpdaterTask macro, res.exhaustive, false macroConfig = -> config = getConfig "macros" @@ -213,9 +222,12 @@ macroConfig = -> config\write! +-- required to register DepCtrl test suite +DepCtrl.__class.version\register DepCtrl + depRec\registerMacros{ {"Install Script", "Installs an automation script or module on your system.", install}, {"Update Script", "Manually check and perform updates to any installed script.", update}, {"Uninstall Script", "Removes an automation script or module from your system.", uninstall}, {"Macro Configuration", "Lets you change per-automation script settings.", macroConfig}, -}, "DependencyControl" \ No newline at end of file +}, "DependencyControl" diff --git a/modules/DependencyControl.moon b/modules/DependencyControl.moon index b7a95c4..09f6696 100644 --- a/modules/DependencyControl.moon +++ b/modules/DependencyControl.moon @@ -24,11 +24,12 @@ class DependencyControl extends Record @Updater = Updater @UnitTestSuite = UnitTestSuite @FileOps = FileOps + @SemanticVersioning = SemanticVersioning rec = DependencyControl{ name: "DependencyControl", - version: "0.6.3", + version: "0.7.0", description: "Provides script management and auto-updating for Aegisub macros and modules.", author: "line0", url: "http://github.com/TypesettingTools/DependencyControl", @@ -46,4 +47,4 @@ LOADED_MODULES[rec.moduleName], package.loaded[rec.moduleName] = DependencyContr DependencyControl.updater\scheduleUpdate rec rec\requireModules! -return DependencyControl \ No newline at end of file +return DependencyControl diff --git a/modules/DependencyControl/Common.moon b/modules/DependencyControl/Common.moon index 8de8024..00d77d2 100644 --- a/modules/DependencyControl/Common.moon +++ b/modules/DependencyControl/Common.moon @@ -1,42 +1,42 @@ -ffi = require "ffi" - -class DependencyControlCommon - -- Some terms are shared across components - @platform = "#{ffi.os}-#{ffi.arch}" - - @terms = { - scriptType: { - singular: { "automation script", "module" } - plural: { "automation scripts", "modules" } - } - - isInstall: { - [true]: "installation" - [false]: "update" - } - - capitalize: (str) -> str[1]\upper! .. str\sub 2 - } - - -- Common enums - @RecordType = { - Managed: 1 - Unmanaged: 2 - } - - @ScriptType = { - Automation: 1 - Module: 2 - name: { - legacy: { "macros", "modules" } - canonical: {"automation", "modules"} - } - } - - automationDir: { - aegisub.decode_path("?user/automation/autoload"), - aegisub.decode_path("?user/automation/include") - } - - @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), - aegisub.decode_path("?user/automation/tests/DepUnit/modules")} \ No newline at end of file +ffi = require "ffi" + +class DependencyControlCommon + -- Some terms are shared across components + @platform = "#{ffi.os}-#{ffi.arch}" + + @terms = { + scriptType: { + singular: { "automation script", "module" } + plural: { "automation scripts", "modules" } + } + + isInstall: { + [true]: "installation" + [false]: "update" + } + + capitalize: (str) -> (str\sub 1, 1)\upper! .. str\sub 2 + } + + -- Common enums + @RecordType = { + Managed: 1 + Unmanaged: 2 + } + + @ScriptType = { + Automation: 1 + Module: 2 + name: { + legacy: { "macros", "modules" } + canonical: {"automation", "modules"} + } + } + + automationDir: { + aegisub.decode_path("?user/automation/autoload"), + aegisub.decode_path("?user/automation/include") + } + + @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), + aegisub.decode_path("?user/automation/tests/DepUnit/modules")} diff --git a/modules/DependencyControl/ConfigHandler.moon b/modules/DependencyControl/ConfigHandler.moon index 11cfacf..6bb9909 100644 --- a/modules/DependencyControl/ConfigHandler.moon +++ b/modules/DependencyControl/ConfigHandler.moon @@ -327,4 +327,4 @@ Reload your automation scripts to generate a new configuration file.]] @userConfig[k] = isTable and @deepCopy(v) or v changesMade = true - return changesMade \ No newline at end of file + return changesMade diff --git a/modules/DependencyControl/FileOps.moon b/modules/DependencyControl/FileOps.moon index 1369a60..75defed 100644 --- a/modules/DependencyControl/FileOps.moon +++ b/modules/DependencyControl/FileOps.moon @@ -304,4 +304,4 @@ class FileOps path = table.concat({dev, dir, file and pathMatch.sep, file}) - return path, dev, dir, file \ No newline at end of file + return path, dev, dir, file diff --git a/modules/DependencyControl/ModuleLoader.moon b/modules/DependencyControl/ModuleLoader.moon index e1f6738..689eee6 100644 --- a/modules/DependencyControl/ModuleLoader.moon +++ b/modules/DependencyControl/ModuleLoader.moon @@ -1,172 +1,175 @@ --- Note: this is a private API intended to be exclusively for internal DependenyControl use --- Everyting in this class can and will change without any prior notice --- and calling any method is guaranteed to interfere with DepdencyControl operation - -class ModuleLoader - msgs = { - checkOptionalModules: { - downloadHint: "Please download the modules in question manually, put them in your %s folder and reload your automation scripts." - missing: "Error: a %s feature you're trying to use requires additional modules that were not found on your system:\n%s\n%s" - } - formatVersionErrorTemplate: { - missing: "— %s %s%s\n—— Reason: %s" - outdated: "— %s (Installed: v%s; Required: v%s)%s\n—— Reason: %s" - } - loadModules: { - missing: "Error: one or more of the modules required by %s could not be found on your system:\n%s\n%s" - missingRecord: "Error: module '%s' is missing a version record." - moduleError: "Error in required module %s:\n%s" - outdated: [[Error: one or more of the modules required by %s are outdated on your system: -%s\nPlease update the modules in question manually and reload your automation scripts.]] - } - } - - @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => - url = url and ": #{url}" or "" - if ref - version = @@parseVersion ref.version - return msgs.formatVersionErrorTemplate.outdated\format name, version, reqVersion, url, reason - else - reqVersion = reqVersion and " (v#{reqVersion})" or "" - return msgs.formatVersionErrorTemplate.missing\format name, reqVersion, url, reason - - @createDummyRef = => - return nil if @scriptType != @@ScriptType.Module - -- global module registry allows for circular dependencies: - -- set a dummy reference to this module since this module is not ready - -- when the other one tries to load it (and vice versa) - export LOADED_MODULES = {} unless LOADED_MODULES - unless LOADED_MODULES[@namespace] - @ref = {} - LOADED_MODULES[@namespace] = setmetatable {__depCtrlDummy: true, version: @}, @ref - return true - return false - - @removeDummyRef = => - return nil if @scriptType != @@ScriptType.Module - if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace].__depCtrlDummy - LOADED_MODULES[@namespace] = nil - return true - return false - - @loadModule = (mdl, usePrivate, reload) => - runInitializer = (ref) -> - return unless type(ref) == "table" and ref.__depCtrlInit - -- Note to future self: don't change this to a class check! When DepCtrl self-updates - -- any managed module initialized before will still use the same instance - if type(ref.version) != "table" or ref.version.__name != @@__name - ref.__depCtrlInit @@ - - with mdl - ._missing, ._error = nil - - moduleName = usePrivate and "#{@namespace}.#{mdl.moduleName}" or .moduleName - name = "#{mdl.name or mdl.moduleName}#{usePrivate and ' (Private Copy)' or ''}" - - if .outdated or reload - -- clear old references - package.loaded[moduleName], LOADED_MODULES[moduleName] = nil - - elseif ._ref = LOADED_MODULES[moduleName] - -- module is already loaded, however it may or may not have been loaded by DepCtrl - -- so we have to call any DepCtrl initializer if it hasn't been called yet - runInitializer ._ref - return ._ref - - loaded, res = xpcall require, debug.traceback, moduleName - unless loaded - LOADED_MODULES[moduleName] = nil - res or= "unknown error" - ._missing = res\match "module '.+' not found:" - ._error = res unless ._missing - return nil - - -- set new references - if reload and ._ref and ._ref.__depCtrlDummy - setmetatable ._ref, res - ._ref, LOADED_MODULES[moduleName] = res, res - - -- run DepCtrl initializer if one was specified - runInitializer res - - return mdl._ref -- having this in the with block breaks moonscript - - @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => - for mdl in *modules - continue if skip[mdl] - with mdl - ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil - - -- try to load private copies of required modules first - ModuleLoader.loadModule @, mdl, true - ModuleLoader.loadModule @, mdl unless ._ref - - -- try to fetch and load a missing module from the web - if ._missing - record = @@{moduleName:.moduleName, name:.name or .moduleName, - version:-1, url:.url, feed:.feed, virtual:true} - ._ref, code, extErr = @@updater\require record, .version, addFeeds, .optional - if ._ref or .optional - ._updated, ._missing = true, false - else - ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, true, extErr - -- nuke dummy reference for circular dependencies - LOADED_MODULES[.moduleName] = nil - - -- check if the version requirements are satisfied - -- which is guaranteed for modules updated with \require, so we don't need to check again - if .version and ._ref and not ._updated - record = ._ref.version - unless record - ._error = msgs.loadModules.missingRecord\format .moduleName - continue - - if type(record) != "table" or record.__class != @@ - record = @@ moduleName: .moduleName, version: record, recordType: @@RecordType.Unmanaged - - -- force an update for outdated modules - if not record\checkVersion .version - ref, code, extErr = @@updater\require record, .version, addFeeds - if ref - ._ref = ref - elseif not .optional - ._outdated = true - ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, false, extErr - else - -- perform regular update check if we can get a lock without waiting - -- right now we don't care about the result and don't reload the module - -- so the update will not be effective until the user restarts Aegisub - -- or reloads the script - @@updater\scheduleUpdate record - - missing, outdated, moduleError = {}, {}, {} - for mdl in *modules - with mdl - name = .name or .moduleName - if ._missing - missing[#missing+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason - elseif ._outdated - outdated[#outdated+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason, ._ref - elseif ._error - moduleError[#moduleError+1] = msgs.loadModules.moduleError\format name, ._error - - errorMsg = {} - if #moduleError > 0 - errorMsg[1] = table.concat moduleError, "\n" - if #outdated > 0 - errorMsg[#errorMsg+1] = msgs.loadModules.outdated\format @name, table.concat outdated, "\n" - if #missing > 0 - errorMsg[#errorMsg+1] = msgs.loadModules.missing\format @name, table.concat(missing, "\n"), downloadHint - - return #errorMsg == 0, table.concat(errorMsg, "\n\n") - - @checkOptionalModules = (modules) => - modules = type(modules)=="string" and {[modules]:true} or {mdl,true for mdl in *modules} - missing = [ModuleLoader.formatVersionErrorTemplate @, mdl.moduleName, mdl.version, msl.url, - mdl._reason for mdl in *@requiredModules when mdl.optional and mdl._missing and modules[mdl.name]] - - if #missing>0 - downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules - errorMsg = msgs.checkOptionalModules.missing\format @name, table.concat(missing, "\n"), downloadHint - return false, errorMsg - return true \ No newline at end of file +-- Note: this is a private API intended to be exclusively for internal DependenyControl use +-- Everything in this class can and will change without any prior notice +-- and calling any method is guaranteed to interfere with DependencyControl operation + +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +class ModuleLoader + msgs = { + checkOptionalModules: { + downloadHint: "Please download the modules in question manually, put them in your %s folder and reload your automation scripts." + missing: "Error: a %s feature you're trying to use requires additional modules that were not found on your system:\n%s\n%s" + } + formatVersionErrorTemplate: { + missing: "— %s %s%s\n—— Reason: %s" + outdated: "— %s (Installed: v%s; Required: v%s)%s\n—— Reason: %s" + } + loadModules: { + missing: "Error: one or more of the modules required by %s could not be found on your system:\n%s\n%s" + missingRecord: "Error: module '%s' is missing a version record." + moduleError: "Error in required module %s:\n%s" + outdated: [[Error: one or more of the modules required by %s are outdated on your system: +%s\nPlease update the modules in question manually and reload your automation scripts.]] + } + } + + @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => + url = url and ": #{url}" or "" + if ref + -- unmanaged records have refs whose .version is a string instead of a DepCtrl record + version = SemanticVersioning\toString type(ref.version) == "table" and ref.version.version or ref.version + return msgs.formatVersionErrorTemplate.outdated\format name, version, reqVersion, url, reason + else + reqVersion = reqVersion and " (v#{reqVersion})" or "" + return msgs.formatVersionErrorTemplate.missing\format name, reqVersion, url, reason + + @createDummyRef = => + return nil if @scriptType != @@ScriptType.Module + -- global module registry allows for circular dependencies: + -- set a dummy reference to this module since this module is not ready + -- when the other one tries to load it (and vice versa) + export LOADED_MODULES = {} unless LOADED_MODULES + unless LOADED_MODULES[@namespace] + @ref = {} + LOADED_MODULES[@namespace] = setmetatable {__depCtrlDummy: true, version: @}, @ref + return true + return false + + @removeDummyRef = => + return nil if @scriptType != @@ScriptType.Module + if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace].__depCtrlDummy + LOADED_MODULES[@namespace] = nil + return true + return false + + @loadModule = (mdl, usePrivate, reload) => + runInitializer = (ref) -> + return unless type(ref) == "table" and ref.__depCtrlInit + -- Note to future self: don't change this to a class check! When DepCtrl self-updates + -- any managed module initialized before will still use the same instance + if type(ref.version) != "table" or not (ref.version.__class and ref.version.__class.__name == @@__name) + ref.__depCtrlInit @@ + + with mdl + ._missing, ._error = nil + + moduleName = usePrivate and "#{@namespace}.#{mdl.moduleName}" or .moduleName + name = "#{mdl.name or mdl.moduleName}#{usePrivate and ' (Private Copy)' or ''}" + + if .outdated or reload + -- clear old references + package.loaded[moduleName], LOADED_MODULES[moduleName] = nil + + elseif ._ref = LOADED_MODULES[moduleName] + -- module is already loaded, however it may or may not have been loaded by DepCtrl + -- so we have to call any DepCtrl initializer if it hasn't been called yet + runInitializer ._ref + return ._ref + + loaded, res = xpcall require, debug.traceback, moduleName + unless loaded + LOADED_MODULES[moduleName] = nil + res or= "unknown error" + ._missing = res\match "module '.+' not found:" + ._error = res unless ._missing + return nil + + -- set new references + if reload and ._ref and ._ref.__depCtrlDummy + setmetatable ._ref, res + ._ref, LOADED_MODULES[moduleName] = res, res + + -- run DepCtrl initializer if one was specified + runInitializer res + + return mdl._ref -- having this in the with block breaks moonscript + + @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => + for mdl in *modules + continue if skip[mdl.moduleName] + with mdl + ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil + + -- try to load private copies of required modules first + ModuleLoader.loadModule @, mdl, true + ModuleLoader.loadModule @, mdl unless ._ref + + -- try to fetch and load a missing module from the web + if ._missing + record = @@{moduleName:.moduleName, name:.name or .moduleName, + version:-1, url:.url, feed:.feed, virtual:true} + ._ref, code, extErr = @@updater\require record, .version, addFeeds, .optional + if ._ref or .optional + ._updated, ._missing = true, false + else + ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, true, extErr + -- nuke dummy reference for circular dependencies + LOADED_MODULES[.moduleName] = nil + + -- check if the version requirements are satisfied + -- which is guaranteed for modules updated with \require, so we don't need to check again + if .version and ._ref and not ._updated + record = ._ref.version + unless record + ._error = msgs.loadModules.missingRecord\format .moduleName + continue + + if type(record) != "table" or record.__class != @@ + record = @@ moduleName: .moduleName, version: record, recordType: @@RecordType.Unmanaged + + -- force an update for outdated modules + if not record\checkVersion .version + ref, code, extErr = @@updater\require record, .version, addFeeds + if ref + ._ref = ref + elseif not .optional + ._outdated = true + ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, false, extErr + else + -- perform regular update check if we can get a lock without waiting + -- right now we don't care about the result and don't reload the module + -- so the update will not be effective until the user restarts Aegisub + -- or reloads the script + @@updater\scheduleUpdate record + + missing, outdated, moduleError = {}, {}, {} + for mdl in *modules + with mdl + name = .name or .moduleName + if ._missing + missing[#missing+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason + elseif ._outdated + outdated[#outdated+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason, ._ref + elseif ._error + moduleError[#moduleError+1] = msgs.loadModules.moduleError\format name, ._error + + errorMsg = {} + if #moduleError > 0 + errorMsg[1] = table.concat moduleError, "\n" + if #outdated > 0 + errorMsg[#errorMsg+1] = msgs.loadModules.outdated\format @name, table.concat outdated, "\n" + if #missing > 0 + errorMsg[#errorMsg+1] = msgs.loadModules.missing\format @name, table.concat(missing, "\n"), downloadHint + + return #errorMsg == 0, table.concat(errorMsg, "\n\n") + + @checkOptionalModules = (modules) => + modules = type(modules)=="string" and {[modules]:true} or {mdl,true for mdl in *modules} + missing = [ModuleLoader.formatVersionErrorTemplate @, mdl.moduleName, mdl.version, mdl.url, + mdl._reason for mdl in *@requiredModules when mdl.optional and mdl._missing and modules[mdl.name]] + + if #missing>0 + downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules + errorMsg = msgs.checkOptionalModules.missing\format @name, table.concat(missing, "\n"), downloadHint + return false, errorMsg + return true diff --git a/modules/DependencyControl/Record.moon b/modules/DependencyControl/Record.moon index 5293321..fc8740b 100644 --- a/modules/DependencyControl/Record.moon +++ b/modules/DependencyControl/Record.moon @@ -1,315 +1,315 @@ -json = require "json" -lfs = require "lfs" -re = require "aegisub.re" - -Common = require "l0.DependencyControl.Common" -Logger = require "l0.DependencyControl.Logger" -ConfigHandler = require "l0.DependencyControl.ConfigHandler" -FileOps = require "l0.DependencyControl.FileOps" -Updater = require "l0.DependencyControl.Updater" -ModuleLoader = require "l0.DependencyControl.ModuleLoader" -SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" - -class Record extends Common - namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" - - msgs = { - new: { - badRecordError: "Error: Bad #{@@__name} record (%s)." - badRecord: { - noUnmanagedMacros: "Creating unmanaged version records for macros is not allowed" - missingNamespace: "No namespace defined" - badVersion: "Couldn't parse version number: %s" - badNamespace: "Namespace '%s' failed validation. Namespace rules: must contain 1+ single dots, but not start or end with a dot; all other characters must be in [A-Za-z0-9-_]." - badModuleTable: "Invalid required module table #%d (%s)." - } - } - uninstall: { - noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{@@__name} can be uninstalled)." - } - writeConfig: { - error: "An error occured while writing the #{@@__name} config file: %s" - writing: "Writing updated %s data to config file..." - } - } - - @depConf = { - file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", - scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE - "requiredModules", "version", "unmanaged"}, - globalDefaults: {updaterEnabled:true, updateInterval:302400, traceLevel:3, extraFeeds:{}, - tryAllFeeds:false, dumpFeeds:true, configDir:"?user/config", - logMaxFiles: 200, logMaxAge: 604800, logMaxSize:10*(10^6), - updateWaitTimeout: 60, updateOrphanTimeout: 600, - logDir: "?user/log", writeLogs: true} - } - - init = => - FileOps.mkdir @depConf.file, true - @loadConfig! - @logger = Logger { fileBaseName: "DepCtrl", fileSubName: script_namespace, prefix: "[#{@@__name}] ", - toFile: @config.c.writeLogs, defaultLevel: @config.c.traceLevel, - maxAge: @config.c.logMaxAge,maxSize: @config.c.logMaxSize, maxFiles: @config.c.logMaxFiles, - logDir: @config.c.logDir } - - @updater = Updater script_namespace, @config, @logger - @configDir = @config.c.configDir - - FileOps.mkdir aegisub.decode_path @configDir - logsHaveBeenTrimmed or= @logger\trimFiles! - FileOps.runScheduledRemoval @configDir - - - new: (args) => - init Record unless @@logger - - -- defaults - args[k] = v for k, v in pairs { - readGlobalScriptVars: true - saveRecordToConfig: true - } when args[k] == nil - - {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, - description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, - author:@author, :version, configFile:@configFile, - :readGlobalScriptVars, :saveRecordToConfig} = args - - @recordType or= @@RecordType.Managed - -- also support name key (as used in configuration) for required modules - @requiredModules or= args.requiredModules - - if @moduleName - @namespace = @moduleName - @name = name or @moduleName - @scriptType = @@ScriptType.Module - ModuleLoader.createDummyRef @ unless @virtual or @recordType == @@RecordType.Unmanaged - - else - if @virtual or not readGlobalScriptVars - @name = name or namespace - @namespace = namespace - version or= 0 - else - @name = name or script_name - @description or= script_description - @author or= script_author - version or= script_version - - @namespace = namespace or script_namespace - assert @recordType == @@RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros - assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace - @scriptType = @@ScriptType.Automation - - -- if the hosting macro doesn't have a namespace defined, define it for - -- the first DepCtrled module loaded by the macro or its required modules - unless script_namespace - export script_namespace = @namespace - - -- non-depctrl record don't need to conform to namespace rules - assert @virtual or @recordType == @@RecordType.Unmanaged or @validateNamespace!, - msgs.new.badRecord.badNamespace\format @namespace - - @configFile = configFile or "#{@namespace}.json" - @automationDir = @@automationDir[@scriptType] - @testDir = @@testDir[@scriptType] - @version, err = @@parseVersion version - assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err - - @requiredModules or= {} - -- normalize short format module tables - for i, mdl in pairs @requiredModules - switch type mdl - when "table" - mdl.moduleName or= mdl[1] - mdl[1] = nil - when "string" - @requiredModules[i] = {moduleName: mdl} - else error msgs.new.badRecordError\format msgs.new.badRecord.badModuleTable\format i, tostring mdl - - shouldWriteConfig = @loadConfig! - - -- write config file if contents are missing or are out of sync with the script version record - -- ramp up the random wait time on first initialization (many scripts may want to write configuration data) - -- we can't really profit from write concerting here because we don't know which module loads last - @writeConfig if shouldWriteConfig and saveRecordToConfig - - checkOptionalModules: ModuleLoader.checkOptionalModules - - -- loads the DependencyControl global configuration - @loadConfig = => - if @config - @config\load! - else @config = ConfigHandler @depConf.file, @depConf.globalDefaults, {"config"}, nil, @logger - - -- loads the script configuration - loadConfig: (importRecord = false) => - -- virtual modules are not yet present on the user's system and have no persistent configuration - @config or= ConfigHandler not @virtual and @@depConf.file, {}, - { @@ScriptType.name.legacy[@scriptType], @namespace }, true, @@logger - - -- import and overwrites version record from the configuration - if importRecord - -- check if a module that was previously virtual was installed in the meantime - -- TODO: prevent issues caused by orphaned config entries - haveConfig = false - if @virtual - @config\setFile @@depConf.file - if @config\load! - haveConfig, @virtual = true, false - else @config\unsetFile! - else - haveConfig = @config\load! - - -- only need to refresh data if the record was changed by an update - if haveConfig - @[key] = @config.c[key] for key in *@@depConf.scriptFields - - elseif not @virtual - -- copy script information to the config - @config\load! - shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true - return shouldWriteConfig - - return false - - writeConfig: => - unless @virtual or @config.file - @config\setFile @@depConf.file - - @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] - @config\import @, @@depConf.scriptFields, false, true - success, errMsg = @config\write false - - assert success, msgs.writeConfig.error\format errMsg - - - @parseVersion = SemanticVersioning.parse - - - @getVersionString = SemanticVersioning.toString - - - getConfigFileName: () => - return aegisub.decode_path "#{@@configDir}/#{@configFile}" - - getConfigHandler: (defaults, section, noLoad) => - return ConfigHandler @getConfigFileName!, defaults, section, noLoad - - getLogger: (args = {}) => - args.fileBaseName or= @namespace - args.toFile = @config.c.logToFile if args.toFile == nil - args.defaultLevel or= @config.c.logLevel - args.prefix or= @moduleName and "[#{@name}]" - - return Logger args - - checkVersion: (value, precision = "patch") => - if type(value) == "table" and value.__class == @@ - value = value.version - return SemanticVersioning\check @version, value - - - getSubmodules: => - return nil if @virtual or @recordType == @@RecordType.Unmanaged or @scriptType != @@ScriptType.Module - mdlConfig = @@config\getSectionHandler @@ScriptType.name.legacy[@@ScriptType.Module] - pattern = "^#{@namespace}."\gsub "%.", "%%." - return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig - - requireModules: (modules = @requiredModules, addFeeds = {@feed}) => - success, err = ModuleLoader.loadModules @, modules, addFeeds - @@updater\releaseLock! - unless success - -- if we failed loading our required modules - -- then that means we also failed to load - LOADED_MODULES[@namespace] = nil - @@logger\error err - return unpack [mdl._ref for mdl in *modules] - - registerTests: (...) => - -- load external tests - haveTests, tests = pcall require, "DepUnit.#{@@ScriptType.name.legacy[@scriptType]}.#{@namespace}" - - if haveTests and not @testsLoaded - @tests, tests.name = tests, @name - modules = table.pack @requireModules! - if @moduleName - @tests\import @ref, modules, ... - else @tests\import modules, ... - - @tests\registerMacros! - @testsLoaded = true - - register: (selfRef, ...) => - -- replace dummy refs with real refs to own module - @ref.__index, @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef, selfRef - @registerTests selfRef, ... - return selfRef - - registerMacro: (name=@name, description=@description, process, validate, isActive, submenu) => - -- alternative signature takes name and description from script - if type(name)=="function" - process, validate, isActive, submenu = name, description, process, validate - name, description = @name, @description - - -- use automation script name for submenu by default - submenu = @name if submenu == true - - menuName = { @config.c.customMenu } - menuName[#menuName+1] = submenu if submenu - menuName[#menuName+1] = name - - -- check for updates before running a macro - processHooked = (sub, sel, act) -> - @@updater\scheduleUpdate @ - @@updater\releaseLock! - return process sub, sel, act - - aegisub.register_macro table.concat(menuName, "/"), description, processHooked, validate, isActive - - registerMacros: (macros = {}, submenuDefault = true) => - for macro in *macros - -- allow macro table to omit name and description - submenuIdx = type(macro[1])=="function" and 4 or 6 - macro[submenuIdx] = submenuDefault if macro[submenuIdx] == nil - @registerMacro unpack(macro, 1, 6) - - setVersion: (version) => - version, err = @@parseVersion version - if version - @version = version - return version - else return nil, err - - validateNamespace: (namespace = @namespace, isVirtual = @virtual) => - return isVirtual or namespaceValidation\match @namespace - - uninstall: (removeConfig = true) => - if @virtual or @recordType == @@RecordType.Unmanaged - return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", - @@terms.scriptType.singular[@scriptType], - @name - @config\delete! - subModules, mdlConfig = @getSubmodules! - -- uninstalling a module also removes all submodules - if subModules and #subModules > 0 - mdlConfig.c[mdl] = nil for mdl in *subModules - mdlConfig\write! - - toRemove, pattern, dir = {} - if @moduleName - nsp, name = @namespace\match "(.+)%.(.+)" - pattern = "^#{name}" - dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" - else - pattern = "^#{@namespace}"\gsub "%.", "%%." - dir = @automationDir - - lfs.chdir dir - for file in lfs.dir dir - mode, path = FileOps.attributes file, "mode" - -- parent level module files must be .ext - currPattern = @moduleName and mode == "file" and pattern.."%." or pattern - -- automation scripts don't use any subdirectories - if (@moduleName or mode == "file") and file\match currPattern - toRemove[#toRemove+1] = path - return FileOps.remove toRemove, true, true \ No newline at end of file +json = require "json" +lfs = require "lfs" +re = require "aegisub.re" + +Common = require "l0.DependencyControl.Common" +Logger = require "l0.DependencyControl.Logger" +ConfigHandler = require "l0.DependencyControl.ConfigHandler" +FileOps = require "l0.DependencyControl.FileOps" +Updater = require "l0.DependencyControl.Updater" +ModuleLoader = require "l0.DependencyControl.ModuleLoader" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" + +class Record extends Common + namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" + + msgs = { + new: { + badRecordError: "Error: Bad #{@@__name} record (%s)." + badRecord: { + noUnmanagedMacros: "Creating unmanaged version records for macros is not allowed" + missingNamespace: "No namespace defined" + badVersion: "Couldn't parse version number: %s" + badNamespace: "Namespace '%s' failed validation. Namespace rules: must contain 1+ single dots, but not start or end with a dot; all other characters must be in [A-Za-z0-9-_]." + badModuleTable: "Invalid required module table #%d (%s)." + } + } + uninstall: { + noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{@@__name} can be uninstalled)." + } + writeConfig: { + error: "An error occured while writing the #{@@__name} config file: %s" + writing: "Writing updated %s data to config file..." + } + } + + @depConf = { + file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", + scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE + "requiredModules", "version", "unmanaged"}, + globalDefaults: {updaterEnabled:true, updateInterval:302400, traceLevel:3, extraFeeds:{}, + tryAllFeeds:false, dumpFeeds:true, configDir:"?user/config", + logMaxFiles: 200, logMaxAge: 604800, logMaxSize:10*(10^6), + updateWaitTimeout: 60, updateOrphanTimeout: 600, + logDir: "?user/log", writeLogs: true} + } + + init = => + FileOps.mkdir @depConf.file, true + @loadConfig! + @logger = Logger { fileBaseName: "DepCtrl", fileSubName: script_namespace, prefix: "[#{@@__name}] ", + toFile: @config.c.writeLogs, defaultLevel: @config.c.traceLevel, + maxAge: @config.c.logMaxAge,maxSize: @config.c.logMaxSize, maxFiles: @config.c.logMaxFiles, + logDir: @config.c.logDir } + + @updater = Updater script_namespace, @config, @logger + @configDir = @config.c.configDir + + FileOps.mkdir aegisub.decode_path @configDir + logsHaveBeenTrimmed or= @logger\trimFiles! + FileOps.runScheduledRemoval @configDir + + + new: (args) => + init Record unless @@logger + + -- defaults + args[k] = v for k, v in pairs { + readGlobalScriptVars: true + saveRecordToConfig: true + } when args[k] == nil + + {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, + description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, + author:@author, :version, configFile:@configFile, + :readGlobalScriptVars, :saveRecordToConfig} = args + + @recordType or= @@RecordType.Managed + -- also support name key (as used in configuration) for required modules + @requiredModules or= args.requiredModules + + if @moduleName + @namespace = @moduleName + @name = name or @moduleName + @scriptType = @@ScriptType.Module + ModuleLoader.createDummyRef @ unless @virtual or @recordType == @@RecordType.Unmanaged + + else + if @virtual or not readGlobalScriptVars + @name = name or namespace + @namespace = namespace + version or= 0 + else + @name = name or script_name + @description or= script_description + @author or= script_author + version or= script_version + + @namespace = namespace or script_namespace + assert @recordType == @@RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros + assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace + @scriptType = @@ScriptType.Automation + + -- if the hosting macro doesn't have a namespace defined, define it for + -- the first DepCtrled module loaded by the macro or its required modules + unless script_namespace + export script_namespace = @namespace + + -- non-depctrl record don't need to conform to namespace rules + assert @virtual or @recordType == @@RecordType.Unmanaged or @validateNamespace!, + msgs.new.badRecord.badNamespace\format @namespace + + @configFile = configFile or "#{@namespace}.json" + @automationDir = @@automationDir[@scriptType] + @testDir = @@testDir[@scriptType] + @version, err = SemanticVersioning\toNumber version + assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err + + @requiredModules or= {} + -- normalize short format module tables + for i, mdl in pairs @requiredModules + switch type mdl + when "table" + mdl.moduleName or= mdl[1] + mdl[1] = nil + when "string" + @requiredModules[i] = {moduleName: mdl} + else error msgs.new.badRecordError\format msgs.new.badRecord.badModuleTable\format i, tostring mdl + + shouldWriteConfig = @loadConfig! + + -- write config file if contents are missing or are out of sync with the script version record + -- ramp up the random wait time on first initialization (many scripts may want to write configuration data) + -- we can't really profit from write concerting here because we don't know which module loads last + @writeConfig if shouldWriteConfig and saveRecordToConfig + + checkOptionalModules: ModuleLoader.checkOptionalModules + + -- loads the DependencyControl global configuration + @loadConfig = => + if @config + @config\load! + else @config = ConfigHandler @depConf.file, @depConf.globalDefaults, {"config"}, nil, @logger + + -- loads the script configuration + loadConfig: (importRecord = false) => + -- virtual modules are not yet present on the user's system and have no persistent configuration + @config or= ConfigHandler not @virtual and @@depConf.file, {}, + { @@ScriptType.name.legacy[@scriptType], @namespace }, true, @@logger + + -- import and overwrites version record from the configuration + if importRecord + -- check if a module that was previously virtual was installed in the meantime + -- TODO: prevent issues caused by orphaned config entries + haveConfig = false + if @virtual + @config\setFile @@depConf.file + if @config\load! + haveConfig, @virtual = true, false + else @config\unsetFile! + else + haveConfig = @config\load! + + -- only need to refresh data if the record was changed by an update + if haveConfig + @[key] = @config.c[key] for key in *@@depConf.scriptFields + + elseif not @virtual + -- copy script information to the config + @config\load! + shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true + return shouldWriteConfig + + return false + + writeConfig: => + unless @virtual or @config.file + @config\setFile @@depConf.file + + @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] + @config\import @, @@depConf.scriptFields, false, true + success, errMsg = @config\write false + + assert success, msgs.writeConfig.error\format errMsg + + + -- retained for compatibility with DepCtrl <= v0.6.3 + -- TODO: deprecate w/ v0.7.0 and remove in next major release + @getVersionNumber = SemanticVersioning.toNumber + @getVersionString = SemanticVersioning.toString + + + getConfigFileName: () => + return aegisub.decode_path "#{@@configDir}/#{@configFile}" + + getConfigHandler: (defaults, section, noLoad) => + return ConfigHandler @getConfigFileName!, defaults, section, noLoad + + getLogger: (args = {}) => + args.fileBaseName or= @namespace + args.toFile = @config.c.logToFile if args.toFile == nil + args.defaultLevel or= @config.c.logLevel + args.prefix or= @moduleName and "[#{@name}]" + + return Logger args + + checkVersion: (value, precision = "patch") => + if type(value) == "table" and value.__class == @@ + value = value.version + return SemanticVersioning\check @version, value + + + getSubmodules: => + return nil if @virtual or @recordType == @@RecordType.Unmanaged or @scriptType != @@ScriptType.Module + mdlConfig = @@config\getSectionHandler @@ScriptType.name.legacy[@@ScriptType.Module] + pattern = "^#{@namespace}."\gsub "%.", "%%." + return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig + + requireModules: (modules = @requiredModules, addFeeds = {@feed}) => + success, err = ModuleLoader.loadModules @, modules, addFeeds + @@updater\releaseLock! + unless success + -- if we failed loading our required modules + -- then that means we also failed to load + LOADED_MODULES[@namespace] = nil + @@logger\error err + return unpack [mdl._ref for mdl in *modules] + + registerTests: (...) => + -- load external tests + haveTests, tests = pcall require, "DepUnit.#{@@ScriptType.name.legacy[@scriptType]}.#{@namespace}" + + if haveTests and not @testsLoaded + @tests, tests.name = tests, @name + modules = table.pack @requireModules! + if @moduleName + @tests\import @ref, modules, ... + else @tests\import modules, ... + + @tests\registerMacros! + @testsLoaded = true + + register: (selfRef, ...) => + -- replace dummy refs with real refs to own module + @ref.__index, @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef, selfRef + @registerTests selfRef, ... + return selfRef + + registerMacro: (name=@name, description=@description, process, validate, isActive, submenu) => + -- alternative signature takes name and description from script + if type(name)=="function" + process, validate, isActive, submenu = name, description, process, validate + name, description = @name, @description + + -- use automation script name for submenu by default + submenu = @name if submenu == true + + menuName = { @config.c.customMenu } + menuName[#menuName+1] = submenu if submenu + menuName[#menuName+1] = name + + -- check for updates before running a macro + processHooked = (sub, sel, act) -> + @@updater\scheduleUpdate @ + @@updater\releaseLock! + return process sub, sel, act + + aegisub.register_macro table.concat(menuName, "/"), description, processHooked, validate, isActive + + registerMacros: (macros = {}, submenuDefault = true) => + for macro in *macros + -- allow macro table to omit name and description + submenuIdx = type(macro[1])=="function" and 4 or 6 + macro[submenuIdx] = submenuDefault if macro[submenuIdx] == nil + @registerMacro unpack(macro, 1, 6) + + setVersion: (version) => + version, err = SemanticVersioning\toNumber version + if version + @version = version + return version + else return nil, err + + validateNamespace: (namespace = @namespace, isVirtual = @virtual) => + return isVirtual or namespaceValidation\match @namespace + + uninstall: (removeConfig = true) => + if @virtual or @recordType == @@RecordType.Unmanaged + return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", + @@terms.scriptType.singular[@scriptType], + @name + @config\delete! + subModules, mdlConfig = @getSubmodules! + -- uninstalling a module also removes all submodules + if subModules and #subModules > 0 + mdlConfig.c[mdl] = nil for mdl in *subModules + mdlConfig\write! + + toRemove, pattern, dir = {} + if @moduleName + nsp, name = @namespace\match "(.+)%.(.+)" + pattern = "^#{name}" + dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" + else + pattern = "^#{@namespace}"\gsub "%.", "%%." + dir = @automationDir + + lfs.chdir dir + for file in lfs.dir dir + mode, path = FileOps.attributes file, "mode" + -- parent level module files must be .ext + currPattern = @moduleName and mode == "file" and pattern.."%." or pattern + -- automation scripts don't use any subdirectories + if (@moduleName or mode == "file") and file\match currPattern + toRemove[#toRemove+1] = path + return FileOps.remove toRemove, true, true diff --git a/modules/DependencyControl/SemanticVersioning.moon b/modules/DependencyControl/SemanticVersioning.moon new file mode 100644 index 0000000..fd410bc --- /dev/null +++ b/modules/DependencyControl/SemanticVersioning.moon @@ -0,0 +1,61 @@ +class SemanticVersioning + msgs = { + toNumber: { + badString: "Can't parse version string '%s'. Make sure it conforms to semantic versioning standards." + badType: "Argument had the wrong type: expected a string or number, got a %s." + overflow: "Error: %s version must be an integer <= 255, got %s." + } + } + + semParts = {{"major", 16}, {"minor", 8}, {"patch", 0}} + + @toString = (version, precision = "patch") => + if type(version) == "string" + version, err = @toNumber version + return nil, err unless version + + parts = {0, 0, 0} + for i, part in ipairs semParts + parts[i] = bit.rshift(version, part[2]) % 256 + break if precision == part[1] + + return "%d.%d.%d"\format unpack parts + + + @toNumber = (value) => + return switch type value + when "number" then math.max value, 0 + when "nil" then 0 + when "string" + matches = {value\match "^(%d+)%.(%d+)%.(%d+)$"} + if #matches != 3 + return false, msgs.toNumber.badString\format value + + version = 0 + for i, part in ipairs semParts + value = tonumber matches[i] + if type(value) != "number" or value > 255 + return false, msgs.toNumber.overflow\format part[1], tostring value + + version += bit.lshift value, part[2] + version + + else false, msgs.toNumber.badType\format type value + + + @check: (a, b, precision = "patch") => + if type(a) != "number" + a, err = @toNumber a + return nil, err unless a + + if type(b) != "number" + b, err = @toNumber b + return nil, err unless b + + mask = 0 + for part in *semParts + mask += 0xFF * 2^part[2] + break if precision == part[1] + + b = bit.band b, mask + return a >= b, b diff --git a/modules/DependencyControl/Tests.moon b/modules/DependencyControl/Tests.moon new file mode 100644 index 0000000..af0ff3e --- /dev/null +++ b/modules/DependencyControl/Tests.moon @@ -0,0 +1,11 @@ +DependencyControl = require "l0.DependencyControl" + +DependencyControl.UnitTestSuite "l0.DependencyControl", (DepCtrl) -> + { + Common: { + _description: "Tests for the Common base class providing shared utilities and enums across DependencyControl components." + + capitalizeTerms: (ut) -> + ut\assertEquals DepCtrl.terms.capitalize("hello world"), "Hello world" + } + } diff --git a/modules/DependencyControl/UnitTestSuite.moon b/modules/DependencyControl/UnitTestSuite.moon index 64b9301..7157fdb 100644 --- a/modules/DependencyControl/UnitTestSuite.moon +++ b/modules/DependencyControl/UnitTestSuite.moon @@ -840,4 +840,4 @@ class UnitTestSuite @logger\log msgs.run.success else @logger\log msgs.run.classesFailed, failedCnt, classCnt - return @success, failedCnt > 0 and allFailed or nil \ No newline at end of file + return @success, failedCnt > 0 and allFailed or nil diff --git a/modules/DependencyControl/UpdateFeed.moon b/modules/DependencyControl/UpdateFeed.moon index 7ec8035..a196e69 100644 --- a/modules/DependencyControl/UpdateFeed.moon +++ b/modules/DependencyControl/UpdateFeed.moon @@ -4,6 +4,7 @@ DownloadManager = require "DM.DownloadManager" DependencyControl = nil Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" @@ -53,20 +54,20 @@ class ScriptUpdateRecord extends Common getChangelog: (versionRecord, minVer = 0) => return "" unless "table" == type @changelog - maxVer = DependencyControl\parseVersion @version - minVer = DependencyControl\parseVersion minVer + maxVer = SemanticVersioning\toNumber @version + minVer = SemanticVersioning\toNumber minVer changelog = {} for ver, entry in pairs @changelog - ver = DependencyControl\parseVersion ver - verStr = DependencyControl\getVersionString ver + ver = SemanticVersioning\toNumber ver + verStr = SemanticVersioning\toString ver if ver >= minVer and ver <= maxVer changelog[#changelog+1] = {ver, verStr, entry} return "" if #changelog == 0 table.sort changelog, (a,b) -> a[1]>b[1] - msg = {msgs.changelog.header\format @name, DependencyControl\getVersionString(@version), @released or ""} + msg = {msgs.changelog.header\format @name, SemanticVersioning\toString(@version), @released or ""} for chg in *changelog chg[3] = {chg[3]} if type(chg[3]) ~= "table" if #chg[3] > 0 @@ -105,6 +106,7 @@ class UpdateFeed extends Common downloadFailed: "Download of feed %s to %s failed (%s)." cantOpen: "Can't open downloaded feed for reading (%s)." parse: "Error parsing feed." + invalidScriptType: "Invalid or unsupported script type: '%s'. Supported types: %s." } } @@ -140,14 +142,13 @@ class UpdateFeed extends Common feedsHaveBeenTrimmed or= Logger(fileMatchTemplate: fileMatchTemplate, logDir: @config.downloadPath, maxFiles: 20)\trimFiles! @fileName = fileName or table.concat {@config.downloadPath, fileBaseName, "%04X"\format(math.random 0, 16^4-1), ".json"} + @downloadManager = DownloadManager aegisub.decode_path @config.downloadPath if @@cache[@url] @logger\trace msgs.trace.usingCached @data = @@cache[@url] elseif autoFetch @fetch! - @downloadManager = DownloadManager aegisub.decode_path @config.downloadPath - getKnownFeeds: => return {} unless @data return [url for _, url in pairs @data.knownFeeds] @@ -238,13 +239,22 @@ class UpdateFeed extends Common return @data getScript: (namespace, scriptType, config, autoChannel) => + -- legacy compatibility for <= 0.6.3 + if scriptType == true then scriptType = @@ScriptType.Module + elseif scriptType == false then scriptType = @@ScriptType.Automation + section = @@ScriptType.name.legacy[scriptType] + unless section + err = msgs.errors.invalidScriptType\format scriptType, + table.concat ["#{v} (#{@@ScriptType.name.canonical[v]})" for k, v in pairs @@ScriptType when k != "name"], ", " + return nil, err + scriptData = @data[section][namespace] return false unless scriptData ScriptUpdateRecord namespace, scriptData, config, scriptType, autoChannel, @logger getMacro: (namespace, config, autoChannel) => - @getScript namespace, false, config, autoChannel + @getScript namespace, @@ScriptType.Automation, config, autoChannel getModule: (namespace, config, autoChannel) => - @getScript namespace, true, config, autoChannel \ No newline at end of file + @getScript namespace, @@ScriptType.Module, config, autoChannel diff --git a/modules/DependencyControl/Updater.moon b/modules/DependencyControl/Updater.moon index 888a105..c2a8248 100644 --- a/modules/DependencyControl/Updater.moon +++ b/modules/DependencyControl/Updater.moon @@ -7,6 +7,7 @@ fileOps = require "l0.DependencyControl.FileOps" Logger = require "l0.DependencyControl.Logger" Common = require "l0.DependencyControl.Common" ModuleLoader = require "l0.DependencyControl.ModuleLoader" +SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" DependencyControl = nil class UpdaterBase extends Common @@ -21,15 +22,16 @@ class UpdaterBase extends Common [6]: "The %s of %s '%s' failed because no suitable package could be found %s." [5]: "Skipped %s of %s '%s': Another update initiated by %s is already running." [7]: "Skipped %s of %s '%s': An internet connection is currently not available." + [8]: "Couldn't %s %s '%s' because the requested version is invalid: %s" [10]: "Skipped %s of %s '%s': the update task is already running." [15]: "Couldn't %s %s '%s' because its requirements could not be satisfied:" [30]: "Couldn't %s %s '%s': failed to create temporary download directory %s" [35]: "Aborted %s of %s '%s' because the feed contained a missing or malformed SHA-1 hash for file %s." [50]: "Couldn't finish %s of %s '%s' because some files couldn't be moved to their target location:\n" [55]: "%s of %s '%s' succeeded, couldn't be located by the module loader." - [56]: "%s of %s '%s' succeeded, but an error occured while loading the module:\n%s" + [56]: "%s of %s '%s' succeeded, but an error occurred while loading the module:\n%s" [57]: "%s of %s '%s' succeeded, but it's missing a version record." - [58]: "%s of unmanaged %s '%s' succeeded, but an error occured while creating a DependencyControl record: %s" + [58]: "%s of unmanaged %s '%s' succeeded, but an error occurred while creating a DependencyControl record: %s", [100]: "Error (%d) in component %s during %s of %s '%s':\n— %s" } updaterErrorComponent: {"DownloadManager (adding download)", "DownloadManager"} @@ -85,19 +87,20 @@ class UpdateTask extends UpdaterBase unknownType: "Skipping file '%s': unknown type '%s'." } refreshRecord: { - unsetVirtual: "Update initated by another macro already fetched %s '%s', switching to update mode." - otherUpdate: "Update initated by another macro already updated %s '%s' to v%s." + unsetVirtual: "Update initiated by another macro already fetched %s '%s', switching to update mode." + otherUpdate: "Update initiated by another macro already updated %s '%s' to v%s." } } - new: (@record, targetVersion = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => + new: (@record, targetVersionNumber = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => DependencyControl or= require "l0.DependencyControl" assert @record.__class == DependencyControl, "First parameter must be a #{DependencyControl.__name} object." + assert type(targetVersionNumber) == "number", "Second parameter must be a semantic version number in integer format." @logger = @updater.logger @triedFeeds = {} @status = nil - @targetVersion = DependencyControl\parseVersion targetVersion + @targetVersion = targetVersionNumber -- set UpdateFeed settings @feedConfig = { @@ -108,10 +111,6 @@ class UpdateTask extends UpdaterBase return nil, -1 unless @updater.config.c.updaterEnabled -- TODO: check if this even works return nil, -2 unless @record\validateNamespace! - set: (targetVersion, @addFeeds, @exhaustive, @channel, @optional) => - @targetVersion = DependencyControl\parseVersion targetVersion - return @ - checkFeed: (feedUrl) => -- get feed contents feed = UpdateFeed feedUrl, false, nil, @feedConfig, @logger @@ -121,8 +120,9 @@ class UpdateTask extends UpdaterBase return nil, msgs.checkFeed.downloadFailed\format err -- select our script and update channel - updateRecord = feed\getScript @record.namespace, @record.scriptType, @record.config, false + updateRecord, err = feed\getScript @record.namespace, @record.scriptType, @record.config, false unless updateRecord + return nil, err if err return nil, msgs.checkFeed.noData\format @@terms.scriptType.singular[@record.scriptType], @record.name success, currentChannel = updateRecord\setChannel @channel @@ -148,7 +148,7 @@ class UpdateTask extends UpdaterBase run: (waitLock, exhaustive = @updater.config.c.tryAllFeeds or @@exhaustive) => - logUpdateError = (code, extErr, virtual = @virtual) -> + logUpdateError = (code, extErr, virtual = @record.virtual) -> if code < 0 @logger\log @getUpdaterErrorMsg code, @record.name, @record.scriptType, virtual, extErr return code, extErr @@ -161,7 +161,7 @@ class UpdateTask extends UpdaterBase -- check if the script was already updated if @updated and not exhaustive and @record\checkVersion @targetVersion - @logger\log msgs.run.alreadyUpdated, @record.name, DependencyControl\getVersionString @record.version + @logger\log msgs.run.alreadyUpdated, @record.name, SemanticVersioning\toString @record.version return 2 -- build feed list @@ -229,12 +229,12 @@ class UpdateTask extends UpdaterBase -- and the version must at least be that returned by at least one feed if maxVer>0 and not @record.virtual and @targetVersion <= @record.version @logger\log msgs.run.upToDate, @@terms.scriptType.singular[@record.scriptType], - @record.name, DependencyControl\getVersionString @record.version + @record.name, SemanticVersioning\toString @record.version return 0 - res = msgs.run.noFeedAvailExt\format @targetVersion == 0 and "any" or DependencyControl\getVersionString(@targetVersion), - @record.virtual and "no" or DependencyControl\getVersionString(@record.version), - maxVer<1 and "none" or DependencyControl\getVersionString maxVer + res = msgs.run.noFeedAvailExt\format @targetVersion == 0 and "any" or SemanticVersioning\toString(@targetVersion), + @record.virtual and "no" or SemanticVersioning\toString(@record.version), + maxVer<1 and "none" or SemanticVersioning\toString maxVer if @optional @logger\log msgs.run.skippedOptional, @record.name, @@terms.isInstall[@record.virtual], @@ -379,20 +379,22 @@ class UpdateTask extends UpdaterBase @ref = ref else with @record - .name, .version, .virtual = @record.name, DependencyControl\parseVersion update.version + .name = @record.name + .virtual = false + .version = SemanticVersioning\toNumber update.version @record\writeConfig! @updated = true - @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual]), + @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual or false]), @@terms.scriptType.singular[@record.scriptType], - @record.name, DependencyControl\getVersionString @record.version + @record.name, SemanticVersioning\toString @record.version - -- Diplay changelog - @logger\log update\getChangelog @record, (DependencyControl\parseVersion oldVer) + 1 + -- Display changelog + @logger\log update\getChangelog @record, (SemanticVersioning\toNumber oldVer) + 1 @logger\log msgs.performUpdate.reloadNotice -- TODO: check handling of private module copies (need extra return value?) - return finish 1, DependencyControl\getVersionString @record.version + return finish 1, SemanticVersioning\toString @record.version refreshRecord: => @@ -406,7 +408,7 @@ class UpdateTask extends UpdaterBase @logger\log msgs.refreshRecord.unsetVirtual, @@terms.scriptType.singular[.scriptType], .name else @logger\log msgs.refreshRecord.otherUpdate, @@terms.scriptType.singular[.scriptType], .name, - DependencyControl\getVersionString @record.version + SemanticVersioning\toString @record.version class Updater extends UpdaterBase msgs = { @@ -435,18 +437,22 @@ class Updater extends UpdaterBase depRec[k] = v for k, v in pairs record record = DependencyControl depRec + targetVersionNumber, err = SemanticVersioning\toNumber targetVersion + if (err) then return nil, -8, err + task = @tasks[record.scriptType][record.namespace] - if task - return task\set targetVersion, addFeeds, exhaustive, channel, optional - else - task, err = UpdateTask record, targetVersion, addFeeds, exhaustive, channel, optional, @ - @tasks[record.scriptType][record.namespace] = task - return task, err + return if task then with task + .targetVersion = targetVersionNumber + .addFeeds, .exhaustive, .channel, .optional = addFeeds, exhaustive, channel, optional + + task, code = UpdateTask record, targetVersionNumber, addFeeds, exhaustive, channel, optional, @ + @tasks[record.scriptType][record.namespace] = task + return task, code require: (record, ...) => @logger\assert record.scriptType == @@ScriptType.Module, msgs.require, record.name or record.namespace @logger\log "%s module '%s'...", record.virtual and "Installing required" or "Updating outdated", record.name - task, code = @addTask record, ... + task, code, res = @addTask record, ... code, res = task\run true if task if code == 0 and not task.updated @@ -521,4 +527,4 @@ class Updater extends UpdaterBase return false unless @hasLock @hasLock = false @config.c.updaterRunning = false - @config\write! \ No newline at end of file + @config\write!