├── DependencyControl.json ├── LICENSE ├── README.md ├── macros └── l0.DependencyControl.Toolbox.moon └── modules ├── DependencyControl.moon └── DependencyControl ├── Common.moon ├── ConfigHandler.moon ├── FileOps.moon ├── Logger.moon ├── ModuleLoader.moon ├── Record.moon ├── UnitTestSuite.moon ├── UpdateFeed.moon └── Updater.moon /DependencyControl.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencyControlFeedFormatVersion": "0.3.0", 3 | "name": "DependencyControl", 4 | "description": "The official DependencyControl repository.", 5 | "baseUrl": "https://github.com/TypesettingTools/DependencyControl", 6 | "url": "@{baseUrl}", 7 | "fileBaseUrl": "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/", 8 | "maintainer": "line0", 9 | "knownFeeds": { 10 | "line0scripts": "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/DependencyControl.json", 11 | "a-mo": "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json", 12 | "SubInspector": "https://raw.githubusercontent.com/TypesettingTools/SubInspector/master/DependencyControl.json", 13 | "ASSFoundation": "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json", 14 | "ffi-experiments": "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json", 15 | "lyger-scripts": "https://raw.githubusercontent.com/TypesettingTools/lyger-Aegisub-Scripts/master/DependencyControl.json", 16 | "unanimated-scripts": "https://raw.githubusercontent.com/TypesettingTools/unanimated-Aegisub-Scripts/master/DependencyControl.json", 17 | "coffeeflux-scripts": "https://raw.githubusercontent.com/TypesettingTools/CoffeeFlux-Aegisub-Scripts/master/DependencyControl.json", 18 | "myaamori-scripts": "https://raw.githubusercontent.com/TypesettingTools/Myaamori-Aegisub-Scripts/master/DependencyControl.json", 19 | "petzku-scripts": "https://raw.githubusercontent.com/petzku/Aegisub-Scripts/master/DependencyControl.json", 20 | "zahuczky-scripts": "https://raw.githubusercontent.com/Zahuczky/Zahuczkys-Aegisub-Scripts/main/DependencyControl.json", 21 | "phoscity-scripts": "https://raw.githubusercontent.com/PhosCity/Aegisub-Scripts/main/DependencyControl.json", 22 | "zeref-scripts": "https://raw.githubusercontent.com/TypesettingTools/zeref-Aegisub-Scripts/main/DependencyControl.json", 23 | "arch1t3cht-scripts": "https://raw.githubusercontent.com/TypesettingTools/arch1t3cht-Aegisub-Scripts/main/DependencyControl.json", 24 | "ILL": "https://raw.githubusercontent.com/TypesettingTools/ILL-Aegisub-Scripts/main/DependencyControl.json" 25 | }, 26 | "macros": { 27 | "l0.DependencyControl.Toolbox": { 28 | "url": "@{baseUrl}#@{namespace}", 29 | "author": "line0", 30 | "name": "DependencyControl Toolbox", 31 | "description": "Provides DependencyControl maintenance and configuration utilities.", 32 | "fileBaseUrl": "@{fileBaseUrl}macros-v@{version}-@{channel}/macros/@{namespace}", 33 | "channels": { 34 | "alpha": { 35 | "version": "0.1.3", 36 | "released": "2016-01-27", 37 | "default": true, 38 | "files": [ 39 | { 40 | "name": ".moon", 41 | "url": "@{fileBaseUrl}@{fileName}", 42 | "sha1": "3677B2817C3D1FFE86981C8ABCC092B3D2CCEE7B" 43 | } 44 | ], 45 | "requiredModules": [ 46 | { 47 | "moduleName": "l0.DependencyControl", 48 | "version": "0.6.1" 49 | } 50 | ] 51 | } 52 | }, 53 | "changelog": { 54 | "0.1.0": [ 55 | "initial release" 56 | ], 57 | "0.1.1": [ 58 | "The Install/Uninstall/Update dialogs now sort scripts by name.", 59 | "DependencyControl and its requirements no longer appear in the uninstall menu." 60 | ], 61 | "0.1.2": [ 62 | "All DependencyControl macros are now available under the common 'DependencyControl' automation submenu." 63 | ], 64 | "0.1.3": [ 65 | "Fixed an issue where trying to uninstall an unmanaged script resulted in an error unrelated to the intended error message." 66 | ] 67 | } 68 | } 69 | }, 70 | "modules": { 71 | "l0.DependencyControl": { 72 | "url": "@{baseUrl}#@{namespace}", 73 | "author": "line0", 74 | "name": "DependencyControl", 75 | "description": "Dependency manager and automatic script updater for Aegisub macros and modules.", 76 | "fileBaseUrl": "@{fileBaseUrl}v@{version}-@{channel}/modules/@{scriptName}", 77 | "channels": { 78 | "alpha": { 79 | "version": "0.6.3", 80 | "released": "2016-02-06", 81 | "default": true, 82 | "files": [ 83 | { 84 | "name": ".moon", 85 | "url": "@{fileBaseUrl}@{fileName}", 86 | "sha1": "76C22149258CB1189265A367C1B28046F54F8FB3" 87 | }, 88 | { 89 | "name": "/ConfigHandler.moon", 90 | "url": "@{fileBaseUrl}@{fileName}", 91 | "sha1": "97BCD3207FE8158261FA7851057464535FCEFBC6" 92 | }, 93 | { 94 | "name": "/FileOps.moon", 95 | "url": "@{fileBaseUrl}@{fileName}", 96 | "sha1": "D999D34DB93BA76EF0E991CEB1CD63F5CC5F8E68" 97 | }, 98 | { 99 | "name": "/Logger.moon", 100 | "url": "@{fileBaseUrl}@{fileName}", 101 | "sha1": "1E479FE95F0DFBEE8B098302AB589F32D0C40A00" 102 | }, 103 | { 104 | "name": "/UnitTestSuite.moon", 105 | "url": "@{fileBaseUrl}@{fileName}", 106 | "sha1": "ADAB6EFB05E08A7828DCA01BC1FC43D6482979A1" 107 | }, 108 | { 109 | "name": "/UpdateFeed.moon", 110 | "url": "@{fileBaseUrl}@{fileName}", 111 | "sha1": "1EE16D9D551FF82C2D7E448F2CD980E528874108" 112 | }, 113 | { 114 | "name": "/Updater.moon", 115 | "url": "@{fileBaseUrl}@{fileName}", 116 | "sha1": "A4AE061724E68B2EFBB7495A477263E1746E228A" 117 | } 118 | ], 119 | "requiredModules": [ 120 | { 121 | "moduleName": "requireffi.requireffi", 122 | "version": "0.1.1", 123 | "feed": "@{feed:ffi-experiments}" 124 | }, 125 | { 126 | "moduleName": "DM.DownloadManager", 127 | "version": "0.3.1", 128 | "feed": "@{feed:ffi-experiments}" 129 | }, 130 | { 131 | "moduleName": "BM.BadMutex", 132 | "version": "0.1.3", 133 | "feed": "@{feed:ffi-experiments}" 134 | }, 135 | { 136 | "moduleName": "PT.PreciseTimer", 137 | "version": "0.1.5", 138 | "feed": "@{feed:ffi-experiments}" 139 | } 140 | ] 141 | } 142 | }, 143 | "changelog": { 144 | "0.6.3": [ 145 | "Fixed a v0.6.2 regression that caused DependencyControl to fail loading the first time after a scheduled self-update." 146 | ], 147 | "0.6.2": [ 148 | "An issue was fixed that would cause DepCtrl initializer code in modules previously loaded with regular Lua loading mechanisms to be skipped when requested in a _DependencyControl_- context. This kept the [requireffi](https://github.com/torque/ffi-experiments/tree/master/requireffi) _DependencyControl_ record from being established, preventing any updates from taking place.", 149 | "UnitTestSuite: Fixed several broken assertions and related error messages, among them the `assertMatches` and `assertErrorMatches` assertions always returning `true`. Please make sure to rerun your tests after upgrading to confirm your tested actually return values whatever they were supposed to match.", 150 | "Updater: Identical or duplicate feeds from different sources (user configuration, feeds and API use) are no longer being checked for updates multiple times.", 151 | "Updater / FileOps: Fixed several broken error messages and return values." 152 | ], 153 | "0.6.1": [ 154 | "The Updater component now supports the DownloadManager v0.4.0 API changes.", 155 | "Updater: A regression introduced in v0.6.0 was fixed that caused update or installation processes to fail when the feed contained deletion records.", 156 | "FileOps.mkdir() no longer falsely retuns an error state when a path to an existing file is passed with the `isFile` flag set." 157 | ], 158 | "0.6.0": [ 159 | "The UnitTestSuite framework for automatically testing automation scripts and modules has been added.", 160 | "Macros can now be registered with custom submenu name.", 161 | "Logger:logEx() now takes an additional optional `indent` parameter to specify a custom indentation level.", 162 | "FileOps.move() no longer overwrites existing files by default.", 163 | "FileOps.mkdir() can now take paths relative to the current working directory as input and returns `true` on success, `false` when the directory already exists or `nil` if the operation failed.", 164 | "FileOps.remove() now returns detailed results for every path specified in addition to overall success information.", 165 | "FileOps.move() no longer fails to move files across file systems on *nix operating systems and properly cleans up after itself if files could not be overwritten and were renamed instead.", 166 | "FileOps: path validation is no longer broken on non-windows systems" 167 | ], 168 | "0.5.3": [ 169 | "ConfigHandler: A host of longstanding issues related to config file corruption and concurrent access to config files from multiple DepCtrl-hosting automation scripts have been fixed.", 170 | "Error Reports of required modules loaded by DependencyControl now actually provide semi-useful stack traces.", 171 | "A bug was fixed that could cause DepCtrl to rerun the __depCtrlInit method on modules even though a prior DependencyControl record had already been initialized.", 172 | "The DependencyControl self-update now runs through properly without throwing an error at the end of the process." 173 | ], 174 | "0.5.2": [ 175 | "Updates and installations no longer fail when no suitable version of a module marked as an optional dependency can be found.", 176 | "ConfigHandlers now recover gracefully when a corrupted config is encountered.", 177 | "Fixed a bug that may have caused updates of unmanaged modules to throw an error after completion.", 178 | "DependencyControl initialization functions in modules with optional DepCtrl support are now expected to use the predefined name __depCtrlInit. This lifts the unreasonable requirement of having to specify the name of the function in the dependency tables of the loading scripts. By extension, this also fixes errors when trying to update the binary modules required by DependencyControl (such as DownloadManager).", 179 | "The Updater now checks for an active internet connection before going ahead with downloading feeds and packages.", 180 | "FileOps: added a copy function for files." 181 | ], 182 | "0.5.1": [ 183 | "Macros registered using DependencyControl now get passed the previously missing 'active_line' paramter.", 184 | "Fixed a bug that would cause an unrelated error to be thrown in place of the real error message when an updated module failed to load." 185 | ], 186 | "0.5.0": [ 187 | "DependencyControl does now auto-update itself and its dependencies.", 188 | "Provided Sub-Modules (Logger, ConfigHandler, ...) can now easily be accessed as class properties of the main DependencyControl module.", 189 | "A bug was fixed that caused macros always being registered with the overall script description, ignoring specific descriptions for the macro menu entries.", 190 | "The \\getConfigHandler() method no longer ignores the defaults parameter.", 191 | "Fixed a FileOps bug that would cause path validation to fail on paths relative to the working directory.", 192 | "ConfigHandler: writes to the configuration table are no longer accidentally routed to the defaults table when a value is updated that only exists in the Defaults.", 193 | "ConfigHandler: Looping over the configuration table is now completely transparent wrt which fields are user configuration or defaults.", 194 | "ConfigHandler: fixed a bug that prevented a global lock on the config file from being released on certain error conditions.", 195 | "The update feed format has been updated to v0.2.0 and introduces a new template variable to reference knownFeeds specifed at the top level." 196 | ] 197 | } 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | DependencyControl - Enterprise Aegisub Script Management 2 | -------------------------------------------------------- 3 | 4 | DependencyControl provides versioning, automatic script update, dependency management and script management services to Aegisub macros and modules. 5 | 6 | __Features__: 7 | 8 | * A lightweight package manager lets users conveniently install scripts right from inside Aegisub 9 | * Loads modules used by an automation script, pulls missing requirements from the internet and informs the user about missing and outdated modules that could not be updated automatically 10 | * Checks scripts and modules for updates and automatically installs them 11 | * Offers convenient macro registration with user-customizable submenus 12 | * Provides configuration, logging services, file operations and a unit test framework for your scripts 13 | * Supports optional modules and private module copies for cases where an older or custom version of a module is required 14 | * Resolves circular dependencies (limitations apply) 15 | 16 | __Requirements__: 17 | 18 | * Aegisub > 3.2.0 (e.g. [Plorkyeran's](http://plorkyeran.com/aegisub/) r8792+ or [my](http://files.line0.eu/builds/Aegisub/) git builds) 19 | * [LuaJSON](https://github.com/harningt/luajson) 20 | * [DownloadManager](https://github.com/torque/ffi-experiments/releases) v0.3.0 21 | * [BadMutex](https://github.com/torque/ffi-experiments/releases) v0.1.2 22 | * [PreciseTimer](https://github.com/torque/ffi-experiments/releases) v0.1.4 23 | 24 | ---------------------------------- 25 | 26 | ### Documentation ### 27 | 28 | 1. [DependencyControl for Users](#dependency-control-for-users) 29 | 2. [Usage for Automation Scripts](#usage-for-automation-scripts) 30 | 3. [Namespaces and Paths](#namespaces-and-paths) 31 | 4. [The Anatomy of an Updater Feed](#the-anatomy-of-an-updater-feed) 32 | 5. [Reference](#reference) 33 | 1. [DependencyControl](#FIXME) 34 | 2. [Updater](#FIXME) 35 | 3. [Logger](#FIXME) 36 | 4. [ConfigHandler](#FIXME) 37 | 5. [FileOps](#FIXME) 38 | 39 | ---------------------------------- 40 | 41 | ### Dependency Control for Users ### 42 | 43 | As an end-user you don't get to decide whether your scripts use DependencyControl or not, but you can control many aspects of its operation. The updater works out-of-the-box (for any script with an update feed) and is run automatically. 44 | 45 | #### Install Instructions #### 46 | 1. Download the latest DependencyControl release for your platform and unpack its contents to your Aegisub **user** automation directory. 47 | Alternatively use one of the [provided Aegisub builds](http://files.line0.eu/builds/Aegisub/) with built-in DependencyControl. 48 | 49 | _It is essential DependencyControl and all scripts it's used reside in the **user** automation directory, **NOT** the the automation directory in the Aegisub application folder._ 50 | 51 | On Windows, this will be `%AppData%\Aegisub\automation` folder. 52 | 53 | 2. In Aegisub, rescan your automation folder (or restart Aegisub). 54 | 55 | #### Configuration #### 56 | DependencyControl comes with sane default settings, so if you're happy with that, there's no need to read further. If you want to disable the updater, use custom menus or want to tweak another aspect of DepedencyControl, read on. 57 | 58 | DependencyControl stores its configuration as a JSON file in the _config_ subdirectory of your Aegisub folder (`l0.DependencyControl.json`). Currently you'll have to edit this file manually, in the future there will be a management macro. 59 | 60 | There are 2 kinds of configuration: 61 | 62 | ##### 1. Global Configuration ##### 63 | Changes made in the `config` section of the configuration file will affect all scripts and general DependencyControl behavior. 64 | 65 | __Available Fields__: 66 | 67 | * *bool* __updaterEnabled [true]:__ Turns the updater on/off 68 | * *int* __updateInterval [3 Days]:__ The time in seconds between two update checks of a script 69 | * *int* __traceLevel [3]:__ Sets the Trace level of DependencyControl update messages. Setting this higher than your _Trace level_ setting in Aegisub will prevent any of the messages from littering your log window. 70 | * *bool* __dumpFeeds [true]:__ Debug option that will make DependencyControl dump updater feeds (original and expanded) to your Aegsiub folder. 71 | * *arr* __extraFeeds:__ lets you provide additional update feeds that will be used when checking any script for updates 72 | * *bool* __tryAllFeeds [false]:__ When set to true, feeds available to update a macro or module will be checked until an update is found. When set to false, a regular update process will stop once a feed confirms the script to be up-to-date. 73 | * *str* __configDir ["?user/config"]:__ Sets the configuration directory that will be "offered" to automation scripts (they may or may not actually use it) 74 | * *str* __writeLogs [true]:__ When enabled, DependencyControl log messages will be written to a file in the Aegisub log folder. This is a valuable resource for debugging, especially since the Aegisub log window is not available during script initalization. 75 | * *int* __logMaxFiles [200]:__ DepedencyControl will purge old updater log files when any of the limits for log file count, log age and cumulative file size is exceeded. 76 | * *int* __logMaxAge [1 Week]:__ Logs with a last modified date that exceeds this limit will be deleted. Takes a duration in seconds. 77 | * *int* __logMaxSize [10 MB]:__ Cumulative file size limit for all log files in bytes. 78 | 79 | ##### 1. Per-script Configuration ##### 80 | Changes made in the `macros` and `modules` sections of the configuration file affect only the script or module in question. 81 | 82 | __Available Fields__: 83 | 84 | * *str* __customMenu:__ If you want to sort your automation macros into submenus, set this to the submenu name (use `/` to denote submenu levels). 85 | * *str* __userFeed:__ When set the updater will use this feed exclusively to update the script in question (instead of other feeds) 86 | * *int* __lastUpdateCheck [auto]:__ This field is used to store the (epoch) time of the last update check. 87 | * *int* __logLevel [3]:__ sets the default trace level for log messages from this script (only applies to messages sent through a Logger instance provided by DepedencyControl to the script) 88 | * *bool* __logToFile [false]:__ set the user preference wrt/ whether log messages of this script should be written to disk or not (same restrictions as above apply, may be overridden by the script) 89 | * author, configFile, feed, moduleName, name, namespace, url, requiredModules, version, unmanaged: These fields hold aspects of the script's version record. Don't change them (they will be reset anyway) 90 | 91 | ----------------------------------------- 92 | ### Usage for Automation Scripts ### 93 | 94 | #### For Macros: #### 95 | 96 | Load DependencyControl at the start of your macro and create a version record. Script and version information is automatically pulled from the `script_*` variables (the additional `script_namespace` variable is **required**). 97 | 98 | Here's an example of a macro that requires several modules - some of which have a version record as well as some that don't. 99 | 100 | ```lua 101 | script_name = "Move Along Path" 102 | script_description = "Moves text along a path specified in a \\clip. Currently only works on fbf lines." 103 | script_version = "0.1.2" 104 | script_author = "line0" 105 | script_namespace = "l0.MoveAlongPath" 106 | 107 | local DependencyControl = require("l0.DependencyControl") 108 | local version = DependencyControl{ 109 | feed = "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/DependencyControl.json", 110 | { 111 | "aegisub.util", 112 | {"a-mo.LineCollection", version="1.0.1", url="https://github.com/torque/Aegisub-Motion"}, 113 | {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, 114 | {"a-mo.Log", url="https://github.com/torque/Aegisub-Motion"}, 115 | {"l0.ASSFoundation", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", 116 | feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 117 | {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", 118 | feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 119 | "YUtils" 120 | } 121 | } 122 | local util, LineCollection, Line, Log, ASS, Common, YUtils = version:requireModules() 123 | ``` 124 | 125 | Specifying a feed in your own version record provides DepedencyControl with a source to download updates to your script from. 126 | Specifying feeds for required modules managed by DependencyControl allows the Updater to discover those modules and fetch them when they're missing from the user's computer. However, you can omit the feed URLs for required modules when your own feed already has references to them. 127 | 128 | 129 | To __register your macros__ use the following code snippets instead of the usual *aegisub.register_macro()* calls: 130 | 131 | For a __single macro__ that should be registered using the *script_name* as automation menu entry, use: 132 | ```Lua 133 | version:registerMacro(myProcessingFunction) 134 | ``` 135 | 136 | For a script that registers __several macros__ using its own submenu use: 137 | ```Lua 138 | version:registerMacros{ 139 | {script_name, "Opens the Move Along Path GUI", showDialog, validClip}, 140 | {"Undo", "Reverts lines to their original state", undo, hasUndoData} 141 | } 142 | ``` 143 | 144 | Using this method for macro registration is a requirement for the __custom submenus__ feature to work with your script and lets DependencyControl hook your macro processing function to run an update check when your macro is run. 145 | 146 | #### For Modules: #### 147 | 148 | Creating a record for a module is very similar to how it does for macros, with the key difference being that name and version information is passed to DependencyControl correctly and a *moduleName* is required. 149 | 150 | ```lua 151 | 152 | local DependencyControl = require("l0.DependencyControl") 153 | local version = DependencyControl{ 154 | name = "ASSFoundation", 155 | version = "0.1.1", 156 | description = "General purpose ASS processing library", 157 | author = "line0", 158 | url = "http://github.com/TypesettingTools/ASSFoundation", 159 | moduleName = "l0.ASSFoundation", 160 | feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json", 161 | { 162 | "l0.ASSFoundation.ClassFactory", 163 | "aegisub.re", "aegisub.util", "aegisub.unicode", 164 | {"l0.ASSFoundation.Common", version="0.1.1", url="https://github.com/TypesettingTools/ASSFoundation", 165 | feed = "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json"}, 166 | {"a-mo.LineCollection", version="1.0.1", url="https://github.com/TypesettingTools/Aegisub-Motion"}, 167 | {"a-mo.Line", version="1.0.0", url="https://github.com/TypesettingTools/Aegisub-Motion"}, 168 | {"a-mo.Log", url="https://github.com/TypesettingTools/Aegisub-Motion"}, 169 | "ASSInspector.Inspector", 170 | {"YUtils", optional=true}, 171 | } 172 | 173 | local createASSClass, re, util, unicode, Common, LineCollection, Line, Log, ASSInspector, YUtils = version:requireModules() 174 | 175 | ``` 176 | 177 | A reference to the version record must be added as the *.version* field of your returned module for version control to work. 178 | A module should also register itself to enable circular dependency support. The *:register()* method returns your module, so the last lines of your module should look like this: 179 | 180 | ```lua 181 | 182 | MyModule.version = version 183 | 184 | return version:register(MyModule) 185 | 186 | ``` 187 | --------------------------------------------- 188 | 189 | ### Namespaces and Paths ### 190 | 191 | DependencyControl strictly enforces a **namespace-based file structure** for modules as well as automation macros in order to ensure there are no conflicts between scripts that happen to have the same name. 192 | 193 | Automation scripts must define their namespace in the version record whereas for modules the module name (as you would use in a `require` statement) defines the namespace. 194 | 195 | #### Rules for a valid namespace: #### 196 | 197 | 1. contains _at least_ one dot 198 | 2. must **not** start or end with a dot 199 | 3. must **not** contain series of two or more dots 200 | 4. the character set is restricted to: `A-Z`, `a-z`, `0-9`, `.`, `_`, `-` 201 | 5. *should* be descriptive (this is more of a guideline) 202 | 203 | __Examples__: 204 | * l0.ASSFoundation 205 | * l0.ASSFoundation.Common (for a separately version-controlled 'submodule') 206 | * l0.ASSWipe 207 | * a-mo.LineCollection 208 | 209 | #### File and Folder Structure #### 210 | 211 | The namespace of your script translates into a subtree of the **user**automation directory you can use to store your files in. DepedencyControl will _not_ refuse to work with scripts that ignore this restriction, however it's designed in such a way that downloading to locations outside of your tree is **impossible** (which means your macro/module be able to use the auto-updater). 212 | 213 | __Automation Scripts__ use the `?user/automation/autoload`, which has a flat file structure. You may **not** use subdirectories and your **file names must start with the namespace of your script**. 214 | 215 | Examples: 216 | * l0.ASSWipe.lua 217 | * l0.ASSWipe.Addon.moon 218 | 219 | __Modules__ use the `?user/automation/include` folder, which has a nested file structure. To determine your _subdirectory/file base name_, the dots in your namespace are replaced with `/` (`\` in Windows terms). 220 | 221 | __Tests__ use the `?user/automation/tests/DepUnit/modules` or `?user/automation/tests/DepUnit/macros` folder depending on whether a macro or automation is being tested and mirror the directory structure of the respective `include` and `autoload` folders. 222 | 223 | Our example module ASSFoundation with namespace __l0.ASSFoundation__ writes (among others) the following files: 224 | * __?user/automation/include/l0/ASSFoundation__.lua 225 | * __?user/automation/include/l0/ASSFoundation__/ClassFactory.lua 226 | * __?user/automation/include/l0/ASSFoundation__/Draw/Bezier.lua 227 | * __?user/automation/tests/modules/l0/ASSFoundation__.lua 228 | 229 | --------------------------------------------- 230 | 231 | ### The Anatomy of an Updater Feed ### 232 | 233 | If you want DepedencyControl auto-update your script on the user's system, you'll need to supply update information in an updater feed, which is a _JSON_ file with a simple basic layout: 234 | 235 | *(`//` denotes a comment explaining the property above)* 236 | 237 | `````javascript 238 | { 239 | "dependencyControlFeedFormatVersion": "0.3.0", 240 | // The version of the feed format. The current version is 0.3.0, don't touch this until further notice. 241 | "name": "line0's Aegisub Scripts", 242 | "description": "Main repository for all of line0's automation macros.", 243 | "maintainer": "line0", 244 | // The title and description of your repository as well as the name of the maintainer. May be used by GUI-driven management tools, package managers, etc... 245 | "knownFeeds": { 246 | "a-mo": "https://raw.githubusercontent.com/TypesettingTools/Aegisub-Motion/DepCtrl/DependencyControl.json", 247 | "ASSFoundation": "https://raw.githubusercontent.com/TypesettingTools/ASSFoundation/master/DependencyControl.json" 248 | }, 249 | // A hashtable of known feed URLs. Can be referenced with @{feed:name} and will be used to discover other repositories the user can install automation scripts and modules from. At the very least this should contain the repo URLs for the required modules in your repo, but may be used to advertise other unrelated repos you trust. 250 | "baseUrl": "https://github.com/TypesettingTools/line0-Aegisub-Scripts", 251 | // baseUrl is a template variable that can be referenced in other string fields of the template. It's useful when you have several scripts which all have their documentation hosted on the same site (so they start with the same URL). For more Information about templates, see the section below. 252 | "url": "@{baseUrl}", 253 | // The address where information about this repository can be found. In this case it references the baseUrl template variable and expands to "https://github.com/TypesettingTools/line0-Aegisub-Scripts". 254 | "fileBaseUrl": "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/@{channel}/@{namespace}", 255 | // A special rolling template variable. See the templates section below for more information. 256 | 257 | "macros": { 258 | // the section where all automation scripts tracked by this feed go. The key for each value is the namespace of the respective script. Below this level, this namespace is available as the @{namespace} and @{namespacePath} template variable 259 | "l0.ASSWipe": { ... }, 260 | "l0.Nudge": { ... } 261 | }, 262 | "modules": { 263 | // Your modules go here. If your feed doesn't track any modules, you may omit this section (same goes for the macros object) 264 | "l0.ASSFoundation": { ... } 265 | } 266 | 267 | ````` 268 | 269 | An automation script or module object looks like this: 270 | 271 | ````javascript 272 | "l0.ASSWipe": { 273 | "url": "@{baseUrl}#@{namespace}", 274 | "author": "line0", 275 | "name": "ASSWipe", 276 | "description": "Performs script cleanup, removes unnecessary tags and lines.", 277 | // These script information fields should be identical to the values defined in your 278 | // DepedencyControl version record. 279 | "channels": { 280 | // a list of update channels available for your script (think release, beta and alpha). 281 | // The key is a channel name of your choice, but should make sense to the user picking one. 282 | "master": { 283 | // This example only defines one channel, which is set up to track 284 | // the HEAD of a GitHub repository. 285 | "version": "0.1.3", 286 | // The current script version served in this channel. 287 | // Must be identical to the one in the version record. 288 | "released": "2015-02-26", 289 | // Release date of the current script version (UTC/ISO 8601 format) 290 | "default": true, 291 | // Marks this channel as the default channel in case the user doesn't have picked a specific one. 292 | // Must be set to true for **exactly** one channel in the list. 293 | "platforms": ["Windows-x86", "Windows-x64", "OSX-x64"] 294 | // Optional: A list of platforms you serve builds for. You should omit this property for regular scripts 295 | // and modules that use only Lua/Moonscript and no binaries. If this property is absent, 296 | // the platform check will be skipped. The platform names are derived from the output of 297 | // ffi.os()-ffi.arch() in luajit. 298 | "files": [ 299 | // A list of files installed by your script. 300 | { 301 | "name": ".lua", 302 | // the file name relative to the path assigned to the script by your namespace choice 303 | // (see 3. Namespaces and Paths for more information). Available as the @{fileName} template variable 304 | // for use in the url field below. 305 | "url": "@{fileBaseUrl}@{fileName}", 306 | // URL from which the **raw** file can be downloaded from (no archives, no javascript 307 | // redirects, etc...). In this case the templates expand to 308 | // "https://raw.githubusercontent.com/TypesettingTools/line0-Aegisub-Scripts/master/l0.ASSWipe.lua" 309 | "sha1": "A7BD1C7F0E776BA3010B1448F22DE6528F73B077" 310 | // The SHA-1 hash of the file being currently served under that url. Will be checked 311 | // against the downloaded file, so it must always be present and valid or the update process 312 | // will fail on the user's end. 313 | }, 314 | { 315 | "name": ".lua", 316 | "type": "test", 317 | // Optional, defaults to "script". Specify "test" to denote a unit test. 318 | // Currently only "script" and "test" are available, unknown script types will be skipped. 319 | "url": "@{fileBaseUrl}.Tests.lua", 320 | "sha1": "27745AB9CF04A840CF3454050CA9D38FA345CEBB" 321 | }, 322 | { 323 | "name": ".Helper.dll", 324 | "url": "@{fileBaseUrl}@{fileName}", 325 | "sha1": "0B4E0511116355D4A11C2EC75DF7EEAD0E14DE9F" 326 | "platform": "Windows-x86" 327 | // Optional. When this property is present, the file will only be downloaded to the users 328 | // computer if his platform matches to this value. 329 | } 330 | ], 331 | "requiredModules": [ 332 | // an exhaustive list of modules required by this script. Must be identical to the required 333 | // module entries in your DepdencyControl record, but you may not use short style here. 334 | // (see 2. Usage for Automation Scripts for more information) 335 | { 336 | "moduleName": "a-mo.LineCollection", 337 | "name": "Aegisub-Motion (LineCollection)", 338 | "url": "https://github.com/torque/Aegisub-Motion", 339 | "version": "1.0.1", 340 | "feed": "@{feed:a-mo}" 341 | }, 342 | { 343 | "moduleName": "l0.ASSFoundation", 344 | "name": "ASSFoundation", 345 | "url": "https://github.com/TypesettingTools/ASSFoundation", 346 | "version": "0.1.1", 347 | "feed": "@{feed:ASSFoundation}" 348 | }, 349 | { 350 | "moduleName": "aegisub.util" 351 | }, 352 | ] 353 | } 354 | }, 355 | "changelog": { 356 | // a change log that allows users to see what's new in this and previous versions. The changelog 357 | // is shared between all channels. Only the entries with a version number equal or below 358 | // the version the user just updated to will be displayed. 359 | "0.1.0": [ 360 | "Sync with ASSFoundation changes", 361 | // one entry for each line 362 | "Start versioning with DependencyControl" 363 | ], 364 | "0.1.3": [ 365 | "Enabled auto-update using DependencyControl", 366 | "Changed config file to \\config\\l0.ASSWipe.json (rename ASSWipe.json to restore your existing configuration)", 367 | "DependencyControl compatibility fixes" 368 | ] 369 | } 370 | } 371 | ```` 372 | 373 | #### Template Variables #### 374 | 375 | To make maintaining an update feed easier, you can use several template variables that will be expanded when used inside string values (but **not** Keys). 376 | 377 | __Regular Variables:__ These reference a specific key or value and are available at the same depth and further down the tree from the point on where they were created. 378 | 379 | Variables extracted at the **same depth** are expanded in a specific order. As a consequence only references to variables of lower order are expanded in values that are assigned to a variable themselves. 380 | 381 | _Depth 1:_ Feed Information 382 | 1. __feedName__: The name of the feed 383 | 2. __baseUrl__: The baseUrl field 384 | 3. __feed:###__: A reference to a feed URL in the knownFeeds table 385 | 386 | _Depth 3:_ Script Information 387 | 1. __namespace__: the script namespace 388 | 2. __namespacePath__: the script namespace with all `.` replaced by `/` 389 | 3. scriptName: the script name 390 | 391 | _Depth 5:_ Version Information 392 | 1. __channel__: the channel name of this version record 393 | 2. __version__: the version number as a SemVer string 394 | 395 | _Depth 7:_ File Information 396 | 1. __platform__: the platform defined for this file, otherwise an empty string 397 | 2. __fileName__: the file name 398 | 399 | 400 | __"Rolling" Variables:__ These variables can be defined at any depth in the JSON tree and are continuously expanded using the variables available. You can reference a rolling variable in itself, which will substitute the template for the contents the variable had at the parent-level. 401 | 402 | Right now there's only one such variable: __fileBaseUrl__, which you can use to construct the URL to a file using the template variables available. 403 | 404 | For an example to serve updates from the HEAD of a GitHub repository, see [here](https://github.com/TypesettingTools/line0-Aegisub-Scripts/blob/master/DependencyControl.json). An example that shows a feed making use of tagged releases is [also available](https://github.com/TypesettingTools/ASSFoundation/blob/master/DependencyControl.json). 405 | 406 | --------------------------------------------- 407 | 408 | ### Reference ### 409 | 410 | This section is currently both incomplete and outdated. Sorry about that. 411 | 412 | #### DependencyControl #### 413 | 414 | __DependencyControl{*tbl* [requiredModules]={}, *str* :name=script_name, *str* :description=script_description, *str* :author=script_author, *str* :url, *str* :version, *str* :moduleName, *str* [:configFile], *string* [:namespace]} --> *obj* DependecyControlRecord__ 415 | 416 | The constructor for a DepedencyControl record. Uses the table-based signature. 417 | __Arguments:__ 418 | 419 | * _requiredModules_: the first and only unnamed argument. Contains all required modules, which may be either a single string for a non-version-controlled requirement or a table with the following fields: 420 | * __*str* [moduleName/[1]]:__ the module name 421 | * __*str* [version]:__ The minimum required version of the module. Must conform to Semantic Versioning standards. The module in question must contain a DependencyControl version record or otherwise compatible version number. 422 | * __*str* [url]__: The URL of the site where the module can be downloaded from (will be shown to the user in error methods). 423 | * __*str* [feed]__: The update feed used to fetch a copy of the required module when it is missing from the user's system. 424 | * __*bool* [optional=false]__: Marks the module as an optional requirement. If the module is missing on the user's system, no error will be thrown. However, version requirements *will* be checked if the module was found. 425 | * __*str* [name]__: Friendly module name (used for error messages). 426 | 427 | * _name, description, author_: Required for modules, pulled from the *script_* globals for macros. 428 | * _version_: Must conform to [Semantic Versioning](http://semver.org/) standards. Labels and build metadata are not supported at this time 429 | * _moduleName_: module name (as used in require statements). Required for modules, must be nil for macros. Represents the namespace of a module. 430 | * _url_: The web site/repository URL of your script 431 | * _feed_: The update feed for your script. 432 | * _configFile_: Configuration file base name used by the script. Defaults to the namespace. Used for configuration services and script management purposes. 433 | 434 | ##### Methods ##### 435 | __:checkVersion(*str/num* version, *str* [precision = "patch"]) --> *bool* moduleUpToDate, *str* error__ 436 | 437 | Returns true if the version number of the record is greater than or equal to __version__. Reduce the __precision__ to `minor` or `major` to also return true for lower patch or minor versions respectively. If the version can't be parsed it returns nil and and error message. 438 | 439 | __:checkOptionalModules(*tbl* modules) --> *bool* result, *str* errorMessage__ 440 | 441 | Returns true if the optional __modules__ have been loaded, where __modules__ is a list of module names. If one or more of the modules are missing it returns false and an error message. 442 | 443 | __:getConfigFileName() --> *str* fileName__ 444 | 445 | Returns a full path to the config file proposed for this script by DependencyControl. Uses the configFile argument passed to the constructor which defaults to the script namespace. The path is subject to user configuration and defaults to "?user\config". The file ending is always .json, because why would you use any other format? 446 | 447 | The rationale for this function is to keep all macro and module configuration files neatly in one spot and make them discoverable for other scripts (through the DepedencyControl config file). 448 | 449 | __:getConfigHandler([defaults], [section], [noLoad]) => *obj* ConfigHandler__ 450 | 451 | Returns a ConfigHandler (see [ConfigHandler Documentation](#FIXME)) attached to the config file configured for this script. 452 | 453 | __:getLogger(*tbl* args) => *obj* Logger__ 454 | 455 | Returns a Logger (see [Logger Documentation](#FIXME)) preconfigured for this script. Trace level and config file preference default to user-configurable values. Log file name and prefix are based on namespace and script name. 456 | 457 | __:getVersionNumber(*str/num* versionString) --> *int/bool* version, *str* error__ 458 | 459 | Takes a SemVer string and converts it into a version number. If parsing the version string fails it returns false and an error message instead. 460 | 461 | __:getVersionString(*int* [version=@version]) --> *str* versionString__ 462 | 463 | Returns a version (by default the script version) as a SemVer string. 464 | 465 | __:getConfigFileName() --> *str* configFileName__ 466 | 467 | Generates and returns a full path to the registered config file name for the module. 468 | 469 | __:loadConfig(*bool* [importRecord], *bool* [forceReloadGlobal]) --> *bool* shouldWriteConfig, *bool* firstInit__ 470 | 471 | Loads global DependencyControl and per-script configuration from the DepedencyControl configuration file. If __importRecord__ is true, the version record information of a DependencyControl record will be (temporarily) overwritten by the values contained in the configuration file. 472 | Global configuration is only loaded on first run or if __forceReloadGlobal__ is true. 473 | 474 | The first return result indicates there are changes to be written to the config file, the second result returns true if the config file was only just created. _Intended for internal use._ 475 | 476 | __:loadModule(*tbl* module, *bool* [usePrivate]) --> *tbl* moduleRef__ 477 | 478 | Loads and returns single module and only errors out in case of module errors. Intended for internal use. If __usePrivate__ is true, a private copy of the module is loaded instead. 479 | 480 | __:moveFile(*str* src, *str* dest) --> *bool* success, *str* error__ 481 | 482 | Moves a file from __source__ to __destiantion__ (where both are full file names). Returns true on success or false and error message on failure. 483 | 484 | __:register(*tbl* selfRef, extraUnitTestArgs...) --> *tbl* selfRef__ 485 | 486 | Replaces dummy reference written to the global LOADED_MODULES table at DependencyControl object creation time with a reference to this module. 487 | Also automatically registers unit tests for this module, passing in any __extraUnitTestArgs__ 488 | 489 | The purpose of this construct is to allow circular references between modules. Limitations apply: the modules in question may not use each other during construction/setup of each module (for obvious reasons). 490 | 491 | Call this method as replacement for returning your module. 492 | 493 | __:registerMacro(*str* [name=@name], *str* [description=@description], *func* processing_function, *func* [validation_function], *func* is_active_function, *bool|string* [submenu=false])__ 494 | 495 | Alternative Signature: 496 | 497 | __:registerMacro(*func* processing_function, *func* [validation_function], *func* is_active_function, *bool|string* [submenu=false])__ 498 | 499 | Registers a single macro using script name and description by default. 500 | Use __submenu__ to specify a submenu name to use for this macro or set it to `true` to use the automation script name. 501 | 502 | If the script entry in the DependencyControl configuration file contains a __customMenu__ property, the macro will be placed in the specified menu. Do note that that this setting is for *user customization* and not to be changed without the user's consent. 503 | 504 | For the other arguments, please refer to the [aegisub.register_macro](http://docs.aegisub.org/latest/Automation/Lua/Registration/#aegisub.register_macro) API documentation. 505 | 506 | __:registerMacros(*tbl* macros, *bool|string* [submenuDefault=true])__ 507 | 508 | Registers multiple macros, where __macros__ is a list of tables containing the arguments to a __:registerMacro()__ call for each automation menu entry. a single macro using script name and description by default. 509 | Use __submenuDefault__ to specify a submenu all macros will be placed in unless overriden on a per-macro basis. Defaults to `true` which causes the automation script name to be used as the submenu name. 510 | 511 | __:registerTests(unitTestArgs...)__ 512 | 513 | Registers unit tests for automation modules, passing in any of specified __unitTestArgs__. Registration of modules is done automatically upon calling __:register__ 514 | 515 | __:requireModules([modules=@requiredModules], *bool* [forceUpdate], *bool* [updateMode], *tbl* [addFeeds={@feed})] --> ...__ 516 | 517 | Loads the modules required by this script and returns a reference for every requirement in the order they were supplied by the user. If an optional module is not found, nil is returned. 518 | 519 | The updater will try to download copies of modules that are missing or outdated on the user's system. The __addFeeds__ parameter can be used to supply additional feeds to search. If missing/outdated requirements can't be fetched, the method will throw an error in normal mode or false and an error message in __update mode__. 520 | 521 | Use __forceUpdate__ to override update intervals and perform update checks for all required modules, even if requirements are satisfied. 522 | 523 | __:writeConfig(*bool* [writeLocal=true], *bool* [writeGlobal=true], *bool* [concert]]__ 524 | 525 | Writes __global__ and per-module __local__ configuration. If __concert__ is true, concerted writing will be used to update the configuration of all DependencyControl hosted by any given macro/environment at once. See ConfigHandler documentation for more information. _Intended for internal use._ 526 | 527 | #### Updater ##### 528 | 529 | ##### Methods ##### 530 | 531 | __:getUpdaterErrorMsg(*int* [code], *str* targetName, ...) --> *str* errorMsg__ 532 | 533 | Used to turn an updater return __code__ into a human-readable error message. The __name__ of the updated component and other format string parameters are passed into the function. 534 | 535 | VarArgs: 536 | 537 | 1. __*bool* isModule__: True when component is a module, false when it is an automation script/macro 538 | 2. __*bool* isFetch__: True when we are fetching a missing module, false when updating 539 | 3. __extError__: Extended error information as returned by the _:update()_ method 540 | 541 | __:getUpdaterLock(*bool* [doWait], *int* [waitTimeout=(user config)]) --> *bool* result, *str* runningHost__ 542 | 543 | Locks the updater to the current macro/environment. Since all automation scripts load in parallel we have to make sure multiple automation scripts don't all update/fetch the same dependencies at once multiple times. The solution is to only let one updater operate at a time. The others will wait their turn and recheck if their required modules were fetched in the meantime. 544 | 545 | If __doWait__ is true, the function will wait until the updater is unlocked or __waitTimeout__ has passed. It will then get the lock and return true. If __doWait__ is false, the function will return immediately (true on success, false if another updater has the lock). _Intendend for internal use_. 546 | 547 | __:releaseUpdaterLock()__ 548 | 549 | Makes an updater host (macro) release its lock on the Updater if it has one. See _:getUpdaterLock_ for more information 550 | 551 | __:update(*bool* [force], *tbl* [addFeeds], *bool* [tryAllFeeds=auto]) --> *int* resultCode, *str* extError__ 552 | 553 | Runs the updater on this automation script or module. This includes recursively updating all required modules. When __force__ is true, required modules will skip their update interval check. 554 | 555 | By default, the updater will process all suitable feeds until one feed confirms the script to be up-to-date (unless configured otherwise by the user or if we are looking for updates to an outdated component). Set __tryAllFeeds__ to true to check all feeds until an update is found. You can also supply __additional candidate feeds__. 556 | 557 | Returns a result code (0: up-to-date, 1: update performed, <=-1: error) and extended error information which can be fed into _:getUpdaterErrorMsg()_ to get a descriptive error message. 558 | 559 | #### Logger #### 560 | 561 | tbd 562 | 563 | #### ConfigHandler #### 564 | 565 | tbd 566 | 567 | #### FileOps #### 568 | 569 | tbd 570 | 571 | #### UnitTestSuite #### 572 | 573 | Reference documentation for the UnitTestSuite module is available in the [source code](https://github.com/TypesettingTools/DependencyControl/blob/master/modules/DependencyControl/UnitTestSuite.moon#L760) 574 | 575 | #### UpdateFeed #### 576 | 577 | tbd 578 | -------------------------------------------------------------------------------- /macros/l0.DependencyControl.Toolbox.moon: -------------------------------------------------------------------------------- 1 | export script_name = "DependencyControl Toolbox" 2 | export script_description = "Provides DependencyControl maintenance and configuration tools." 3 | export script_version = "0.1.3" 4 | export script_author = "line0" 5 | export script_namespace = "l0.DependencyControl.Toolbox" 6 | 7 | DepCtrl = require "l0.DependencyControl" 8 | depRec = DepCtrl feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json" 9 | logger = DepCtrl.logger 10 | logger.usePrefixWindow = false 11 | 12 | msgs = { 13 | install: { 14 | scanning: "Scanning %d available feeds..." 15 | } 16 | uninstall: { 17 | running: "Uninstalling %s '%s'..." 18 | success: "%s '%s' was removed sucessfully. Reload your automation scripts or restart Aegisub for the changes to take effect." 19 | lockedFiles: "%s Some script files are still in use and will be deleted during the next restart/reload:\n%s" 20 | error: "Error: %s" 21 | } 22 | macroConfig: { 23 | hints: { 24 | customMenu: "Lets you sort your automation macros into submenus. Use / to denote submenu levels." 25 | userFeed: "When set the updater will use this feed exclusively to update the script in question." 26 | } 27 | } 28 | } 29 | 30 | -- Shared Functions 31 | 32 | buildInstalledDlgList = (scriptType, config, isUninstall) -> 33 | list, map, protectedModules = {}, {}, {} 34 | if isUninstall 35 | protectedModules[mdl.moduleName] = true for mdl in *DepCtrl.version.requiredModules 36 | protectedModules[DepCtrl.version.moduleName] = true 37 | 38 | for namespace, script in pairs config.c[scriptType] 39 | continue if protectedModules[namespace] 40 | item = "%s v%s%s"\format script.name, depRec\getVersionString(script.version), 41 | script.activeChannel and " [#{script.activeChannel}]" or "" 42 | list[#list+1] = item 43 | table.sort list, (a, b) -> a\lower! < b\lower! 44 | map[item] = script 45 | return list, map 46 | 47 | getConfig = (section) -> 48 | config = DepCtrl.config\getSectionHandler section 49 | config.c.macros or= {} if not section or #section == 0 50 | return config 51 | 52 | getKnownFeeds = (config) -> 53 | getScriptFeeds = (t) -> [v.userFeed or v.feed for _,v in pairs config.c[t] when v.feed or v.userFeed] 54 | 55 | -- fetch all feeds and look for further known feeds 56 | recurse = (feeds, knownFeeds = {}, feedList = {}) -> 57 | for url in *feeds 58 | feed = DepCtrl.UpdateFeed url 59 | continue if knownFeeds[url] or not feed.data 60 | feedList[#feedList+1], knownFeeds[url] = feed, true 61 | recurse feed\getKnownFeeds!, knownFeeds, feedList 62 | return knownFeeds, feedList 63 | 64 | -- get additional feeds added by the user 65 | knownFeeds, feedList = recurse DepCtrl.config.c.extraFeeds 66 | -- collect feeds from all installed automation scripts and modules 67 | recurse getScriptFeeds("modules"), knownFeeds, feedList 68 | recurse getScriptFeeds("macros"), knownFeeds, feedList 69 | 70 | return feedList 71 | 72 | getScriptListDlg = (macros, modules) -> 73 | { 74 | {label: "Automation Scripts: ", class: "label", x: 0, y: 0, width: 1, height: 1 }, 75 | {name: "macro", class: "dropdown", x: 1, y: 0, width: 1, height: 1, items: macros, value: "" }, 76 | {label: "Modules: ", class: "label", x: 0, y: 1, width: 1, height: 1 }, 77 | {name: "module", class: "dropdown", x: 1, y: 1, width: 1, height: 1, items: modules, value: "" } 78 | } 79 | 80 | runUpdaterTask = (scriptData, exhaustive) -> 81 | return unless scriptData 82 | task, err = DepCtrl.updater\addTask scriptData, nil, nil, exhaustive, scriptData.channel 83 | if task then task\run! 84 | else logger\log err 85 | 86 | 87 | -- Macros 88 | 89 | install = -> 90 | config = getConfig! 91 | 92 | addAvailableToInstall = (tbl, feed, scriptType) -> 93 | for namespace, data in pairs feed.data[scriptType] 94 | scriptData = feed\getScript namespace, scriptType == "modules", nil, false 95 | channels, defaultChannel = scriptData\getChannels! 96 | tbl[namespace] or= {} 97 | for channel in *channels 98 | record = scriptData.data.channels[channel] 99 | verNum = depRec\getVersionNumber record.version 100 | unless config.c[scriptType][namespace] or (tbl[namespace][channel] and verNum < tbl[namespace][channel].verNum) 101 | tbl[namespace][channel] = { name: scriptData.name, version: record.version, verNum: verNum, feed: feed.url, 102 | default: defaultChannel == channel, moduleName: scriptType == "modules" and namespace } 103 | return tbl 104 | 105 | buildDlgList = (tbl) -> 106 | list, map = {}, {} 107 | for namespace, channels in pairs tbl 108 | for channel, rec in pairs channels 109 | item = "%s v%s%s"\format rec.name, rec.version, rec.default and "" or " [#{channel}]" 110 | list[#list+1] = item 111 | table.sort list, (a, b) -> a\lower! < b\lower! 112 | map[item] = { :namespace, :channel, feed: rec.feed, name: rec.name, virtual: true, 113 | moduleName: rec.moduleName } 114 | 115 | return list, map 116 | 117 | -- get a list of the highest versions of automation scripts and modules 118 | -- we can install but wich are not yet installed 119 | macros, modules, feeds = {}, {}, getKnownFeeds config 120 | 121 | logger\log msgs.install.scanning, #feeds 122 | for feed in *feeds 123 | macros = addAvailableToInstall macros, feed, "macros" 124 | modules = addAvailableToInstall modules, feed, "modules" 125 | 126 | -- build macro and module lists as well as reverse mappings 127 | moduleList, moduleMap = buildDlgList modules 128 | macroList, macroMap = buildDlgList macros 129 | 130 | btn, res = aegisub.dialog.display getScriptListDlg macroList, moduleList 131 | return unless btn 132 | 133 | -- create and run the update tasks 134 | macro, mdl = macroMap[res.macro], moduleMap[res.module] 135 | runUpdaterTask mdl, false 136 | runUpdaterTask macro, false 137 | 138 | uninstall = -> 139 | doUninstall = (script) -> 140 | return unless script 141 | scriptType = script.moduleName and "Module" or "Macro" 142 | logger\log msgs.uninstall.running, scriptType, script.name 143 | success, details = DepCtrl(script)\uninstall! 144 | if success == nil 145 | if "table" == type details 146 | -- error may be a string or a file list 147 | details = table.concat ["#{path}: #{res[2]}" for path, res in pairs details when res[1] == nil], "\n" 148 | logger\log msgs.uninstall.error, details 149 | else 150 | msg = msgs.uninstall.success\format scriptType, script.name 151 | logger\log if success 152 | msg 153 | else 154 | fileList = table.concat ["#{path} (#{res[2]})" for path, res in pairs details when res[1] != true], "\n" 155 | msgs.uninstall.lockedFiles\format msg, fileList 156 | 157 | return success 158 | 159 | config = getConfig! 160 | 161 | -- build macro and module lists as well as reverse mappings 162 | moduleList, moduleMap = buildInstalledDlgList "modules", config, true 163 | macroList, macroMap = buildInstalledDlgList "macros", config, true 164 | 165 | btn, res = aegisub.dialog.display getScriptListDlg macroList, moduleList 166 | return unless btn 167 | 168 | macro, mdl = macroMap[res.macro], moduleMap[res.module] 169 | doUninstall mdl 170 | doUninstall macro 171 | 172 | update = -> 173 | config = getConfig! 174 | 175 | -- build macro and module lists as well as reverse mappings 176 | moduleList, moduleMap = buildInstalledDlgList "modules", config 177 | macroList, macroMap = buildInstalledDlgList "macros", config 178 | 179 | dlg = getScriptListDlg macroList, moduleList 180 | dlg[5] = {name: "exhaustive", label: "Exhaustive Mode", class: "checkbox", x: 0, y: 2, width: 1, height: 1} 181 | btn, res = aegisub.dialog.display dlg 182 | return unless btn 183 | 184 | -- create and run the update tasks 185 | macro, mdl = macroMap[res.macro], moduleMap[res.module] 186 | runUpdaterTask mdl, res.exhaustive 187 | runUpdaterTask macro, res.exhaustive 188 | 189 | macroConfig = -> 190 | config = getConfig "macros" 191 | 192 | dlg, i = {}, 1 193 | for nsp, macro in pairs config.userConfig 194 | dlg[i*5+t-1] = tbl for t, tbl in ipairs { 195 | {label: macro.name, class: "label", x: 0, y: i, width: 1, height: 1 }, 196 | {label: "Menu Group: ", class: "label", x: 1, y: i, width: 1, height: 1 }, 197 | {name: "#{nsp}.customMenu", class: "edit", x: 2, y: i, width: 1, height: 1, 198 | text: macro.customMenu or "", hint: msgs.macroConfig.hints.customMenu }, 199 | {label: "Custom Update Feed: ", class: "label", x: 3, y: i, width: 1, height: 1 }, 200 | {name: "#{nsp}.userFeed", class: "edit", x: 4, y: i, width: 1, height: 1, 201 | text: macro.userFeed or "", hint: msgs.macroConfig.hints.userFeed } 202 | } 203 | i += 1 204 | btn, res = aegisub.dialog.display dlg 205 | return unless btn 206 | 207 | for k, v in pairs res 208 | nsp, prop = k\match "(.+)%.(.+)" 209 | if config.c[nsp][prop] and v == "" 210 | config.c[nsp][prop] = nil 211 | elseif v != "" 212 | config.c[nsp][prop] = v 213 | 214 | config\write! 215 | 216 | depRec\registerMacros{ 217 | {"Install Script", "Installs an automation script or module on your system.", install}, 218 | {"Update Script", "Manually check and perform updates to any installed script.", update}, 219 | {"Uninstall Script", "Removes an automation script or module from your system.", uninstall}, 220 | {"Macro Configuration", "Lets you change per-automation script settings.", macroConfig}, 221 | }, "DependencyControl" -------------------------------------------------------------------------------- /modules/DependencyControl.moon: -------------------------------------------------------------------------------- 1 | MIN_MOONSCRIPT_VERSION = "0.3.0" 2 | 3 | SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" 4 | moonscript = require 'moonscript.version' 5 | assert SemanticVersioning\check(moonscript.version, MIN_MOONSCRIPT_VERSION), 6 | [[ DependencyControl requires Moonscript v%s or later to work, 7 | however the Version %s provided by your Aegisub installation is outdated. 8 | Update to a recent Aegisub build to resolve this issue. 9 | ]]\format MIN_MOONSCRIPT_VERSION, moonscript.version 10 | 11 | 12 | Logger = require "l0.DependencyControl.Logger" 13 | UpdateFeed = require "l0.DependencyControl.UpdateFeed" 14 | ConfigHandler = require "l0.DependencyControl.ConfigHandler" 15 | FileOps = require "l0.DependencyControl.FileOps" 16 | Updater = require "l0.DependencyControl.Updater" 17 | UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" 18 | Record = require "l0.DependencyControl.Record" 19 | 20 | class DependencyControl extends Record 21 | @ConfigHandler = ConfigHandler 22 | @UpdateFeed = UpdateFeed 23 | @Logger = Logger 24 | @Updater = Updater 25 | @UnitTestSuite = UnitTestSuite 26 | @FileOps = FileOps 27 | 28 | 29 | rec = DependencyControl{ 30 | name: "DependencyControl", 31 | version: "0.6.3", 32 | description: "Provides script management and auto-updating for Aegisub macros and modules.", 33 | author: "line0", 34 | url: "http://github.com/TypesettingTools/DependencyControl", 35 | moduleName: "l0.DependencyControl", 36 | feed: "https://raw.githubusercontent.com/TypesettingTools/DependencyControl/master/DependencyControl.json", 37 | { 38 | {"DM.DownloadManager", version: "0.3.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, 39 | {"BM.BadMutex", version: "0.1.3", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, 40 | {"PT.PreciseTimer", version: "0.1.5", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, 41 | {"requireffi.requireffi", version: "0.1.1", feed: "https://raw.githubusercontent.com/torque/ffi-experiments/master/DependencyControl.json"}, 42 | } 43 | } 44 | DependencyControl.__class.version = rec 45 | LOADED_MODULES[rec.moduleName], package.loaded[rec.moduleName] = DependencyControl, DependencyControl 46 | DependencyControl.updater\scheduleUpdate rec 47 | rec\requireModules! 48 | 49 | return DependencyControl -------------------------------------------------------------------------------- /modules/DependencyControl/Common.moon: -------------------------------------------------------------------------------- 1 | ffi = require "ffi" 2 | 3 | class DependencyControlCommon 4 | -- Some terms are shared across components 5 | @platform = "#{ffi.os}-#{ffi.arch}" 6 | 7 | @terms = { 8 | scriptType: { 9 | singular: { "automation script", "module" } 10 | plural: { "automation scripts", "modules" } 11 | } 12 | 13 | isInstall: { 14 | [true]: "installation" 15 | [false]: "update" 16 | } 17 | 18 | capitalize: (str) -> str[1]\upper! .. str\sub 2 19 | } 20 | 21 | -- Common enums 22 | @RecordType = { 23 | Managed: 1 24 | Unmanaged: 2 25 | } 26 | 27 | @ScriptType = { 28 | Automation: 1 29 | Module: 2 30 | name: { 31 | legacy: { "macros", "modules" } 32 | canonical: {"automation", "modules"} 33 | } 34 | } 35 | 36 | automationDir: { 37 | aegisub.decode_path("?user/automation/autoload"), 38 | aegisub.decode_path("?user/automation/include") 39 | } 40 | 41 | @testDir = {aegisub.decode_path("?user/automation/tests/DepUnit/macros"), 42 | aegisub.decode_path("?user/automation/tests/DepUnit/modules")} -------------------------------------------------------------------------------- /modules/DependencyControl/ConfigHandler.moon: -------------------------------------------------------------------------------- 1 | util = require "aegisub.util" 2 | json = require "json" 3 | PreciseTimer = require "PT.PreciseTimer" 4 | mutex = require "BM.BadMutex" 5 | 6 | fileOps = require "l0.DependencyControl.FileOps" 7 | Logger = require "l0.DependencyControl.Logger" 8 | 9 | class ConfigHandler 10 | @handlers = {} 11 | errors = { 12 | jsonDecode: "JSON parse error: %s" 13 | configCorrupted: [[An error occured while parsing the JSON config file. 14 | A backup of the corrupted configuration has been written to '%s'. 15 | Reload your automation scripts to generate a new configuration file.]] 16 | badKey: "Can't %s section because the key #%d (%s) leads to a %s." 17 | jsonRoot: "JSON root element must be an array or a hashtable, got a %s." 18 | noFile: "No config file defined." 19 | failedLock: "Failed to lock config file for %s: %s" 20 | waitLockFailed: "Error waiting for existing lock to be released: %s" 21 | forceReleaseFailed: "Failed to force-release existing lock after timeout had passed (%s)" 22 | noLock: "#{@@__name} doesn't have a lock" 23 | writeFailedRead: "Failed reading config file: %s." 24 | lockTimeout: "Timeout reached while waiting for write lock." 25 | } 26 | traceMsgs = { 27 | -- waitingLockPre: "Waiting %d ms before trying to get a lock..." 28 | waitingLock: "Waiting for config file lock to be released (%d ms passed)... " 29 | waitingLockFinished: "Lock was released after %d ms." 30 | mergeSectionStart: "Merging own section into configuration. Own Section: %s\nConfiguration: %s" 31 | mergeSectionResult: "Merge completed with result: %s" 32 | fileNotFound: "Couldn't find config file '%s'." 33 | fileCreate: "Config file '%s' doesn't exist, yet. Will write a fresh copy containing the current configuration section." 34 | writing: "Writing config file '%s'..." 35 | -- waitingLockTimeout: "Timeout was reached after %d seconds, force-releasing lock..." 36 | } 37 | 38 | new: (@file, defaults, @section, noLoad, @logger = Logger fileBaseName: @@__name) => 39 | @section = {@section} if "table" != type @section 40 | @defaults = defaults and util.deep_copy(defaults) or {} 41 | -- register all handlers for concerted writing 42 | @setFile @file 43 | 44 | -- set up user configuration and make defaults accessible 45 | @userConfig = {} 46 | @config = setmetatable {}, { 47 | __index: (_, k) -> 48 | if @userConfig and @userConfig[k] ~= nil 49 | return @userConfig[k] 50 | else return @defaults[k] 51 | __newindex: (_, k, v) -> 52 | @userConfig or= {} 53 | @userConfig[k] = v 54 | __len: (tbl) -> return 0 55 | __ipairs: (tbl) -> error "numerically indexed config hive keys are not supported" 56 | __pairs: (tbl) -> 57 | merged = util.copy @defaults 58 | merged[k] = v for k, v in pairs @userConfig 59 | return next, merged 60 | } 61 | @c = @config -- shortcut 62 | 63 | -- rig defaults in a way that writing to contained tables deep-copies the whole default 64 | -- into the user configuration and sets the requested property there 65 | recurse = (tbl) -> 66 | for k,v in pairs tbl 67 | continue if type(v)~="table" or type(k)=="string" and k\match "^__" 68 | -- replace every table reference with an empty proxy table 69 | -- this ensures all writes to the table get intercepted 70 | tbl[k] = setmetatable {__key: k, __parent: tbl, __tbl: v}, { 71 | -- make the original table the index of the proxy so that defaults can be read 72 | __index: v 73 | __len: (tbl) -> return #tbl.__tbl 74 | __newindex: (tbl, k, v) -> 75 | upKeys, parent = {}, tbl.__parent 76 | -- trace back to defaults entry, pick up the keys along the path 77 | while parent.__parent 78 | tbl = parent 79 | upKeys[#upKeys+1] = tbl.__key 80 | parent = tbl.__parent 81 | 82 | -- deep copy the whole defaults node into the user configuration 83 | -- (util.deep_copy does not copy attached metatable references) 84 | -- make sure we copy the actual table, not the proxy 85 | @userConfig or= {} 86 | @userConfig[tbl.__key] = util.deep_copy @defaults[tbl.__key].__tbl 87 | -- finally perform requested write on userdata 88 | tbl = @userConfig[tbl.__key] 89 | for i = #upKeys-1, 1, -1 90 | tbl = tbl[upKeys[i]] 91 | tbl[k] = v 92 | __pairs: (tbl) -> return next, tbl.__tbl 93 | __ipairs: (tbl) -> 94 | i, n, orgTbl = 0, #tbl.__tbl, tbl.__tbl 95 | -> 96 | i += 1 97 | return i, orgTbl[i] if i <= n 98 | } 99 | recurse tbl[k] 100 | 101 | recurse @defaults 102 | @load! unless noLoad 103 | 104 | setFile: (path) => 105 | return false unless path 106 | if @@handlers[path] 107 | table.insert @@handlers[path], @ 108 | else @@handlers[path] = {@} 109 | path, err = fileOps.validateFullPath path, true 110 | return nil, err unless path 111 | @file = path 112 | return true 113 | 114 | unsetFile: => 115 | handlers = @@handlers[@file] 116 | if handlers and #handlers>1 117 | @@handlers[@file] = [handler for handler in *handlers when handler != @] 118 | else @@handlers[@file] = nil 119 | @file = nil 120 | return true 121 | 122 | readFile: (file = @file, useLock = true, waitLockTime) => 123 | if useLock 124 | time, err = @getLock waitLockTime 125 | unless time 126 | -- handle\close! 127 | return false, errors.failedLock\format "reading", err 128 | 129 | mode, file = fileOps.attributes file, "mode" 130 | if mode == nil 131 | @releaseLock! if useLock 132 | return false, file 133 | elseif not mode 134 | @releaseLock! if useLock 135 | @logger\trace traceMsgs.fileNotFound, @file 136 | return nil 137 | 138 | handle, err = io.open file, "r" 139 | unless handle 140 | @releaseLock! if useLock 141 | return false, err 142 | 143 | data = handle\read "*a" 144 | success, result = pcall json.decode, data 145 | unless success 146 | handle\close! 147 | -- JSON parse error usually points to a corrupted config file 148 | -- Rename the broken file to allow generating a new one 149 | -- so the user can continue his work 150 | @logger\trace errors.jsonDecode, result 151 | backup = @file .. ".corrupted" 152 | fileOps.copy @file, backup 153 | fileOps.remove @file, false, true 154 | 155 | @releaseLock! if useLock 156 | return false, errors.configCorrupted\format backup 157 | 158 | handle\close! 159 | @releaseLock! if useLock 160 | 161 | if "table" != type result 162 | return false, errors.jsonRoot\format type result 163 | 164 | return result 165 | 166 | load: => 167 | return false, errors.noFile unless @file 168 | 169 | config, err = @readFile! 170 | return config, err unless config 171 | 172 | sectionExists = true 173 | for i=1, #@section 174 | config = config[@section[i]] 175 | switch type config 176 | when "table" continue 177 | when "nil" 178 | config, sectionExists = {}, false 179 | break 180 | else return false, errors.badKey\format "retrive", i, tostring(@section[i]),type config 181 | 182 | @userConfig or= {} 183 | @userConfig[k] = v for k,v in pairs config 184 | return sectionExists 185 | 186 | mergeSection: (config) => 187 | --@logger\trace traceMsgs.mergeSectionStart, @logger\dumpToString(@section), 188 | -- @logger\dumpToString config 189 | 190 | section, sectionExists = config, true 191 | -- create missing parent sections 192 | for i=1, #@section 193 | childSection = section[@section[i]] 194 | if childSection == nil 195 | -- don't create parent sections if this section is going to be deleted 196 | unless @userConfig 197 | sectionExists = false 198 | break 199 | section[@section[i]] = {} 200 | childSection = section[@section[i]] 201 | elseif "table" != type childSection 202 | return false, errors.badKey\format "update", i, tostring(@section[i]),type childSection 203 | section = childSection if @userConfig or i < #@section 204 | -- merge our values into our section 205 | if @userConfig 206 | section[k] = v for k,v in pairs @userConfig 207 | elseif sectionExists 208 | section[@section[#@section]] = nil 209 | 210 | -- @logger\trace traceMsgs.mergeSectionResult, @logger\dumpToString config 211 | return config 212 | 213 | delete: (concertWrite, waitLockTime) => 214 | @userConfig = nil 215 | return @write concertWrite, waitLockTime 216 | 217 | write: (concertWrite, waitLockTime) => 218 | return false, errors.noFile unless @file 219 | 220 | -- get a lock to avoid concurrent config file access 221 | time, err = @getLock waitLockTime 222 | unless time 223 | return false, errors.failedLock\format "writing", err 224 | 225 | -- read the config file 226 | config, err = @readFile @file, false 227 | if config == false 228 | @releaseLock! 229 | return false, errors.writeFailedRead\format err 230 | @logger\trace traceMsgs.fileCreate, @file unless config 231 | config or= {} 232 | 233 | -- merge in our section 234 | -- concerted writing allows us to update a configuration file 235 | -- shared by multiple handlers in the lua environment 236 | handlers = concertWrite and @@handlers[@file] or {@} 237 | for handler in *handlers 238 | config, err = handler\mergeSection config 239 | unless config 240 | @releaseLock! 241 | return false, err 242 | 243 | -- create JSON 244 | success, res = pcall json.encode, config 245 | unless success 246 | @releaseLock! 247 | return false, res 248 | 249 | -- write the whole config file in one go 250 | handle, err = io.open(@file, "w") 251 | unless handle 252 | @releaseLock! 253 | return false, err 254 | 255 | @logger\trace traceMsgs.writing, @file 256 | handle\setvbuf "full", 10e6 257 | handle\write res 258 | handle\flush! 259 | handle\close! 260 | @releaseLock! 261 | 262 | return true 263 | 264 | getLock: (waitTimeout = 5000, checkInterval = 50) => 265 | return 0 if @hasLock 266 | success = mutex.tryLock! 267 | if success 268 | @hasLock = true 269 | return 0 270 | 271 | timeout, timePassed = waitTimeout, 0 272 | while not success and timeout > 0 273 | PreciseTimer.sleep checkInterval 274 | success = mutex.tryLock! 275 | timeout -= checkInterval 276 | timePassed = waitTimeout - timeout 277 | if timePassed % (checkInterval*5) == 0 278 | @logger\trace traceMsgs.waitingLock, timePassed 279 | 280 | if success 281 | @logger\trace traceMsgs.waitingLockFinished, timePassed 282 | @hasLock = true 283 | return timePassed 284 | else 285 | -- @logger\trace traceMsgs.waitingLockTimeout, waitTimeout/1000 286 | -- success, err = @releaseLock true 287 | -- unless success 288 | -- return false, errors.forceReleaseFailed\format err 289 | -- @hasLock = true 290 | --return waitTimeout 291 | return false, errors.lockTimeout 292 | 293 | getSectionHandler: (section, defaults, noLoad) => 294 | return @@ @file, defaults, section, noLoad, @logger 295 | 296 | releaseLock: (force) => 297 | if @hasLock or force 298 | @hasLock = false 299 | mutex.unlock! 300 | return true 301 | return false, errors.noLock 302 | 303 | -- copied from Aegisub util.moon, adjusted to skip private keys 304 | deepCopy: (tbl) => 305 | seen = {} 306 | copy = (val) -> 307 | return val if type(val) != 'table' 308 | return seen[val] if seen[val] 309 | seen[val] = val 310 | {k, copy(v) for k, v in pairs val when type(k) != "string" or k\sub(1,1) != "_"} 311 | copy tbl 312 | 313 | import: (tbl = {}, keys, updateOnly, skipSameLengthTables) => 314 | tbl = tbl.userConfig if tbl.__class == @@ 315 | changesMade = false 316 | @userConfig or= {} 317 | keys = {key, true for key in *keys} if keys 318 | 319 | for k,v in pairs tbl 320 | continue if keys and not keys[k] or @userConfig[k] == v 321 | continue if updateOnly and @c[k] == nil 322 | -- TODO: deep-compare tables 323 | isTable = type(v) == "table" 324 | if isTable and skipSameLengthTables and type(@userConfig[k]) == "table" and #v == #@userConfig[k] 325 | continue 326 | continue if type(k) == "string" and k\sub(1,1) == "_" 327 | @userConfig[k] = isTable and @deepCopy(v) or v 328 | changesMade = true 329 | 330 | return changesMade -------------------------------------------------------------------------------- /modules/DependencyControl/FileOps.moon: -------------------------------------------------------------------------------- 1 | ffi = require "ffi" 2 | re = require "aegisub.re" 3 | lfs = require "lfs" 4 | 5 | Logger = require "l0.DependencyControl.Logger" 6 | local ConfigHandler 7 | 8 | class FileOps 9 | msgs = { 10 | generic: { 11 | deletionRescheduled: "Another deletion attempt has been rescheduled for the next restart." 12 | } 13 | attributes: { 14 | badPath: "Path failed verification: %s." 15 | genericError: "Can't retrieve attributes: %s." 16 | noAttribute: "Can't find attriubte with name '%s'." 17 | } 18 | 19 | mkdir: { 20 | createError: "Error creating directory: %s." 21 | otherExists: "Couldn't create directory because a %s of the same name is already present." 22 | } 23 | copy: { 24 | targetExists: "Target file '%s' already exists" 25 | genericError: "An error occured while copying file '%s' to '%s':\n%s" 26 | dirCopyUnsupported: "Copying directories is currently not supported." 27 | missingSource: "Couldn't find source file '%s'." 28 | openError: "Couldn't open %s file '%s' for reading: \n%s" 29 | } 30 | move: { 31 | inUseTryingRename: "Target file '%s' already exists and appears to be in use. Trying to rename and delete existing file..." 32 | renamedDeletionFailed: "The existing file was successfully renamed to '%s', but couldn't be deleted (%s).\n%s" 33 | overwritingFile: "File '%s' already exists, overwriting..." 34 | createdDir: "Created target directory '%s'." 35 | exists: "Couldn't move file '%s' to '%s' because a %s of the same name is already present." 36 | genericError: "An error occured while moving file '%s' to '%s':\n%s" 37 | createDirError: "Moving '%s' to '%s' failed (%s)." 38 | cantRemove: "Couldn't overwrite file '%s': %s. Attempts at renaming the existing target file failed." 39 | cantRenameTryingCopy: "Move operation failed to rename '%s' to '%s' (%s), trying copy+remove instead..." 40 | couldntRemoveFiles: "Move operation suceeded to copied the file(s) to the target location, but some of the source files couldn't be removed:\n%s\n%s" 41 | cantCopy: "Move operation failed to copy '%s' to '%s' (%s) after a failed rename attempt (%s)." 42 | } 43 | rmdir: { 44 | emptyPath: "Argument #1 (path) must not be an empty string." 45 | couldntRemoveFiles: "Some of the files and folders in the specified directory couldn't be removed:\n%s" 46 | couldntRemoveDir: "Error removing empty directory: %s." 47 | 48 | } 49 | validateFullPath: { 50 | badType: "Argument #1 (path) had the wrong type. Expected 'string', got '%s'." 51 | tooLong: "The specified path exceeded the maximum length limit (%d > %d)." 52 | invalidChars: "The specifed path contains one or more invalid characters: '%s'." 53 | reservedNames: "The specified path contains reserved path or file names: '%s'." 54 | parentPath: "Accessing parent directories is not allowed." 55 | notFullPath: "The specified path is not a valid full path." 56 | missingExt: "The specified path is missing a file extension." 57 | } 58 | } 59 | 60 | devPattern = ffi.os == "Windows" and "[A-Za-z]:" or "/[^\\\\/]+" 61 | pathMatch = { 62 | sep: ffi.os == "Windows" and "\\" or "/" 63 | pattern: re.compile "^(#{devPattern})((?:[\\\\/][^\\\\/]*[^\\\\/\\s\\.])*)[\\\\/]([^\\\\/]*[^\\\\/\\s\\.])?$" 64 | invalidChars: '[<>:"|%?%*%z%c;]' 65 | reservedNames: re.compile "[\\\\/](CON|COM[1-9]|PRN|AUX|NUL|LPT[1-9])(?:[\\\\/].*?)?$", re.ICASE 66 | maxLen: 255 67 | } 68 | @logger = Logger! 69 | 70 | createConfig = (noLoad, configDir) -> 71 | FileOps.configDir = configDir if configDir 72 | ConfigHandler or= require "l0.DependencyControl.ConfigHandler" 73 | FileOps.config or= ConfigHandler "#{FileOps.configDir}/l0.#{FileOps.__name}.json", 74 | {toRemove: {}}, nil, noLoad, FileOps.logger 75 | return FileOps.config 76 | 77 | remove: (paths, recurse, reSchedule) -> 78 | config = createConfig true 79 | configLoaded, overallSuccess, details, firstErr = false, true, {} 80 | paths = {paths} unless type(paths) == "table" 81 | 82 | for path in *paths 83 | mode, path = FileOps.attributes path, "mode" 84 | if mode 85 | rmFunc = mode == "file" and os.remove or FileOps.rmdir 86 | res, err = rmFunc path, recurse 87 | unless res 88 | firstErr or= err 89 | unless reSchedule -- delete operation failed entirely 90 | details[path] = {nil, err} 91 | overallSuccess = nil 92 | continue 93 | 94 | -- load the FileOps configuration file and reschedule deletions 95 | unless configLoaded 96 | FileOps.config\load! 97 | configLoaded = true 98 | config.c.toRemove[path] = os.time! 99 | -- mark the operations as failed "for now", indicating a second attempt has been scheduled 100 | details[path] = {false, err} 101 | overallSuccess = false 102 | 103 | -- delete operation succeeded 104 | else details[path] = {true} 105 | -- file not found or permission issue 106 | else details[path] = {nil, path} 107 | 108 | config\write! if configLoaded 109 | return overallSuccess, details, firstErr 110 | 111 | runScheduledRemoval: (configDir) -> 112 | config = createConfig false, configDir 113 | paths = [path for path, _ in pairs config.c.toRemove] 114 | if #paths > 0 115 | -- rescheduled removals will not be rescheduled another time 116 | FileOps.remove paths, true 117 | config.c.toRemove = {} 118 | config\write! 119 | return true 120 | 121 | copy: ( source, target ) -> 122 | -- source check 123 | mode, sourceFullPath, _, _, fileName = FileOps.attributes source, "mode" 124 | switch mode 125 | when "directory" 126 | return false, msgs.copy.dirCopyUnsupported 127 | when nil 128 | return false, msgs.copy.genericError\format source, target, sourceFullPath 129 | when false 130 | return false, msgs.copy.missingSource\format source 131 | 132 | -- target check 133 | checkTarget = (target) -> 134 | mode, targetFullPath = FileOps.attributes target, "mode" 135 | switch mode 136 | when "file" 137 | return false, msgs.copy.targetExists\format target 138 | when nil 139 | return false, msgs.copy.genericError\format source, target, targetFullPath 140 | when "directory" 141 | target ..= "/#{fileName}" 142 | return checkTarget target 143 | return true, targetFullPath 144 | 145 | success, targetFullPath = checkTarget target 146 | return false, targetFullPath unless success 147 | 148 | input, msg = io.open sourceFullPath, "rb" 149 | unless input 150 | return false, msgs.copy.openError\format "source", sourceFullPath, msg 151 | 152 | output, msg = io.open targetFullPath, "wb" 153 | unless output 154 | input\close! 155 | return false, msgs.copy.openError\format "target", targetFullPath, msg 156 | 157 | success, msg = output\write input\read "*a" 158 | input\close! 159 | output\close! 160 | 161 | if success 162 | return true 163 | else 164 | return false, msgs.copy.genericError\format sourceFullPath, targetFullPath, msg 165 | 166 | 167 | move: (source, target, overwrite) -> 168 | mode, err = FileOps.attributes target, "mode" 169 | if mode == "file" 170 | unless overwrite 171 | return false, msgs.move.exists\format source, target, mode 172 | FileOps.logger\trace msgs.move.overwritingFile, target 173 | res, _, err = FileOps.remove target 174 | unless res 175 | -- can't remove old target file, probably in use or lack of permissions 176 | -- try to rename and then delete it 177 | FileOps.logger\debug msgs.move.inUseTryingRename, target 178 | junkName = "#{target}.depCtrlRemoved" 179 | -- There might be an old removed file we couldn't delete before 180 | FileOps.remove junkName 181 | res = os.rename target, junkName 182 | unless res 183 | return false, msgs.move.cantRemove\format target, err 184 | -- rename succeeded, now clean up after ourselves 185 | res, _, err = FileOps.remove junkName, false, true 186 | unless res 187 | FileOps.logger\debug msgs.move.renamedDeletionFailed, junkName, err, msgs.generic.deletionRescheduled 188 | 189 | elseif mode -- a directory (or something else) of the same name as the target file is already present 190 | return false, msgs.move.exists\format source, target, mode 191 | elseif mode == nil -- if retrieving the attributes of a file fails, something is probably wrong 192 | return false, msgs.move.genericError\format source, target, err 193 | 194 | else -- target file not found, check directory 195 | res, dir = FileOps.mkdir target, true 196 | if res == nil 197 | return false, msgs.move.createDirError\format source, target, err 198 | elseif res 199 | FileOps.logger\trace msgs.move.createdDir, dir 200 | 201 | -- at this point the target directory exists and the target file doesn't, move the file 202 | res, err = os.rename source, target 203 | unless res 204 | -- renaming the file failed, could be because of a permission issue 205 | -- but me might a well be trying to rename over file system boundaries on *nix 206 | -- so we should try copy + remove before giving up 207 | FileOps.logger\debug msgs.move.cantRenameTryingCopy, source, target, err 208 | renErr, res, err = err, FileOps.copy source, target 209 | unless res 210 | return false, msgs.move.cantCopy\format source, target, err, renErr 211 | res, details = FileOps.remove source, false, true -- TODO: also support directories/recursion, but also require copy to support it 212 | 213 | unless res 214 | fileList = table.concat ["#{path}: #{res[2]}" for path, res in pairs details when not res[1]], "\n" 215 | FileOps.logger\debug msgs.move.couldntRemoveFiles, fileList, msgs.generic.deletionRescheduled 216 | 217 | return true 218 | 219 | rmdir: (path, recurse = true) -> 220 | return nil, msgs.rmdir.emptyPath if path == "" 221 | mode, path = FileOps.attributes path, "mode" 222 | return nil, msgs.rmdir.notPath unless mode == "directory" 223 | 224 | if recurse 225 | -- recursively remove contained files and directories 226 | toRemove = ["#{path}/#{file}" for file in lfs.dir path] 227 | res, details = FileOps.remove toRemove, true 228 | unless res 229 | fileList = table.concat ["#{path}: #{res[2]}" for path, res in pairs details when not res[1]], "\n" 230 | return nil, msgs.rmdir.couldntRemoveFiles\format fileList 231 | 232 | -- remove empty directory 233 | success, err = lfs.rmdir path 234 | unless success 235 | return nil, msgs.rmdir.couldntRemoveDir\format err 236 | 237 | return true 238 | 239 | mkdir: (path, isFile) -> 240 | mode, fullPath, dev, dir, file = FileOps.attributes path, "mode" 241 | dir = isFile and table.concat({dev,dir or file}) or fullPath 242 | 243 | if mode == nil 244 | return nil, msgs.attributes.genericError\format fullPath 245 | elseif not mode 246 | res, err = lfs.mkdir dir 247 | if err -- can't create directory (possibly a permission error) 248 | return nil, msgs.mkdir.createError\format err 249 | return true, dir 250 | elseif isFile and mode == "file" -- if the file already exists, so does the directory 251 | return false, dir 252 | elseif mode != "directory" -- a file of the same name as the target directory is already present 253 | return nil, msgs.mkdir.otherExists\format mode 254 | return false, dir 255 | 256 | attributes: (path, key) -> 257 | fullPath, dev, dir, file = FileOps.validateFullPath path 258 | unless fullPath 259 | path = "#{lfs.currentdir!}/#{path}" 260 | fullPath, dev, dir, file = FileOps.validateFullPath path 261 | unless fullPath 262 | return nil, msgs.attributes.badPath\format dev 263 | 264 | attr, err = lfs.attributes fullPath, key 265 | if err 266 | return nil, msgs.attributes.genericError\format err 267 | elseif not attr 268 | return false, fullPath, dev, dir, file 269 | 270 | return attr, fullPath, dev, dir, file 271 | 272 | validateFullPath: (path, checkFileExt) -> 273 | if type(path) != "string" 274 | return nil, msgs.validateFullPath.badType\format type(path) 275 | -- expand aegisub path specifiers 276 | path = aegisub.decode_path path 277 | -- expand home directory on linux 278 | homeDir = os.getenv "HOME" 279 | path = path\gsub "^~", "{#homeDir}/" if homeDir 280 | -- use single native path separators 281 | path = path\gsub "[\\/]+", pathMatch.sep 282 | -- check length 283 | if #path > pathMatch.maxLen 284 | return false, msgs.validateFullPath.tooLong\format #path, pathMatch.maxLen 285 | -- check for invalid characters 286 | invChar = path\match pathMatch.invalidChars, ffi.os == "Windows" and 3 or nil 287 | if invChar 288 | return false, msgs.validateFullPath.invalidChars\format invChar 289 | -- check for reserved file names 290 | reserved = pathMatch.reservedNames\match path 291 | if reserved 292 | return false, msgs.validateFullPath.reservedNames\format reserved[2].str 293 | -- check for path escalation 294 | if path\match "%.%." 295 | return false, msgs.validateFullPath.parentPath 296 | 297 | -- check if we got a valid full path 298 | matches = pathMatch.pattern\match path 299 | dev, dir, file = matches[2].str, matches[3].str, matches[4].str if matches 300 | unless dev 301 | return false, msgs.validateFullPath.notFullPath 302 | if checkFileExt and not (file and file\match ".+%.+") 303 | return false, msgs.validateFullPath.missingExt 304 | 305 | path = table.concat({dev, dir, file and pathMatch.sep, file}) 306 | 307 | return path, dev, dir, file -------------------------------------------------------------------------------- /modules/DependencyControl/Logger.moon: -------------------------------------------------------------------------------- 1 | PreciseTimer = require "PT.PreciseTimer" 2 | lfs = require "lfs" 3 | 4 | class Logger 5 | levels = {"fatal", "error", "warning", "hint", "debug", "trace"} 6 | defaultLevel: 2 7 | maxToFileLevel: 5 8 | fileBaseName: script_namespace or "UNKNOWN" 9 | fileSubName: "" 10 | logDir: "?user/log" 11 | fileTemplate: "%s/%s-%04x_%s_%s.log" 12 | fileMatchTemplate: "%d%d%d%d%-%d%d%-%d%d%-%d%d%-%d%d%-%d%d%-%x%x%x%x_@{fileBaseName}_?.*%.log$" 13 | prefix: "" 14 | toFile: false, toWindow: true 15 | indent: 0 16 | usePrefixFile: true 17 | usePrefixWindow: true 18 | indentStr: "—" 19 | maxFiles: 200, maxAge: 604800, maxSize:10*(10^6) 20 | 21 | timer, seeded = PreciseTimer!, false 22 | 23 | new: (args) => 24 | if args 25 | @[k] = v for k, v in pairs args 26 | if args.usePrefix ~= nil 27 | @usePrefixFile, @usePrefixWindow = args.usePrefix 28 | 29 | -- scripts are loaded simultaneously, so we need to avoid seeding the rng with the same time 30 | unless seeded 31 | timer.sleep 10 for i=1,50 32 | math.randomseed(timer\timeElapsed!*1000000) 33 | math.random, math.random, math.random 34 | seeded = true 35 | -- timer gets freed on garbage collection 36 | timer = nil 37 | 38 | @lastHadLineFeed = true 39 | escaped = @fileBaseName\gsub("([%%%(%)%[%]%.%*%-%+%?%$%^])","%%%1") 40 | @fileMatch = @fileMatchTemplate\gsub "@{fileBaseName}", escaped 41 | @fileName = @fileTemplate\format aegisub.decode_path(@logDir), os.date("%Y-%m-%d-%H-%M-%S"), 42 | math.random(0, 16^4-1), @fileBaseName, @fileSubName 43 | 44 | logEx: (level = @defaultLevel, msg = "", insertLineFeed = true, prefix = @prefix, indent = @indent, ...) => 45 | return false if msg == "" 46 | 47 | prefixWin = @usePrefixWindow and prefix or "" 48 | lineFeed = insertLineFeed and "\n" or "" 49 | indentStr = indent==0 and "" or @indentStr\rep(indent) .. " " 50 | 51 | msg = if @lastHadLineFeed 52 | @format msg, indent, ... 53 | elseif 0 < select "#", ... 54 | msg\format ... 55 | 56 | show = aegisub.log and @toWindow 57 | if @toFile and level <= @maxToFileLevel 58 | @handle = io.open(@fileName, "a") unless @handle 59 | linePre = @lastHadLineFeed and "#{indentStr}[#{levels[level+1]\upper!}] #{os.date '%H:%M:%S'} #{show and '+' or '•'} " or "" 60 | line = table.concat({linePre, @usePrefixFile and prefix or "", msg, lineFeed}) 61 | @handle\write(line)\flush! 62 | 63 | -- for some reason the stack trace gets swallowed when not doing the replace 64 | assert level > 1,"#{indentStr}Error: #{prefixWin}#{msg\gsub ':', ': '}" 65 | if show 66 | aegisub.log level, table.concat({indentStr, prefixWin, msg, lineFeed}) 67 | 68 | @lastHadLineFeed = insertLineFeed 69 | return true 70 | 71 | format: (msg, indent, ...) => 72 | if type(msg) == "table" 73 | msg = table.concat msg, "\n" 74 | 75 | if 0 < select "#", ... 76 | msg = msg\format ... 77 | 78 | return msg unless indent>0 79 | 80 | indentRep = @indentStr\rep(indent) 81 | indentStr = indentRep .. " " 82 | -- indent after line breaks and connect indentation supplied in the user message 83 | return msg\gsub("\n", "\n"..indentStr)\gsub "\n#{indentStr}(#{@indentStr})", "\n#{indentRep}%1" 84 | 85 | log: (level, msg, ...) => 86 | return false unless level or msg 87 | 88 | if "number" != type level 89 | return @logEx @defaultLevel, level, true, nil, nil, msg, ... 90 | else return @logEx level, msg, true, nil, nil, ... 91 | 92 | fatal: (...) => @log 0, ... 93 | error: (...) => @log 1, ... 94 | warn: (...) => @log 2, ... 95 | hint: (...) => @log 3, ... 96 | debug: (...) => @log 4, ... 97 | trace: (...) => @log 5, ... 98 | 99 | assert: (cond, ...) => 100 | if not cond 101 | @log 1, ... 102 | else return cond 103 | 104 | progress: (progress=false, msg = "", ...) => 105 | if @progressStep and not progress 106 | @logEx nil, "■"\rep(10-@progressStep).."]", true, "" 107 | @progressStep = nil 108 | elseif progress 109 | unless @progressStep 110 | @progressStep = 0 111 | msg ..= " " if #msg>0 112 | @logEx nil, "#{msg}[", false, nil, nil, ... 113 | step = math.floor(progress * 0.01 + 0.5) / 0.1 114 | @logEx nil, "■"\rep(step-@progressStep), false, "" 115 | @progressStep = step 116 | 117 | -- taken from https://github.com/TypesettingCartel/Aegisub-Motion/blob/master/src/Log.moon 118 | dump: ( item, ignore, level = @defaultLevel ) => 119 | @log level, @dumpToString item, ignore 120 | 121 | dumpToString: ( item, ignore ) => 122 | if "table" != type item 123 | return tostring item 124 | 125 | count, tablecount = 1, 1 126 | 127 | result = { "{ @#{tablecount}" } 128 | seen = { [item]: tablecount } 129 | recurse = ( item, space ) -> 130 | for key, value in pairs item 131 | unless key == ignore 132 | if "number" == type key 133 | key = "##{key}" 134 | if "table" == type value 135 | unless seen[value] 136 | tablecount += 1 137 | seen[value] = tablecount 138 | count += 1 139 | result[count] = space .. "#{key}: { @#{tablecount}" 140 | recurse value, space .. " " 141 | count += 1 142 | result[count] = space .. "}" 143 | else 144 | count += 1 145 | result[count] = space .. "#{key}: @#{seen[value]}" 146 | 147 | else 148 | if "string" == type value 149 | value = ("%q")\format value 150 | 151 | count += 1 152 | result[count] = space .. "#{key}: #{value}" 153 | 154 | recurse item, " " 155 | result[count+1] = "}" 156 | 157 | return table.concat(result, "\n")\gsub "%%", "%%%%" 158 | 159 | windowError: ( errorMessage ) -> 160 | aegisub.dialog.display { { class: "label", label: errorMessage } }, { "&Close" }, { cancel: "&Close" } 161 | aegisub.cancel! 162 | 163 | 164 | trimFiles: (doWipe, maxAge = @maxAge, maxSize = @maxSize, maxFiles = @maxFiles) => 165 | files, totalSize, deletedSize, now, f = {}, 0, 0, os.time!, 0 166 | 167 | dir = aegisub.decode_path @logDir 168 | lfs.chdir dir 169 | for file in lfs.dir dir 170 | attr = lfs.attributes file 171 | if type(attr) == "table" and attr.mode == "file" and file\find @fileMatch 172 | f += 1 173 | files[f] = {name:file, modified:attr.modification, size:attr.size} 174 | 175 | table.sort files, (a,b) -> a.modified > b.modified 176 | total, kept = #files, 0 177 | 178 | for i, file in ipairs files 179 | totalSize += file.size 180 | if doWipe or kept > maxFiles or totalSize > maxSize or file.modified+maxAge < now 181 | deletedSize += file.size 182 | os.remove file.name 183 | else 184 | kept += 1 185 | return total-kept, deletedSize, total, totalSize 186 | -------------------------------------------------------------------------------- /modules/DependencyControl/ModuleLoader.moon: -------------------------------------------------------------------------------- 1 | -- Note: this is a private API intended to be exclusively for internal DependenyControl use 2 | -- Everyting in this class can and will change without any prior notice 3 | -- and calling any method is guaranteed to interfere with DepdencyControl operation 4 | 5 | class ModuleLoader 6 | msgs = { 7 | checkOptionalModules: { 8 | downloadHint: "Please download the modules in question manually, put them in your %s folder and reload your automation scripts." 9 | missing: "Error: a %s feature you're trying to use requires additional modules that were not found on your system:\n%s\n%s" 10 | } 11 | formatVersionErrorTemplate: { 12 | missing: "— %s %s%s\n—— Reason: %s" 13 | outdated: "— %s (Installed: v%s; Required: v%s)%s\n—— Reason: %s" 14 | } 15 | loadModules: { 16 | missing: "Error: one or more of the modules required by %s could not be found on your system:\n%s\n%s" 17 | missingRecord: "Error: module '%s' is missing a version record." 18 | moduleError: "Error in required module %s:\n%s" 19 | outdated: [[Error: one or more of the modules required by %s are outdated on your system: 20 | %s\nPlease update the modules in question manually and reload your automation scripts.]] 21 | } 22 | } 23 | 24 | @formatVersionErrorTemplate = (name, reqVersion, url, reason, ref) => 25 | url = url and ": #{url}" or "" 26 | if ref 27 | version = @@parseVersion ref.version 28 | return msgs.formatVersionErrorTemplate.outdated\format name, version, reqVersion, url, reason 29 | else 30 | reqVersion = reqVersion and " (v#{reqVersion})" or "" 31 | return msgs.formatVersionErrorTemplate.missing\format name, reqVersion, url, reason 32 | 33 | @createDummyRef = => 34 | return nil if @scriptType != @@ScriptType.Module 35 | -- global module registry allows for circular dependencies: 36 | -- set a dummy reference to this module since this module is not ready 37 | -- when the other one tries to load it (and vice versa) 38 | export LOADED_MODULES = {} unless LOADED_MODULES 39 | unless LOADED_MODULES[@namespace] 40 | @ref = {} 41 | LOADED_MODULES[@namespace] = setmetatable {__depCtrlDummy: true, version: @}, @ref 42 | return true 43 | return false 44 | 45 | @removeDummyRef = => 46 | return nil if @scriptType != @@ScriptType.Module 47 | if LOADED_MODULES[@namespace] and LOADED_MODULES[@namespace].__depCtrlDummy 48 | LOADED_MODULES[@namespace] = nil 49 | return true 50 | return false 51 | 52 | @loadModule = (mdl, usePrivate, reload) => 53 | runInitializer = (ref) -> 54 | return unless type(ref) == "table" and ref.__depCtrlInit 55 | -- Note to future self: don't change this to a class check! When DepCtrl self-updates 56 | -- any managed module initialized before will still use the same instance 57 | if type(ref.version) != "table" or ref.version.__name != @@__name 58 | ref.__depCtrlInit @@ 59 | 60 | with mdl 61 | ._missing, ._error = nil 62 | 63 | moduleName = usePrivate and "#{@namespace}.#{mdl.moduleName}" or .moduleName 64 | name = "#{mdl.name or mdl.moduleName}#{usePrivate and ' (Private Copy)' or ''}" 65 | 66 | if .outdated or reload 67 | -- clear old references 68 | package.loaded[moduleName], LOADED_MODULES[moduleName] = nil 69 | 70 | elseif ._ref = LOADED_MODULES[moduleName] 71 | -- module is already loaded, however it may or may not have been loaded by DepCtrl 72 | -- so we have to call any DepCtrl initializer if it hasn't been called yet 73 | runInitializer ._ref 74 | return ._ref 75 | 76 | loaded, res = xpcall require, debug.traceback, moduleName 77 | unless loaded 78 | LOADED_MODULES[moduleName] = nil 79 | res or= "unknown error" 80 | ._missing = res\match "module '.+' not found:" 81 | ._error = res unless ._missing 82 | return nil 83 | 84 | -- set new references 85 | if reload and ._ref and ._ref.__depCtrlDummy 86 | setmetatable ._ref, res 87 | ._ref, LOADED_MODULES[moduleName] = res, res 88 | 89 | -- run DepCtrl initializer if one was specified 90 | runInitializer res 91 | 92 | return mdl._ref -- having this in the with block breaks moonscript 93 | 94 | @loadModules = (modules, addFeeds = {@feed}, skip = @moduleName and {[@moduleName]: true} or {}) => 95 | for mdl in *modules 96 | continue if skip[mdl] 97 | with mdl 98 | ._ref, ._updated, ._missing, ._outdated, ._reason, ._error = nil 99 | 100 | -- try to load private copies of required modules first 101 | ModuleLoader.loadModule @, mdl, true 102 | ModuleLoader.loadModule @, mdl unless ._ref 103 | 104 | -- try to fetch and load a missing module from the web 105 | if ._missing 106 | record = @@{moduleName:.moduleName, name:.name or .moduleName, 107 | version:-1, url:.url, feed:.feed, virtual:true} 108 | ._ref, code, extErr = @@updater\require record, .version, addFeeds, .optional 109 | if ._ref or .optional 110 | ._updated, ._missing = true, false 111 | else 112 | ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, true, extErr 113 | -- nuke dummy reference for circular dependencies 114 | LOADED_MODULES[.moduleName] = nil 115 | 116 | -- check if the version requirements are satisfied 117 | -- which is guaranteed for modules updated with \require, so we don't need to check again 118 | if .version and ._ref and not ._updated 119 | record = ._ref.version 120 | unless record 121 | ._error = msgs.loadModules.missingRecord\format .moduleName 122 | continue 123 | 124 | if type(record) != "table" or record.__class != @@ 125 | record = @@ moduleName: .moduleName, version: record, recordType: @@RecordType.Unmanaged 126 | 127 | -- force an update for outdated modules 128 | if not record\checkVersion .version 129 | ref, code, extErr = @@updater\require record, .version, addFeeds 130 | if ref 131 | ._ref = ref 132 | elseif not .optional 133 | ._outdated = true 134 | ._reason = @@updater\getUpdaterErrorMsg code, .name or .moduleName, true, false, extErr 135 | else 136 | -- perform regular update check if we can get a lock without waiting 137 | -- right now we don't care about the result and don't reload the module 138 | -- so the update will not be effective until the user restarts Aegisub 139 | -- or reloads the script 140 | @@updater\scheduleUpdate record 141 | 142 | missing, outdated, moduleError = {}, {}, {} 143 | for mdl in *modules 144 | with mdl 145 | name = .name or .moduleName 146 | if ._missing 147 | missing[#missing+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason 148 | elseif ._outdated 149 | outdated[#outdated+1] = ModuleLoader.formatVersionErrorTemplate @, name, .version, .url, ._reason, ._ref 150 | elseif ._error 151 | moduleError[#moduleError+1] = msgs.loadModules.moduleError\format name, ._error 152 | 153 | errorMsg = {} 154 | if #moduleError > 0 155 | errorMsg[1] = table.concat moduleError, "\n" 156 | if #outdated > 0 157 | errorMsg[#errorMsg+1] = msgs.loadModules.outdated\format @name, table.concat outdated, "\n" 158 | if #missing > 0 159 | errorMsg[#errorMsg+1] = msgs.loadModules.missing\format @name, table.concat(missing, "\n"), downloadHint 160 | 161 | return #errorMsg == 0, table.concat(errorMsg, "\n\n") 162 | 163 | @checkOptionalModules = (modules) => 164 | modules = type(modules)=="string" and {[modules]:true} or {mdl,true for mdl in *modules} 165 | missing = [ModuleLoader.formatVersionErrorTemplate @, mdl.moduleName, mdl.version, msl.url, 166 | mdl._reason for mdl in *@requiredModules when mdl.optional and mdl._missing and modules[mdl.name]] 167 | 168 | if #missing>0 169 | downloadHint = msgs.checkOptionalModules.downloadHint\format @@automationDir.modules 170 | errorMsg = msgs.checkOptionalModules.missing\format @name, table.concat(missing, "\n"), downloadHint 171 | return false, errorMsg 172 | return true -------------------------------------------------------------------------------- /modules/DependencyControl/Record.moon: -------------------------------------------------------------------------------- 1 | json = require "json" 2 | lfs = require "lfs" 3 | re = require "aegisub.re" 4 | 5 | Common = require "l0.DependencyControl.Common" 6 | Logger = require "l0.DependencyControl.Logger" 7 | ConfigHandler = require "l0.DependencyControl.ConfigHandler" 8 | FileOps = require "l0.DependencyControl.FileOps" 9 | Updater = require "l0.DependencyControl.Updater" 10 | ModuleLoader = require "l0.DependencyControl.ModuleLoader" 11 | SemanticVersioning = require "l0.DependencyControl.SemanticVersioning" 12 | 13 | class Record extends Common 14 | namespaceValidation = re.compile "^(?:[-\\w]+\\.)+[-\\w]+$" 15 | 16 | msgs = { 17 | new: { 18 | badRecordError: "Error: Bad #{@@__name} record (%s)." 19 | badRecord: { 20 | noUnmanagedMacros: "Creating unmanaged version records for macros is not allowed" 21 | missingNamespace: "No namespace defined" 22 | badVersion: "Couldn't parse version number: %s" 23 | 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-_]." 24 | badModuleTable: "Invalid required module table #%d (%s)." 25 | } 26 | } 27 | uninstall: { 28 | noVirtualOrUnmanaged: "Can't uninstall %s %s '%s'. (Only installed scripts managed by #{@@__name} can be uninstalled)." 29 | } 30 | writeConfig: { 31 | error: "An error occured while writing the #{@@__name} config file: %s" 32 | writing: "Writing updated %s data to config file..." 33 | } 34 | } 35 | 36 | @depConf = { 37 | file: aegisub.decode_path "?user/config/l0.#{@@__name}.json", 38 | scriptFields: {"author", "configFile", "feed", "moduleName", "name", "namespace", "url", -- REMOVE 39 | "requiredModules", "version", "unmanaged"}, 40 | globalDefaults: {updaterEnabled:true, updateInterval:302400, traceLevel:3, extraFeeds:{}, 41 | tryAllFeeds:false, dumpFeeds:true, configDir:"?user/config", 42 | logMaxFiles: 200, logMaxAge: 604800, logMaxSize:10*(10^6), 43 | updateWaitTimeout: 60, updateOrphanTimeout: 600, 44 | logDir: "?user/log", writeLogs: true} 45 | } 46 | 47 | init = => 48 | FileOps.mkdir @depConf.file, true 49 | @loadConfig! 50 | @logger = Logger { fileBaseName: "DepCtrl", fileSubName: script_namespace, prefix: "[#{@@__name}] ", 51 | toFile: @config.c.writeLogs, defaultLevel: @config.c.traceLevel, 52 | maxAge: @config.c.logMaxAge,maxSize: @config.c.logMaxSize, maxFiles: @config.c.logMaxFiles, 53 | logDir: @config.c.logDir } 54 | 55 | @updater = Updater script_namespace, @config, @logger 56 | @configDir = @config.c.configDir 57 | 58 | FileOps.mkdir aegisub.decode_path @configDir 59 | logsHaveBeenTrimmed or= @logger\trimFiles! 60 | FileOps.runScheduledRemoval @configDir 61 | 62 | 63 | new: (args) => 64 | init Record unless @@logger 65 | 66 | -- defaults 67 | args[k] = v for k, v in pairs { 68 | readGlobalScriptVars: true 69 | saveRecordToConfig: true 70 | } when args[k] == nil 71 | 72 | {@requiredModules, moduleName:@moduleName, configFile:configFile, virtual:@virtual, :name, 73 | description:@description, url:@url, feed:@feed, recordType:@recordType, :namespace, 74 | author:@author, :version, configFile:@configFile, 75 | :readGlobalScriptVars, :saveRecordToConfig} = args 76 | 77 | @recordType or= @@RecordType.Managed 78 | -- also support name key (as used in configuration) for required modules 79 | @requiredModules or= args.requiredModules 80 | 81 | if @moduleName 82 | @namespace = @moduleName 83 | @name = name or @moduleName 84 | @scriptType = @@ScriptType.Module 85 | ModuleLoader.createDummyRef @ unless @virtual or @recordType == @@RecordType.Unmanaged 86 | 87 | else 88 | if @virtual or not readGlobalScriptVars 89 | @name = name or namespace 90 | @namespace = namespace 91 | version or= 0 92 | else 93 | @name = name or script_name 94 | @description or= script_description 95 | @author or= script_author 96 | version or= script_version 97 | 98 | @namespace = namespace or script_namespace 99 | assert @recordType == @@RecordType.Managed, msgs.new.badRecordError\format msgs.new.badRecord.noUnmanagedMacros 100 | assert @namespace, msgs.new.badRecordError\format msgs.new.badRecord.missingNamespace 101 | @scriptType = @@ScriptType.Automation 102 | 103 | -- if the hosting macro doesn't have a namespace defined, define it for 104 | -- the first DepCtrled module loaded by the macro or its required modules 105 | unless script_namespace 106 | export script_namespace = @namespace 107 | 108 | -- non-depctrl record don't need to conform to namespace rules 109 | assert @virtual or @recordType == @@RecordType.Unmanaged or @validateNamespace!, 110 | msgs.new.badRecord.badNamespace\format @namespace 111 | 112 | @configFile = configFile or "#{@namespace}.json" 113 | @automationDir = @@automationDir[@scriptType] 114 | @testDir = @@testDir[@scriptType] 115 | @version, err = @@parseVersion version 116 | assert @version, msgs.new.badRecordError\format msgs.new.badRecord.badVersion\format err 117 | 118 | @requiredModules or= {} 119 | -- normalize short format module tables 120 | for i, mdl in pairs @requiredModules 121 | switch type mdl 122 | when "table" 123 | mdl.moduleName or= mdl[1] 124 | mdl[1] = nil 125 | when "string" 126 | @requiredModules[i] = {moduleName: mdl} 127 | else error msgs.new.badRecordError\format msgs.new.badRecord.badModuleTable\format i, tostring mdl 128 | 129 | shouldWriteConfig = @loadConfig! 130 | 131 | -- write config file if contents are missing or are out of sync with the script version record 132 | -- ramp up the random wait time on first initialization (many scripts may want to write configuration data) 133 | -- we can't really profit from write concerting here because we don't know which module loads last 134 | @writeConfig if shouldWriteConfig and saveRecordToConfig 135 | 136 | checkOptionalModules: ModuleLoader.checkOptionalModules 137 | 138 | -- loads the DependencyControl global configuration 139 | @loadConfig = => 140 | if @config 141 | @config\load! 142 | else @config = ConfigHandler @depConf.file, @depConf.globalDefaults, {"config"}, nil, @logger 143 | 144 | -- loads the script configuration 145 | loadConfig: (importRecord = false) => 146 | -- virtual modules are not yet present on the user's system and have no persistent configuration 147 | @config or= ConfigHandler not @virtual and @@depConf.file, {}, 148 | { @@ScriptType.name.legacy[@scriptType], @namespace }, true, @@logger 149 | 150 | -- import and overwrites version record from the configuration 151 | if importRecord 152 | -- check if a module that was previously virtual was installed in the meantime 153 | -- TODO: prevent issues caused by orphaned config entries 154 | haveConfig = false 155 | if @virtual 156 | @config\setFile @@depConf.file 157 | if @config\load! 158 | haveConfig, @virtual = true, false 159 | else @config\unsetFile! 160 | else 161 | haveConfig = @config\load! 162 | 163 | -- only need to refresh data if the record was changed by an update 164 | if haveConfig 165 | @[key] = @config.c[key] for key in *@@depConf.scriptFields 166 | 167 | elseif not @virtual 168 | -- copy script information to the config 169 | @config\load! 170 | shouldWriteConfig = @config\import @, @@depConf.scriptFields, false, true 171 | return shouldWriteConfig 172 | 173 | return false 174 | 175 | writeConfig: => 176 | unless @virtual or @config.file 177 | @config\setFile @@depConf.file 178 | 179 | @@logger\trace msgs.writeConfig.writing, @@terms.scriptType.singular[@scriptType] 180 | @config\import @, @@depConf.scriptFields, false, true 181 | success, errMsg = @config\write false 182 | 183 | assert success, msgs.writeConfig.error\format errMsg 184 | 185 | 186 | @parseVersion = SemanticVersioning.parse 187 | 188 | 189 | @getVersionString = SemanticVersioning.toString 190 | 191 | 192 | getConfigFileName: () => 193 | return aegisub.decode_path "#{@@configDir}/#{@configFile}" 194 | 195 | getConfigHandler: (defaults, section, noLoad) => 196 | return ConfigHandler @getConfigFileName!, defaults, section, noLoad 197 | 198 | getLogger: (args = {}) => 199 | args.fileBaseName or= @namespace 200 | args.toFile = @config.c.logToFile if args.toFile == nil 201 | args.defaultLevel or= @config.c.logLevel 202 | args.prefix or= @moduleName and "[#{@name}]" 203 | 204 | return Logger args 205 | 206 | checkVersion: (value, precision = "patch") => 207 | if type(value) == "table" and value.__class == @@ 208 | value = value.version 209 | return SemanticVersioning\check @version, value 210 | 211 | 212 | getSubmodules: => 213 | return nil if @virtual or @recordType == @@RecordType.Unmanaged or @scriptType != @@ScriptType.Module 214 | mdlConfig = @@config\getSectionHandler @@ScriptType.name.legacy[@@ScriptType.Module] 215 | pattern = "^#{@namespace}."\gsub "%.", "%%." 216 | return [mdl for mdl, _ in pairs mdlConfig.c when mdl\match pattern], mdlConfig 217 | 218 | requireModules: (modules = @requiredModules, addFeeds = {@feed}) => 219 | success, err = ModuleLoader.loadModules @, modules, addFeeds 220 | @@updater\releaseLock! 221 | unless success 222 | -- if we failed loading our required modules 223 | -- then that means we also failed to load 224 | LOADED_MODULES[@namespace] = nil 225 | @@logger\error err 226 | return unpack [mdl._ref for mdl in *modules] 227 | 228 | registerTests: (...) => 229 | -- load external tests 230 | haveTests, tests = pcall require, "DepUnit.#{@@ScriptType.name.legacy[@scriptType]}.#{@namespace}" 231 | 232 | if haveTests and not @testsLoaded 233 | @tests, tests.name = tests, @name 234 | modules = table.pack @requireModules! 235 | if @moduleName 236 | @tests\import @ref, modules, ... 237 | else @tests\import modules, ... 238 | 239 | @tests\registerMacros! 240 | @testsLoaded = true 241 | 242 | register: (selfRef, ...) => 243 | -- replace dummy refs with real refs to own module 244 | @ref.__index, @ref, LOADED_MODULES[@moduleName] = selfRef, selfRef, selfRef 245 | @registerTests selfRef, ... 246 | return selfRef 247 | 248 | registerMacro: (name=@name, description=@description, process, validate, isActive, submenu) => 249 | -- alternative signature takes name and description from script 250 | if type(name)=="function" 251 | process, validate, isActive, submenu = name, description, process, validate 252 | name, description = @name, @description 253 | 254 | -- use automation script name for submenu by default 255 | submenu = @name if submenu == true 256 | 257 | menuName = { @config.c.customMenu } 258 | menuName[#menuName+1] = submenu if submenu 259 | menuName[#menuName+1] = name 260 | 261 | -- check for updates before running a macro 262 | processHooked = (sub, sel, act) -> 263 | @@updater\scheduleUpdate @ 264 | @@updater\releaseLock! 265 | return process sub, sel, act 266 | 267 | aegisub.register_macro table.concat(menuName, "/"), description, processHooked, validate, isActive 268 | 269 | registerMacros: (macros = {}, submenuDefault = true) => 270 | for macro in *macros 271 | -- allow macro table to omit name and description 272 | submenuIdx = type(macro[1])=="function" and 4 or 6 273 | macro[submenuIdx] = submenuDefault if macro[submenuIdx] == nil 274 | @registerMacro unpack(macro, 1, 6) 275 | 276 | setVersion: (version) => 277 | version, err = @@parseVersion version 278 | if version 279 | @version = version 280 | return version 281 | else return nil, err 282 | 283 | validateNamespace: (namespace = @namespace, isVirtual = @virtual) => 284 | return isVirtual or namespaceValidation\match @namespace 285 | 286 | uninstall: (removeConfig = true) => 287 | if @virtual or @recordType == @@RecordType.Unmanaged 288 | return nil, msgs.uninstall.noVirtualOrUnmanaged\format @virtual and "virtual" or "unmanaged", 289 | @@terms.scriptType.singular[@scriptType], 290 | @name 291 | @config\delete! 292 | subModules, mdlConfig = @getSubmodules! 293 | -- uninstalling a module also removes all submodules 294 | if subModules and #subModules > 0 295 | mdlConfig.c[mdl] = nil for mdl in *subModules 296 | mdlConfig\write! 297 | 298 | toRemove, pattern, dir = {} 299 | if @moduleName 300 | nsp, name = @namespace\match "(.+)%.(.+)" 301 | pattern = "^#{name}" 302 | dir = "#{@automationDir}/#{nsp\gsub '%.', '/'}" 303 | else 304 | pattern = "^#{@namespace}"\gsub "%.", "%%." 305 | dir = @automationDir 306 | 307 | lfs.chdir dir 308 | for file in lfs.dir dir 309 | mode, path = FileOps.attributes file, "mode" 310 | -- parent level module files must be .ext 311 | currPattern = @moduleName and mode == "file" and pattern.."%." or pattern 312 | -- automation scripts don't use any subdirectories 313 | if (@moduleName or mode == "file") and file\match currPattern 314 | toRemove[#toRemove+1] = path 315 | return FileOps.remove toRemove, true, true -------------------------------------------------------------------------------- /modules/DependencyControl/UnitTestSuite.moon: -------------------------------------------------------------------------------- 1 | 2 | Logger = require "l0.DependencyControl.Logger" 3 | re = require "aegisub.re" 4 | -- make sure tests can be loaded from the test directory 5 | package.path ..= aegisub.decode_path("?user/automation/tests") .. "/?.lua;" 6 | 7 | --- A class for all single unit tests. 8 | -- Provides useful assertion and logging methods for a user-specified test function. 9 | -- @classmod UnitTest 10 | class UnitTest 11 | @msgs = { 12 | run: { 13 | setup: "Performing setup... " 14 | teardown: "Performing teardown... " 15 | test: "Running test '%s'... " 16 | ok: "OK." 17 | failed: "FAILED!" 18 | reason: "Reason: %s" 19 | } 20 | new: { 21 | badTestName: "Test name must be of type %s, got a %s." 22 | } 23 | 24 | assert: { 25 | true: "Expected true, actual value was %s." 26 | false: "Expected false, actual value was %s." 27 | nil: "Expected nil, actual value was %s." 28 | notNil: "Got nil when a value was expected." 29 | truthy: "Expected a truthy value, actual value was falsy (%s)." 30 | falsy: "Expected a falsy value, actual value was truthy (%s)." 31 | type: "Expected a value of type %s, actual value was of type %s." 32 | sameType: "Type of expected value (%s) didn't match type of actual value (%s)." 33 | inRange: "Expected value to be in range [%d .. %d], actual value %d was %s %d." 34 | almostEquals: "Expected value to be almost equal %d ± %d, actual value was %d." 35 | notAlmostEquals: "Expected numerical value to not be close to %d ± %d, actual value was %d." 36 | checkArgTypes: "Expected argument #%d (%s) to be of type %s, got a %s." 37 | zero: "Expected 0, actual value was a %s." 38 | notZero: "Got a 0 when a number other than 0 was expected." 39 | compare: "Expected value to be a number %s %d, actual value was %d." 40 | integer: "Expected numerical value to be an integer, actual value was %d." 41 | positiveNegative: "Expected a %s number (0 %s), actual value was %d." 42 | equals: "Actual value didn't match expected value.\n%s actual: %s\n%s expected: %s" 43 | notEquals: "Actual value equals expected value when it wasn't supposed to:\n%s actual: %s" 44 | is: "Expected %s, actual value was %s." 45 | isNot: "Actual value %s was identical to the expected value when it wasn't supposed to." 46 | itemsEqual: "Actual item values of table weren't %s to the expected values (checked %s):\n Actual: %s\nExpected: %s" 47 | itemsEqualNumericKeys: "only continuous numerical keys" 48 | itemsEqualAllKeys: "all keys" 49 | continuous: "Expected table to have continuous numerical keys, but value at index %d of %d was a nil." 50 | matches: "String value '%s' didn't match expected %s pattern '%s'." 51 | contains: "String value '%s' didn't contain expected substring '%s' (case-%s comparison)." 52 | error: "Expected function to throw an error but it succesfully returned %d values: %s" 53 | errorMsgMatches: "Error message '%s' didn't match expected %s pattern '%s'." 54 | } 55 | 56 | formatTemplate: { 57 | type: "'%s' of type %s" 58 | } 59 | } 60 | 61 | --- Creates a single unit test. 62 | -- Instead of calling this constructor you'd usually provide test data 63 | -- in a table structure to @{UnitTestSuite:new} as an argument. 64 | -- @tparam string name a descriptive title for the test 65 | -- @tparam function(UnitTest, ...) testFunc the function containing the test code 66 | -- @tparam UnitTestClass testClass the test class this test belongs to 67 | -- @treturn UnitTest the unit test 68 | -- @see UnitTestSuite:new 69 | new: (@name, @f = -> , @testClass) => 70 | @logger = @testClass.logger 71 | error type(@logger) unless type(@logger) == "table" 72 | @logger\assert type(@name) == "string", @@msgs.new.badTestName, type @name 73 | 74 | --- Runs the unit test function. 75 | -- In addition to the @{UnitTest} object itself, it also passes 76 | -- the specified arguments into the function. 77 | -- @param[opt] args any optional modules or other data the test function needs 78 | -- @treturn[1] boolean true (test succeeded) 79 | -- @treturn[2] boolean false (test failed) 80 | -- @treturn[2] string the error message describing how the test failed 81 | run: (...) => 82 | @assertFailed = false 83 | @logStart! 84 | @success, res = xpcall @f, debug.traceback, @, ... 85 | @logResult res 86 | 87 | return @success, @errMsg 88 | 89 | --- Formats and writes a "running test x" message to the log. 90 | -- @local 91 | logStart: => 92 | @logger\logEx nil, @@msgs.run.test, false, nil, nil, @name 93 | 94 | --- Formats and writes the test result to the log. 95 | -- In case of failure the message contains details about either the test assertion that failed 96 | -- or a stack trace if the test ran into a different exception. 97 | -- @local 98 | -- @tparam[opt=errMsg] the error message being logged; defaults to the error returned by the last run of this test 99 | logResult: (errMsg = @errMsg) => 100 | if @success 101 | @logger\logEx nil, @@msgs.run.ok, nil, nil, 0 102 | else 103 | if @assertFailed 104 | -- scrub useless stack trace from asserts provided by this module 105 | errMsg = errMsg\gsub "%[%w+ \".-\"%]:%d+:", "" 106 | errMsg = errMsg\gsub "stack traceback:.*", "" 107 | @errMsg = errMsg 108 | @logger\logEx nil, @@msgs.run.failed, nil, nil, 0 109 | @logger.indent += 1 110 | @logger\log @@msgs.run.reason, @errMsg 111 | @logger.indent -= 1 112 | 113 | --- Formats a message with a specified predefined template. 114 | -- Currently only supports the "type" template. 115 | -- @local 116 | -- @tparam string template the name of the template to use 117 | -- @param[opt] args any arguments required for formatting the message 118 | format: (tmpl, ...) => 119 | inArgs = table.pack ... 120 | outArgs = switch tmpl 121 | when "type" then {tostring(inArgs[1]), type(inArgs[1])} 122 | 123 | @@msgs.formatTemplate[tmpl]\format unpack outArgs 124 | 125 | 126 | -- static helper functions 127 | 128 | --- Compares equality of two specified arguments 129 | -- Requirements for values are considered equal: 130 | -- [1] their types match 131 | -- [2] their metatables are equal 132 | -- [3] strings and numbers are compared by value 133 | -- functions and cdata are compared by reference 134 | -- tables must have equal values at identical indexes and are compared recursively 135 | -- (i.e. two table copies of `{"a", {"b"}}` are considered equal) 136 | -- @static 137 | -- @param a the first value 138 | -- @param b the second value 139 | -- @tparam[opt] string aType if already known, specify the type of the first value 140 | -- for a small performance benefit 141 | -- @tparam[opt] string bType the type of the second value 142 | -- @treturn boolean `true` if a and b are equal, otherwise `false` 143 | equals: (a, b, aType, bType) -> 144 | -- TODO: support equality comparison of tables used as keys 145 | treeA, treeB, depth = {}, {}, 0 146 | 147 | recurse = (a, b, aType = type a, bType) -> 148 | -- identical values are equal 149 | return true if a == b 150 | -- only tables can be equal without also being identical 151 | bType or= type b 152 | return false if aType != bType or aType != "table" 153 | 154 | -- perform table equality comparison 155 | return false if #a != #b 156 | 157 | aFieldCnt, bFieldCnt = 0, 0 158 | local tablesSeenAtKeys 159 | 160 | depth += 1 161 | treeA[depth], treeB[depth] = a, b 162 | 163 | for k, v in pairs a 164 | vType = type v 165 | if vType == "table" 166 | -- comparing tables is expensive so we should keep a list 167 | -- of keys we can skip checking when iterating table b 168 | tablesSeenAtKeys or= {} 169 | tablesSeenAtKeys[k] = true 170 | 171 | -- detect synchronous circular references to prevent infinite recursion loops 172 | for i = 1, depth 173 | return true if v == treeA[i] and b[k] == treeB[i] 174 | 175 | unless recurse v, b[k], vType 176 | depth -= 1 177 | return false 178 | 179 | aFieldCnt += 1 180 | 181 | for k, v in pairs b 182 | continue if tablesSeenAtKeys and tablesSeenAtKeys[k] 183 | if bFieldCnt == aFieldCnt or not recurse v, a[k] 184 | -- no need to check further if the field count is not identical 185 | depth -= 1 186 | return false 187 | bFieldCnt += 1 188 | 189 | -- check metatables for equality 190 | res = recurse getmetatable(a), getmetatable b 191 | depth -= 1 192 | return res 193 | 194 | return recurse a, b, aType, bType 195 | 196 | 197 | --- Compares equality of two specified tables ignoring table keys. 198 | -- The table comparison works much in the same way as @{UnitTest:equals}, 199 | -- however this method doesn't require table keys to be equal between a and b 200 | -- and considers two tables to be equal if an equal value is found in b for every value in a and vice versa. 201 | -- By default this only looks at numerical indexes 202 | -- as this kind of comparison doesn't usually make much sense for hashtables. 203 | -- @static 204 | -- @tparam table a the first table 205 | -- @tparam table b the second table 206 | -- @tparam[opt=true] bool onlyNumericalKeys Disable this option to also compare items with non-numerical keys 207 | -- at the expense of a performance hit. 208 | -- @tparam[opt=false] bool ignoreExtraAItems Enable this option to make the comparison one-sided, 209 | -- ignoring additional items present in a but not in b. 210 | -- @tparam[opt=false] bool requireIdenticalItems Enable this option if you require table items to be identical, 211 | -- i.e. compared by reference, rather than by equality. 212 | itemsEqual: (a, b, onlyNumKeys = true, ignoreExtraAItems, requireIdenticalItems) -> 213 | seen, aTbls = {}, {} 214 | aCnt, aTblCnt, bCnt = 0, 0, 0 215 | 216 | findEqualTable = (bTbl) -> 217 | for i, aTbl in ipairs aTbls 218 | if UnitTest.equals aTbl, bTbl 219 | table.remove aTbls, i 220 | seen[aTbl] = nil 221 | return true 222 | return false 223 | 224 | if onlyNumKeys 225 | aCnt, bCnt = #a, #b 226 | return false if not ignoreExtraAItems and aCnt != bCnt 227 | 228 | for v in *a 229 | seen[v] = true 230 | if "table" == type v 231 | aTblCnt += 1 232 | aTbls[aTblCnt] = v 233 | 234 | for v in *b 235 | -- identical values 236 | if seen[v] 237 | seen[v] = nil 238 | continue 239 | 240 | -- equal values 241 | if type(v) != "table" or requireIdenticalItems or not findEqualTable v 242 | return false 243 | 244 | 245 | else 246 | for _, v in pairs a 247 | aCnt += 1 248 | seen[v] = true 249 | if "table" == type v 250 | aTblCnt += 1 251 | aTbls[aTblCnt] = v 252 | 253 | for _, v in pairs b 254 | bCnt += 1 255 | -- identical values 256 | if seen[v] 257 | seen[v] = nil 258 | continue 259 | 260 | -- equal values 261 | if type(v) != "table" or requireIdenticalItems or not findEqualTable v 262 | return false 263 | 264 | return false if not ignoreExtraAItems and aCnt != bCnt 265 | 266 | return true 267 | 268 | --- Helper method to mark a test as failed by assertion and throw a specified error message. 269 | -- @local 270 | -- @param condition passing in a falsy value causes the assertion to fail 271 | -- @tparam string message error message (may contain format string templates) 272 | -- @param[opt] args any arguments required for formatting the message 273 | assert: (condition, ...) => 274 | args = table.pack ... 275 | msg = table.remove args, 1 276 | unless condition 277 | @assertFailed = true 278 | @logger\logEx 1, msg, nil, nil, 0, unpack args 279 | 280 | 281 | -- type assertions 282 | 283 | --- Fails the assertion if the specified value didn't have the expected type. 284 | -- @param value the value to be type-checked 285 | -- @tparam string expectedType the expected type 286 | assertType: (val, expected) => 287 | @checkArgTypes val: {val, "_any"}, expected: {expected, "string"} 288 | actual = type val 289 | @assert actual == expected, @@msgs.assert.type, expected, actual 290 | 291 | --- Fails the assertion if the types of the actual and expected value didn't match 292 | -- @param actual the actual value 293 | -- @param expected the expected value 294 | assertSameType: (actual, expected) => 295 | actualType, expectedType = type(actual), type expected 296 | @assert actualType == expectedType, @@msgs.assert.sameType, expectedType, actualType 297 | 298 | --- Fails the assertion if the specified value isn't a boolean 299 | -- @param value the value expected to be a boolean 300 | assertBoolean: (val) => @assertType val, "boolean" 301 | --- Shorthand for @{UnitTest:assertBoolean} 302 | assertBool: (val) => @assertType val, "boolean" 303 | 304 | --- Fails the assertion if the specified value isn't a function 305 | -- @param value the value expected to be a function 306 | assertFunction: (val) => @assertType val, "function" 307 | 308 | --- Fails the assertion if the specified value isn't a number 309 | -- @param value the value expected to be a number 310 | assertNumber: (val) => @assertType val, "number" 311 | 312 | --- Fails the assertion if the specified value isn't a string 313 | -- @param value the value expected to be a string 314 | assertString: (val) => @assertType val, "string" 315 | 316 | --- Fails the assertion if the specified value isn't a table 317 | -- @param value the value expected to be a table 318 | assertTable: (val) => @assertType val, "table" 319 | 320 | --- Helper method to type-check arguments as a prerequisite to other asserts. 321 | -- @local 322 | -- @tparam {[string]={value, string}} args a hashtable of argument values and expected types 323 | -- indexed by the respective argument names 324 | checkArgTypes: (args) => 325 | i, expected, actual = 1 326 | for name, types in pairs args 327 | actual, expected = types[2], type types[1] 328 | continue if expected == "_any" 329 | @logger\assert actual == expected, @@msgs.assert.checkArgTypes, i, name, 330 | expected, @format "type", types[1] 331 | i += 1 332 | 333 | 334 | -- boolean asserts 335 | 336 | --- Fails the assertion if the specified value isn't the boolean `true`. 337 | -- @param value the value expected to be `true` 338 | assertTrue: (val) => 339 | @assert val == true, @@msgs.assert.true, @format "type", val 340 | 341 | --- Fails the assertion if the specified value doesn't evaluate to boolean `true`. 342 | -- In Lua this is only ever the case for `nil` and boolean `false`. 343 | -- @param value the value expected to be truthy 344 | assertTruthy: (val) => 345 | @assert val, @@msgs.assert.truthy, @format "type", val 346 | 347 | --- Fails the assertion if the specified value isn't the boolean `false`. 348 | -- @param value the value expected to be `false` 349 | assertFalse: (val) => 350 | @assert val == false, @@msgs.assert.false, @format "type", val 351 | 352 | --- Fails the assertion if the specified value doesn't evaluate to boolean `false`. 353 | -- In Lua `nil` is the only other value that evaluates to `false`. 354 | -- @param value the value expected to be falsy 355 | assertFalsy: (val) => 356 | @assert not val, @@msgs.assert.falsy, @format "type", val 357 | 358 | --- Fails the assertion if the specified value is not `nil`. 359 | -- @param value the value expected to be `nil` 360 | assertNil: (val) => 361 | @assert val == nil, @@msgs.assert.nil, @format "type", val 362 | 363 | --- Fails the assertion if the specified value is `nil`. 364 | -- @param value the value expected to not be `nil` 365 | assertNotNil: (val) => 366 | @assert val != nil, @@msgs.assert.notNil, @format "type", val 367 | 368 | 369 | -- numerical asserts 370 | 371 | --- Fails the assertion if a number is out of the specified range. 372 | -- @tparam number actual the number expected to be in range 373 | -- @tparam number min the minimum (inclusive) value 374 | -- @tparam number max the maximum (inclusive) value 375 | assertInRange: (actual, min = -math.huge, max = math.huge) => 376 | @checkArgTypes actual: {actual, "number"}, min: {min, "number"}, max: {max, "number"} 377 | @assert actual >= min, @@msgs.assert.inRange, min, max, actual, "<", min 378 | @assert actual <= max, @@msgs.assert.inRange, min, max, actual, ">", max 379 | 380 | --- Fails the assertion if a number is not lower than the specified value. 381 | -- @tparam number actual the number to compare 382 | -- @tparam number limit the lower limit (exclusive) 383 | assertLessThan: (actual, limit) => 384 | @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} 385 | @assert actual < limit, @@msgs.assert.compare, "<", limit, actual 386 | 387 | --- Fails the assertion if a number is not lower than or equal to the specified value. 388 | -- @tparam number actual the number to compare 389 | -- @tparam number limit the lower limit (inclusive) 390 | assertLessThanOrEquals: (actual, limit) => 391 | @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} 392 | @assert actual <= limit, @@msgs.assert.compare, "<=", limit, actual 393 | 394 | --- Fails the assertion if a number is not greater than the specified value. 395 | -- @tparam number actual the number to compare 396 | -- @tparam number limit the upper limit (exclusive) 397 | assertGreaterThan: (actual, limit) => 398 | @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} 399 | @assert actual > limit, @@msgs.assert.compare, ">", limit, actual 400 | 401 | --- Fails the assertion if a number is not greater than or equal to the specified value. 402 | -- @tparam number actual the number to compare 403 | -- @tparam number limit the upper limit (inclusive) 404 | assertGreaterThanOrEquals: (actual, limit) => 405 | @checkArgTypes actual: {actual, "number"}, limit: {limit, "number"} 406 | @assert actual >= limit, @@msgs.assert.compare, ">=", limit, actual 407 | 408 | --- Fails the assertion if a number is not in range of an expected value +/- a specified margin. 409 | -- @tparam number actual the actual value 410 | -- @tparam number expected the expected value 411 | -- @tparam[opt=1e-8] number margin the maximum (inclusive) acceptable margin of error 412 | assertAlmostEquals: (actual, expected, margin = 1e-8) => 413 | @checkArgTypes actual: {actual, "number"}, min: {expected, "number"}, max: {margin, "number"} 414 | 415 | margin = math.abs margin 416 | @assert math.abs(actual-expected) <= margin, @@msgs.assert.almostEquals, 417 | expected, margin, actual 418 | 419 | --- Fails the assertion if a number differs from another value at most by a specified margin. 420 | -- Inverse of @{assertAlmostEquals} 421 | -- @tparam number actual the actual value 422 | -- @tparam number value the value being compared against 423 | -- @tparam[opt=1e-8] number margin the maximum (inclusive) margin of error for the numbers to be considered equal 424 | assertNotAlmostEquals: (actual, value, margin = 1e-8) => 425 | @checkArgTypes actual: {actual, "number"}, value: {value, "number"}, max: {margin, "number"} 426 | 427 | margin = math.abs margin 428 | @assert math.abs(actual-value) > margin, @@msgs.assert.almostEquals, value, margin, actual 429 | 430 | --- Fails the assertion if a number is not equal to 0 (zero). 431 | -- @tparam number actual the value 432 | assertZero: (actual) => 433 | @checkArgTypes actual: {actual, "number"} 434 | @assert actual == 0, @@msgs.assert.zero, actual 435 | 436 | --- Fails the assertion if a number is equal to 0 (zero). 437 | -- Inverse of @{assertZero} 438 | -- @tparam number actual the value 439 | assertNotZero: (actual) => 440 | @checkArgTypes actual: {actual, "number"} 441 | @assert actual != 0, @@msgs.assert.notZero 442 | 443 | --- Fails the assertion if a specified number has a fractional component. 444 | -- All numbers in Lua share a common data type, which is usually a double, 445 | -- which is the reason this is not a type check. 446 | -- @tparam number actual the value 447 | assertInteger: (actual) => 448 | @checkArgTypes actual: {actual, "number"} 449 | @assert math.floor(actual) == actual, @@msgs.assert.integer, actual 450 | 451 | --- Fails the assertion if a specified number is less than or equal 0. 452 | -- @tparam number actual the value 453 | -- @tparam[opt=false] boolean includeZero makes the assertion consider 0 to be positive 454 | assertPositive: (actual, includeZero = false) => 455 | @checkArgTypes actual: {actual, "number"}, includeZero: {includeZero, "boolean"} 456 | res = includeZero and actual >= 0 or actual > 0 457 | @assert res, @@msgs.assert.positiveNegative, "positive", 458 | includeZero and "included" or "excluded" 459 | 460 | --- Fails the assertion if a specified number is greater than or equal 0. 461 | -- @tparam number actual the value 462 | -- @tparam[opt=false] boolean includeZero makes the assertion not fail when a 0 is encountered 463 | assertNegative: (actual, includeZero = false) => 464 | @checkArgTypes actual: {actual, "number"}, includeZero: {includeZero, "boolean"} 465 | res = includeZero and actual <= 0 or actual < 0 466 | @assert res, @@msgs.assert.positiveNegative, "positive", 467 | includeZero and "included" or "excluded" 468 | 469 | 470 | -- generic asserts 471 | 472 | --- Fails the assertion if a the actual value is not *equal* to the expected value. 473 | -- On the requirements for equality see @{UnitTest:equals} 474 | -- @param actual the actual value 475 | -- @param expected the expected value 476 | assertEquals: (actual, expected) => 477 | @assert self.equals(actual, expected), @@msgs.assert.equals, type(actual), 478 | @logger\dumpToString(actual), type(expected), @logger\dumpToString expected 479 | 480 | --- Fails the assertion if a the actual value is *equal* to the expected value. 481 | -- Inverse of @{UnitTest:assertEquals} 482 | -- @param actual the actual value 483 | -- @param expected the expected value 484 | assertNotEquals: (actual, expected) => 485 | @assert not self.equals(actual, expected), @@msgs.assert.notEquals, 486 | type(actual), @logger\dumpToString expected 487 | 488 | --- Fails the assertion if a the actual value is not *identical* to the expected value. 489 | -- Uses the `==` operator, so in contrast to @{UnitTest:assertEquals}, 490 | -- this assertion compares tables by reference. 491 | -- @param actual the actual value 492 | -- @param expected the expected value 493 | assertIs: (actual, expected) => 494 | @assert actual == expected, @@msgs.assert.is, @format("type", expected), 495 | @format "type", actual 496 | 497 | --- Fails the assertion if a the actual value is *identical* to the expected value. 498 | -- Inverse of @{UnitTest:assertIs} 499 | -- @param actual the actual value 500 | -- @param expected the expected value 501 | assertIsNot: (actual, expected) => 502 | @assert actual != expected, @@msgs.assert.isNot, @format "type", expected 503 | 504 | 505 | -- table asserts 506 | 507 | --- Fails the assertion if the items of one table aren't *equal* to the items of another. 508 | -- Unlike @{UnitTest:assertEquals} this ignores table keys, so e.g. two numerically-keyed tables 509 | -- with equal items in a different order would still be considered equal. 510 | -- By default this assertion only compares values at numerical indexes (see @{UnitTest:itemsEqual} for details). 511 | -- @tparam table actual the first table 512 | -- @tparam table expected the second table 513 | -- @tparam[opt=true] boolean onlyNumericalKeys Disable this option to also compare items with non-numerical keys at the expense of a performance hit. 514 | assertItemsEqual: (actual, expected, onlyNumKeys = true) => 515 | @checkArgTypes { actual: {actual, "table"}, expected: {actual, "table"}, 516 | onlyNumKeys: {onlyNumKeys, "boolean"} 517 | } 518 | 519 | @assert self.itemsEqual(actual, expected, onlyNumKeys), 520 | @@msgs.assert[onlyNumKeys and "itemsEqualNumericKeys" or "itemsEqualAllKeys"], 521 | @logger\dumpToString(actual), @logger\dumpToString expected 522 | 523 | 524 | --- Fails the assertion if the items of one table aren't *identical* to the items of another. 525 | -- Like @{UnitTest:assertItemsEqual} this ignores table keys, however it compares table items by reference. 526 | -- By default this assertion only compares values at numerical indexes (see @{UnitTest:itemsEqual} for details). 527 | -- @tparam table actual the first table 528 | -- @tparam table expected the second table 529 | -- @tparam[opt=true] boolean onlyNumericalKeys Disable this option to also compare items with non-numerical keys 530 | assertItemsAre: (actual, expected, onlyNumKeys = true) => 531 | @checkArgTypes { actual: {actual, "table"}, expected: {actual, "table"}, 532 | onlyNumKeys: {onlyNumKeys, "boolean"} 533 | } 534 | 535 | @assert self.itemsEqual(actual, expected, onlyNumKeys, nil, true), 536 | @@msgs.assert[onlyNumKeys and "itemsEqualNumericKeys" or "itemsEqualAllKeys"], 537 | @logger\dumpToString(actual), @logger\dumpToString expected 538 | 539 | --- Fails the assertion if the numerically-keyed items of a table aren't continuous. 540 | -- The rationale for this is that when iterating a table with ipairs or retrieving its length 541 | -- with the # operator, Lua may stop processing the table once the item at index n is nil, 542 | -- effectively hiding any subsequent values 543 | -- @tparam table tbl the table to be checked 544 | assertContinuous: (tbl) => 545 | @checkArgTypes { tbl: {tbl, "table"} } 546 | 547 | realCnt, contCnt = 0, #tbl 548 | for _, v in pairs tbl 549 | if type(v) == "number" and math.floor(v) == v 550 | realCnt += 1 551 | 552 | @assert realCnt == contCnt, @@msgs.assert.continuous, contCnt+1, realCnt 553 | 554 | -- string asserts 555 | 556 | --- Fails the assertion if a string doesn't match the specified pattern. 557 | -- Supports both Lua and Regex patterns. 558 | -- @tparam string str the input string 559 | -- @tparam string pattern the pattern to be matched against 560 | -- @tparam[opt=false] boolean useRegex Enable this option to use Regex instead of Lua patterns 561 | -- @tparam[optchain] re.Flags flags Any amount of regex flags as defined by the Aegisub re module 562 | -- (see here for details: http://docs.aegisub.org/latest/Automation/Lua/Modules/re/#flags) 563 | assertMatches: (str, pattern, useRegex = false, ...) => 564 | @checkArgTypes { str: {str, "string"}, pattern: {pattern, "string"}, 565 | useRegex: {useRegex, "boolean"} 566 | } 567 | 568 | match = useRegex and re.match(str, pattern, ...) or str\match pattern, ... 569 | @assert match, @@msgs.assert.matches, str, useRegex and "regex" or "Lua", pattern 570 | 571 | --- Fails the assertion if a string doesn't contain a specified substring. 572 | -- Search is case-sensitive by default. 573 | -- @tparam string str the input string 574 | -- @tparam string needle the substring to be found 575 | -- @tparam[opt=true] boolean caseSensitive Disable this option to use locale-dependent case-insensitive comparison. 576 | -- @tparam[opt=1] number init the first byte to start the search at 577 | assertContains: (str, needle, caseSensitive = true, init = 1) => 578 | @checkArgTypes { str: {str, "string"}, needle: {needle, "string"}, 579 | caseSensitive: {caseSensitive, "boolean"}, init: {init, "number"} 580 | } 581 | 582 | _str, _needle = if caseSensitive 583 | str\lower!, needle\lower! 584 | else str, needle 585 | @assert str\find(needle, init, true), str, needle, 586 | caseSensitive and "sensitive" or "insensitive" 587 | 588 | -- function asserts 589 | 590 | 591 | --- Fails the assertion if calling a function with the specified arguments doesn't cause it throw an error. 592 | -- @tparam function func the function to be called 593 | -- @param[opt] args any number of arguments to be passed into the function 594 | assertError: (func, ...) => 595 | @checkArgTypes { func: {func, "function"} } 596 | 597 | res = table.pack pcall func, ... 598 | retCnt, success = res.n, table.remove res, 1 599 | res.n = nil 600 | @assert success == false, @@msgs.assert.error, retCnt, @logger\dumpToString res 601 | return res[1] 602 | 603 | --- Fails the assertion if a function call doesn't cause an error message that matches the specified pattern. 604 | -- Supports both Lua and Regex patterns. 605 | -- @tparam function func the function to be called 606 | -- @tparam[opt={}] table args a table of any number of arguments to be passed into the function 607 | -- @tparam string pattern the pattern to be matched against 608 | -- @tparam[opt=false] boolean useRegex Enable this option to use Regex instead of Lua patterns 609 | -- @tparam[optchain] re.Flags flags Any amount of regex flags as defined by the Aegisub re module 610 | -- (see here for details: http://docs.aegisub.org/latest/Automation/Lua/Modules/re/#flags) 611 | assertErrorMsgMatches: (func, params = {}, pattern, useRegex = false, ...) => 612 | @checkArgTypes { func: {func, "function"}, params: {params, "table"}, 613 | pattern: {pattern, "string"}, useRegex: {useRegex, "boolean"} 614 | } 615 | msg = @assertError func, unpack params 616 | 617 | match = useRegex and re.match(msg, pattern, ...) or msg\match pattern, ... 618 | @assert match, @@msgs.assert.errorMsgMatches, msg, useRegex and "regex" or "Lua", pattern 619 | 620 | 621 | --- A special case of the UnitTest class for a setup routine 622 | -- @classmod UnitTestSetup 623 | class UnitTestSetup extends UnitTest 624 | --- Runs the setup routine. 625 | -- Only the @{UnitTestSetup} object is passed into the function. 626 | -- Values returned by the setup routine are stored to be passed into the test functions later. 627 | -- @treturn[1] boolean true (test succeeded) 628 | -- @treturn[1] table retVals all values returned by the function packed into a table 629 | -- @treturn[2] boolean false (test failed) 630 | -- @treturn[2] string the error message describing how the test failed 631 | run: => 632 | @logger\logEx nil, @@msgs.run.setup, false 633 | 634 | res = table.pack pcall @f, @ 635 | @success = table.remove res, 1 636 | @logResult res[1] 637 | 638 | if @success 639 | @retVals = res 640 | return true, @retVals 641 | 642 | return false, @errMsg 643 | 644 | --- A special case of the UnitTest class for a teardown routine 645 | -- @classmod UnitTestTeardown 646 | class UnitTestTeardown extends UnitTest 647 | --- Formats and writes a "running test x" message to the log. 648 | -- @local 649 | logStart: => 650 | @logger\logEx nil, @@msgs.run.teardown, false 651 | 652 | 653 | --- Holds a unit test class, i.e. a group of unit tests with common setup and teardown routines 654 | -- @classmod UnitTestClass 655 | class UnitTestClass 656 | msgs = { 657 | run: { 658 | runningTests: "Running test class '%s' (%d tests)..." 659 | setupFailed: "Setup for test class '%s' FAILED, skipping tests." 660 | abort: "Test class '%s' FAILED after %d tests, aborting." 661 | testsFailed: "Done testing class '%s'. FAILED %d of %d tests." 662 | success: "Test class '%s' completed successfully." 663 | testNotFound: "Couldn't find requested test '%s'." 664 | } 665 | } 666 | 667 | --- Creates a new unit test class complete with a number of unit test as well as optional setup and teardown. 668 | -- Instead of calling this constructor directly, it is recommended to call @{UnitTestSuite:new} instead, 669 | -- which takes a table of test functions and creates test classes automatically. 670 | -- @tparam string name a descriptive name for the test class 671 | -- @tparam[opt={}] {[string] = function|table, ...} args a table of test functions by name; 672 | -- indexes starting with "_" have special meaning and are not added as regular tests: 673 | -- * _setup: a @{UnitTestSetup} routine 674 | -- * _teardown: a @{UnitTestTeardown} routine 675 | -- * _order: alternative syntax to the order parameter (see below) 676 | -- @tparam [opt=nil (unordered)] {string, ...} A list of test names in the desired execution order. 677 | -- Only tests mentioned in this table will be performed when running the whole test class. 678 | -- If unspecified, all tests will be run in random order. 679 | new: (@name, args = {}, @order, @testSuite) => 680 | @logger = @testSuite.logger 681 | @setup = UnitTestSetup "setup", args._setup, @ 682 | @teardown = UnitTestTeardown "teardown", args._teardown, @ 683 | @description = args._description 684 | @order or= args._order 685 | @tests = [UnitTest(name, f, @) for name, f in pairs args when "_" != name\sub 1,1] 686 | 687 | --- Runs all tests in the unit test class in the specified order. 688 | -- @param[opt=false] abortOnFail stops testing once a test fails 689 | -- @param[opt=(default)] overrides the default test order 690 | -- @treturn[1] boolean true (test class succeeded) 691 | -- @treturn[2] boolean false (test class failed) 692 | -- @treturn[2] {@{UnitTest}, ...} a list of unit test that failed 693 | run: (abortOnFail, order = @order) => 694 | tests, failed = @tests, {} 695 | if order 696 | tests, mappings = {}, {test.name, test for test in *@tests} 697 | for i, name in ipairs order 698 | @logger\assert mappings[name], msgs.run.testNotFound, name 699 | tests[i] = mappings[name] 700 | testCnt, failedCnt = #tests, 0 701 | 702 | @logger\log msgs.run.runningTests, @name, testCnt 703 | @logger.indent += 1 704 | 705 | success, res = @setup\run! 706 | -- failing the setup always aborts 707 | unless success 708 | @logger.indent -= 1 709 | @logger\warn msgs.run.setupFailed, @name 710 | return false, -1 711 | 712 | for i, test in pairs tests 713 | unless test\run unpack res 714 | failedCnt += 1 715 | failed[#failed+1] = test 716 | if abortOnFail 717 | @logger.indent -= 1 718 | @logger\warn msgs.run.abort, @name, i 719 | return false, failed 720 | 721 | @logger.indent -= 1 722 | @success = failedCnt == 0 723 | 724 | if @success 725 | @logger\log msgs.run.success, @name 726 | return true 727 | 728 | @logger\log msgs.run.testsFailed, @name, failedCnt, testCnt 729 | return false, failed 730 | 731 | 732 | --- A DependencyControl unit test suite. 733 | -- Your test file/module must return a UnitTestSuite object in order to be recognized as a test suite. 734 | class UnitTestSuite 735 | msgs = { 736 | run: { 737 | running: "Running %d test classes for %s... " 738 | aborted: "Aborting after %d test classes... " 739 | classesFailed: "FAILED %d of %d test classes." 740 | success: "All tests completed successfully." 741 | classNotFound: "Couldn't find requested test class '%s'." 742 | } 743 | registerMacros: { 744 | allDesc: "Runs the whole test suite." 745 | } 746 | new: { 747 | badClassesType: "Test classes must be passed in either as a table or an import function, got a %s" 748 | } 749 | import: { 750 | noTableReturned: "The test import function must return a table of test classes, got a %s." 751 | } 752 | } 753 | 754 | @UnitTest = UnitTest 755 | @UnitTestClass = UnitTestClass 756 | 757 | --- Creates a complete unit test suite for a module or automation script. 758 | -- Using this constructor will create all test classes and tests automatically. 759 | -- @tparam string namespace the namespace of the module or automation script to test. 760 | -- @tparam {[string] = table, ...}|function(self, dependencies, args...) args To create a UnitTest suite, 761 | -- you must supply a hashtable of @{UnitTestClass} constructor tables by name. You can either do so directly, 762 | -- or wrap it in a function that takes a number of arguments depending on how the tests are registered: 763 | -- * self: the module being testsed (skipped for automation scripts) 764 | -- * dependencies: a numerically keyed table of all the modules required by the tested script/module (in order) 765 | -- * args: any additional arguments passed into the @{DependencyControl\registerTests} function. 766 | -- Doing so is required to test automation scripts as well as module functions not exposed by its API. 767 | -- indexes starting with "_" have special meaning and are not added as regular tests: 768 | -- * _order: alternative syntax to the order parameter (see below) 769 | -- @tparam [opt=nil (unordered)] {string, ...} An list of test class names in the desired execution order. 770 | -- Only test classes mentioned in this table will be performed when running the whole test suite. 771 | -- If unspecified, all test classes will be run in random order. 772 | new: (@namespace, classes, @order) => 773 | @logger = Logger defaultLevel: 3, fileBaseName: @namespace, fileSubName: "UnitTests", toFile: true 774 | @classes = {} 775 | switch type classes 776 | when "table" then @addClasses classes 777 | when "function" then @importFunc = classes 778 | else @logger\error msgs.new.badClassesType, type classes 779 | 780 | --- Constructs test classes and adds them to the suite. 781 | -- Use this if you need to add additional test classes to an existing @{UnitTestSuite} object. 782 | -- @tparam {[string] = table, ...} args a hashtable of @{UnitTestClass} constructor tables by name. 783 | addClasses: (classes) => 784 | @classes[#@classes+1] = UnitTestClass(name, args, args._order, @) for name, args in pairs classes when "_" != name\sub 1,1 785 | if classes._order 786 | @order or= {} 787 | @order[#@order+1] = clsName for clsName in *classes._order 788 | 789 | --- Imports test classes from a function (passing in the specified arguments) and adds them to the suite. 790 | -- Use this if you need to add additional test classes to an existing @{UnitTestSuite} object. 791 | -- @tparam [opt] args a hashtable of @{UnitTestClass} constructor tables by name. 792 | import: (...) => 793 | return false unless @importFunc 794 | classes = self.importFunc ... 795 | @logger\assert type(classes) == "table", msgs.import.noTableReturned, type classes 796 | @addClasses classes 797 | @importFunc = nil 798 | 799 | --- Registers macros for running all or specific test classes of this suite. 800 | -- If the test script is placed in the appropriate directory (according to module/automation script namespace), 801 | -- this is automatically handled by DependencyControl. 802 | registerMacros: => 803 | menuItem = {"DependencyControl", "Run Tests", @name or @namespace, "[All]"} 804 | aegisub.register_macro table.concat(menuItem, "/"), msgs.registerMacros.allDesc, -> @run! 805 | for cls in *@classes 806 | menuItem[4] = cls.name 807 | aegisub.register_macro table.concat(menuItem, "/"), cls.description, -> cls\run! 808 | 809 | --- Runs all test classes of this suite in the specified order. 810 | -- @param[opt=false] abortOnFail stops testing once a test fails 811 | -- @param[opt=(default)] overrides the default test order 812 | -- @treturn[1] boolean true (test class succeeded) 813 | -- @treturn[2] boolean false (test class failed) 814 | -- @treturn[2] {@{UnitTest}, ...} a list of unit test that failed 815 | run: (abortOnFail, order = @order) => 816 | classes, allFailed = @classes, {} 817 | if order 818 | classes, mappings = {}, {cls.name, cls for cls in *@classes} 819 | for i, name in ipairs order 820 | @logger\assert mappings[name], msgs.run.classNotFound, name 821 | classes[i] = mappings[name] 822 | 823 | classCnt, failedCnt = #classes, 0 824 | @logger\log msgs.run.running, classCnt, @namespace 825 | @logger.indent += 1 826 | 827 | for i, cls in pairs classes 828 | success, failed = cls\run abortOnFail 829 | unless success 830 | failedCnt += 1 831 | allFailed[#allFailed+1] = test for test in *failed 832 | if abortOnFail 833 | @logger.indent -= 1 834 | @logger\warn msgs.run.abort, i 835 | return false, allFailed 836 | 837 | @logger.indent -= 1 838 | @success = failedCnt == 0 839 | if @success 840 | @logger\log msgs.run.success 841 | else @logger\log msgs.run.classesFailed, failedCnt, classCnt 842 | 843 | return @success, failedCnt > 0 and allFailed or nil -------------------------------------------------------------------------------- /modules/DependencyControl/UpdateFeed.moon: -------------------------------------------------------------------------------- 1 | json = require "json" 2 | DownloadManager = require "DM.DownloadManager" 3 | 4 | DependencyControl = nil 5 | Logger = require "l0.DependencyControl.Logger" 6 | Common = require "l0.DependencyControl.Common" 7 | 8 | defaultLogger = Logger fileBaseName: "DepCtrl.UpdateFeed" 9 | 10 | class ScriptUpdateRecord extends Common 11 | msgs = { 12 | errors: { 13 | noActiveChannel: "No active channel." 14 | } 15 | changelog: { 16 | header: "Changelog for %s v%s (released %s):" 17 | verTemplate: "v %s:" 18 | msgTemplate: " • %s" 19 | } 20 | } 21 | 22 | new: (@namespace, @data, @config = {c:{}}, scriptType, autoChannel = true, @logger = defaultLogger) => 23 | DependencyControl or= require "l0.DependencyControl" 24 | @moduleName = scriptType == @@ScriptType.Module and @namespace 25 | @[k] = v for k, v in pairs data 26 | @setChannel! if autoChannel 27 | 28 | 29 | getChannels: => 30 | channels, default = {} 31 | for name, channel in pairs @data.channels 32 | channels[#channels+1] = name 33 | if channel.default and not default 34 | default = name 35 | 36 | return channels, default 37 | 38 | setChannel: (channelName = @config.c.activeChannel) => 39 | with @config.c 40 | .channels, default = @getChannels! 41 | .lastChannel or= channelName or default 42 | channelData = @data.channels[.lastChannel] 43 | @activeChannel = .lastChannel 44 | return false, @activeChannel unless channelData 45 | @[k] = v for k, v in pairs channelData 46 | 47 | @files = @files and [file for file in *@files when not file.platform or file.platform == @@platform] or {} 48 | return true, @activeChannel 49 | 50 | checkPlatform: => 51 | @logger\assert @activeChannel, msgs.errors.noActiveChannel 52 | return not @platforms or ({p,true for p in *@platforms})[@@platform], @@platform 53 | 54 | getChangelog: (versionRecord, minVer = 0) => 55 | return "" unless "table" == type @changelog 56 | maxVer = DependencyControl\parseVersion @version 57 | minVer = DependencyControl\parseVersion minVer 58 | 59 | changelog = {} 60 | for ver, entry in pairs @changelog 61 | ver = DependencyControl\parseVersion ver 62 | verStr = DependencyControl\getVersionString ver 63 | if ver >= minVer and ver <= maxVer 64 | changelog[#changelog+1] = {ver, verStr, entry} 65 | 66 | return "" if #changelog == 0 67 | table.sort changelog, (a,b) -> a[1]>b[1] 68 | 69 | msg = {msgs.changelog.header\format @name, DependencyControl\getVersionString(@version), @released or ""} 70 | for chg in *changelog 71 | chg[3] = {chg[3]} if type(chg[3]) ~= "table" 72 | if #chg[3] > 0 73 | msg[#msg+1] = @logger\format msgs.changelog.verTemplate, 1, chg[2] 74 | msg[#msg+1] = @logger\format(msgs.changelog.msgTemplate, 1, entry) for entry in *chg[3] 75 | 76 | return table.concat msg, "\n" 77 | 78 | class UpdateFeed extends Common 79 | templateData = { 80 | maxDepth: 7, 81 | templates: { 82 | feedName: {depth: 1, order: 1, key: "name" } 83 | baseUrl: {depth: 1, order: 2, key: "baseUrl" } 84 | feed: {depth: 1, order: 3, key: "knownFeeds", isHashTable: true } 85 | namespace: {depth: 3, order: 1, parentKeys: {macros:true, modules:true} } 86 | namespacePath: {depth: 3, order: 2, parentKeys: {macros:true, modules:true}, repl:"%.", to: "/" } 87 | scriptName: {depth: 3, order: 3, key: "name" } 88 | channel: {depth: 5, order: 1, parentKeys: {channels:true} } 89 | version: {depth: 5, order: 2, key: "version" } 90 | platform: {depth: 7, order: 1, key: "platform" } 91 | fileName: {depth: 7, order: 2, key: "name" } 92 | -- rolling templates 93 | fileBaseUrl: {key: "fileBaseUrl", rolling: true } 94 | } 95 | sourceAt: {} 96 | } 97 | 98 | msgs = { 99 | trace: { 100 | usingCached: "Using cached feed." 101 | downloaded: "Downloaded feed to %s." 102 | } 103 | errors: { 104 | downloadAdd: "Couldn't initiate download of %s to %s (%s)." 105 | downloadFailed: "Download of feed %s to %s failed (%s)." 106 | cantOpen: "Can't open downloaded feed for reading (%s)." 107 | parse: "Error parsing feed." 108 | } 109 | } 110 | 111 | @defaultConfig = { 112 | downloadPath: aegisub.decode_path "?temp/l0.#{@@__name}_feedCache" 113 | dumpExpanded: false 114 | } 115 | @cache = {} 116 | 117 | fileBaseName = "l0.#{@@__name}_" 118 | fileMatchTemplate = "l0.#{@@__name}_%x%x%x%x.*%.json" 119 | feedsHaveBeenTrimmed = false 120 | 121 | -- precalculate some tables for the templater 122 | templateData.rolling = {n, true for n,t in pairs templateData.templates when t.rolling} 123 | templateData.sourceKeys = {t.key, t.depth for n,t in pairs templateData.templates when t.key} 124 | with templateData 125 | for i=1,.maxDepth 126 | .sourceAt[i], j = {}, 1 127 | for name, tmpl in pairs .templates 128 | if tmpl.depth==i and not tmpl.rolling 129 | .sourceAt[i][j] = name 130 | j += 1 131 | table.sort .sourceAt[i], (a,b) -> return .templates[a].order < .templates[b].order 132 | 133 | new: (@url, autoFetch = true, fileName, @config = {}, @logger = defaultLogger) => 134 | DependencyControl or= require "l0.DependencyControl" 135 | 136 | -- fill in missing config values 137 | @config[k] = v for k, v in pairs @@defaultConfig when @config[k] == nil 138 | 139 | -- delete old feeds 140 | feedsHaveBeenTrimmed or= Logger(fileMatchTemplate: fileMatchTemplate, logDir: @config.downloadPath, maxFiles: 20)\trimFiles! 141 | 142 | @fileName = fileName or table.concat {@config.downloadPath, fileBaseName, "%04X"\format(math.random 0, 16^4-1), ".json"} 143 | if @@cache[@url] 144 | @logger\trace msgs.trace.usingCached 145 | @data = @@cache[@url] 146 | elseif autoFetch 147 | @fetch! 148 | 149 | @downloadManager = DownloadManager aegisub.decode_path @config.downloadPath 150 | 151 | getKnownFeeds: => 152 | return {} unless @data 153 | return [url for _, url in pairs @data.knownFeeds] 154 | -- TODO: maybe also search all requirements for feed URLs 155 | 156 | fetch: (fileName) => 157 | @fileName = fileName if fileName 158 | 159 | dl, err = @downloadManager\addDownload @url, @fileName 160 | unless dl 161 | return false, msgs.errors.downloadAdd\format @url, @fileName, err 162 | 163 | @downloadManager\waitForFinish -> true 164 | if dl.error 165 | return false, msgs.errors.downloadFailed\format @url, @fileName, dl.error 166 | 167 | @logger\trace msgs.trace.downloaded, @fileName 168 | 169 | handle, err = io.open @fileName 170 | unless handle 171 | return false, msgs.errors.cantOpen\format err 172 | 173 | decoded, data = pcall json.decode, handle\read "*a" 174 | unless decoded and data 175 | -- luajson errors are useless dumps of whatever, no use to pass them on to the user 176 | return false, msgs.errors.parse 177 | 178 | data[key] = {} for key in *{ @@ScriptType.name.legacy[@@ScriptType.Automation], 179 | @@ScriptType.name.legacy[@@ScriptType.Module], 180 | "knownFeeds"} when not data[key] 181 | @data, @@cache[@url] = data, data 182 | @expand! 183 | return @data 184 | 185 | expand: => 186 | {:templates, :maxDepth, :sourceAt, :rolling, :sourceKeys} = templateData 187 | vars, rvars = {}, {i, {} for i=0, maxDepth} 188 | 189 | expandTemplates = (val, depth, rOff=0) -> 190 | return switch type val 191 | when "string" 192 | val = val\gsub "@{(.-):(.-)}", (name, key) -> 193 | if type(vars[name]) == "table" or type(rvars[depth+rOff]) == "table" 194 | vars[name][key] or rvars[depth+rOff][name][key] 195 | val\gsub "@{(.-)}", (name) -> vars[name] or rvars[depth+rOff][name] 196 | when "table" 197 | {k, expandTemplates v, depth, rOff for k, v in pairs val} 198 | else val 199 | 200 | 201 | recurse = (obj, depth = 1, parentKey = "", upKey = "") -> 202 | -- collect regular template variables first 203 | for name in *sourceAt[depth] 204 | with templates[name] 205 | if not .key 206 | -- template variables are not expanded if they are keys 207 | vars[name] = parentKey if .parentKeys[upKey] 208 | elseif .key and obj[.key] 209 | -- expand other templates used in template variable 210 | obj[.key] = expandTemplates obj[.key], depth 211 | vars[name] = obj[.key] 212 | vars[name] = vars[name]\gsub(.repl, .to) if .repl 213 | 214 | -- update rolling template variables last 215 | for name,_ in pairs rolling 216 | rvars[depth][name] = obj[templates[name].key] or rvars[depth-1][name] or "" 217 | rvars[depth][name] = expandTemplates rvars[depth][name], depth, -1 218 | obj[templates[name].key] and= rvars[depth][name] 219 | 220 | -- expand variables in non-template strings and recurse tables 221 | for k,v in pairs obj 222 | if sourceKeys[k] ~= depth and not rolling[k] 223 | switch type v 224 | when "string" 225 | obj[k] = expandTemplates obj[k], depth 226 | when "table" 227 | recurse v, depth+1, k, parentKey 228 | -- invalidate template variables created at depth+1 229 | vars[name] = nil for name in *sourceAt[depth+1] 230 | rvars[depth+1] = {} 231 | 232 | recurse @data 233 | 234 | if @dumpExpanded 235 | handle = io.open @fileName\gsub(".json$", ".exp.json"), "w" 236 | handle\write(json.encode @data)\close! 237 | 238 | return @data 239 | 240 | getScript: (namespace, scriptType, config, autoChannel) => 241 | section = @@ScriptType.name.legacy[scriptType] 242 | scriptData = @data[section][namespace] 243 | return false unless scriptData 244 | ScriptUpdateRecord namespace, scriptData, config, scriptType, autoChannel, @logger 245 | 246 | getMacro: (namespace, config, autoChannel) => 247 | @getScript namespace, false, config, autoChannel 248 | 249 | getModule: (namespace, config, autoChannel) => 250 | @getScript namespace, true, config, autoChannel -------------------------------------------------------------------------------- /modules/DependencyControl/Updater.moon: -------------------------------------------------------------------------------- 1 | lfs = require "lfs" 2 | DownloadManager = require "DM.DownloadManager" 3 | PreciseTimer = require "PT.PreciseTimer" 4 | 5 | UpdateFeed = require "l0.DependencyControl.UpdateFeed" 6 | fileOps = require "l0.DependencyControl.FileOps" 7 | Logger = require "l0.DependencyControl.Logger" 8 | Common = require "l0.DependencyControl.Common" 9 | ModuleLoader = require "l0.DependencyControl.ModuleLoader" 10 | DependencyControl = nil 11 | 12 | class UpdaterBase extends Common 13 | @logger = Logger fileBaseName: "DependencyControl.Updater" 14 | msgs = { 15 | updateError: { 16 | [0]: "Couldn't %s %s '%s' because of a paradox: module not found but updater says up-to-date (%s)" 17 | [1]: "Couldn't %s %s '%s' because the updater is disabled." 18 | [2]: "Skipping %s of %s '%s': namespace '%s' doesn't conform to rules." 19 | [3]: "Skipping %s of unmanaged %s '%s'." 20 | [4]: "No remaining feed available to %s %s '%s' from." 21 | [6]: "The %s of %s '%s' failed because no suitable package could be found %s." 22 | [5]: "Skipped %s of %s '%s': Another update initiated by %s is already running." 23 | [7]: "Skipped %s of %s '%s': An internet connection is currently not available." 24 | [10]: "Skipped %s of %s '%s': the update task is already running." 25 | [15]: "Couldn't %s %s '%s' because its requirements could not be satisfied:" 26 | [30]: "Couldn't %s %s '%s': failed to create temporary download directory %s" 27 | [35]: "Aborted %s of %s '%s' because the feed contained a missing or malformed SHA-1 hash for file %s." 28 | [50]: "Couldn't finish %s of %s '%s' because some files couldn't be moved to their target location:\n" 29 | [55]: "%s of %s '%s' succeeded, couldn't be located by the module loader." 30 | [56]: "%s of %s '%s' succeeded, but an error occured while loading the module:\n%s" 31 | [57]: "%s of %s '%s' succeeded, but it's missing a version record." 32 | [58]: "%s of unmanaged %s '%s' succeeded, but an error occured while creating a DependencyControl record: %s" 33 | [100]: "Error (%d) in component %s during %s of %s '%s':\n— %s" 34 | } 35 | updaterErrorComponent: {"DownloadManager (adding download)", "DownloadManager"} 36 | } 37 | 38 | getUpdaterErrorMsg: (code, name, scriptType, isInstall, detailMsg) => 39 | if code <= -100 40 | -- Generic downstream error 41 | return msgs.updateError[100]\format -code, msgs.updaterErrorComponent[math.floor(-code/100)], 42 | @@terms.isInstall[isInstall], @@terms.scriptType.singular[scriptType], name, detailMsg 43 | else 44 | -- Updater error: 45 | return msgs.updateError[-code]\format @@terms.isInstall[isInstall], 46 | @@terms.scriptType.singular[scriptType], 47 | name, detailMsg 48 | 49 | class UpdateTask extends UpdaterBase 50 | dlm = DownloadManager! 51 | msgs = { 52 | checkFeed: { 53 | downloadFailed: "Failed to download feed: %s" 54 | noData: "The feed doesn't have any update information for %s '%s'." 55 | badChannel: "The specified update channel '%s' wasn't present in the feed." 56 | invalidVersion: "The feed contains an invalid version record for %s '%s' (channel: %s): %s." 57 | unsupportedPlatform: "No download available for your platform '%s' (channel: %s)." 58 | noFiles: "No files available to download for your platform '%s' (channel: %s)." 59 | } 60 | run: { 61 | starting: "Starting %s of %s '%s'... " 62 | fetching: "Trying to %sfetch missing %s '%s'..." 63 | feedCandidates: "Trying %d candidate feeds (%s mode)..." 64 | feedTrying: "Checking feed %d/%d (%s)..." 65 | upToDate: "The %s '%s' is up-to-date (v%s)." 66 | alreadyUpdated: "%s v%s has already been installed." 67 | noFeedAvailExt: "(required: %s; installed: %s; available: %s)" 68 | noUpdate: "Feed has no new update." 69 | skippedOptional: "Skipped %s of optional dependency '%s': %s" 70 | optionalNoFeed: "No feed available to download module from." 71 | optionalNoUpdate: "No suitable download could be found %s." 72 | } 73 | 74 | performUpdate: { 75 | updateReqs: "Checking requirements..." 76 | updateReady: "Update ready. Using temporary directory '%s'." 77 | fileUnchanged: "Skipped unchanged file '%s'." 78 | fileAddDownload: "Added Download %s ==> '%s'." 79 | filesDownloading: "Downloading %d files..." 80 | movingFiles: "Downloads complete. Now moving files to Aegisub automation directory '%s'..." 81 | movedFile: "Moved '%s' ==> '%s'." 82 | moveFileFailed: "Failed to move '%s' ==> '%s': %s" 83 | updSuccess: "%s of %s '%s' (v%s) complete." 84 | reloadNotice: "Please rescan your autoload directory for the changes to take effect." 85 | unknownType: "Skipping file '%s': unknown type '%s'." 86 | } 87 | refreshRecord: { 88 | unsetVirtual: "Update initated by another macro already fetched %s '%s', switching to update mode." 89 | otherUpdate: "Update initated by another macro already updated %s '%s' to v%s." 90 | } 91 | } 92 | 93 | new: (@record, targetVersion = 0, @addFeeds, @exhaustive, @channel, @optional, @updater) => 94 | DependencyControl or= require "l0.DependencyControl" 95 | assert @record.__class == DependencyControl, "First parameter must be a #{DependencyControl.__name} object." 96 | 97 | @logger = @updater.logger 98 | @triedFeeds = {} 99 | @status = nil 100 | @targetVersion = DependencyControl\parseVersion targetVersion 101 | 102 | -- set UpdateFeed settings 103 | @feedConfig = { 104 | downloadPath: aegisub.decode_path "?user/feedDump/" 105 | dumpExpanded: true 106 | } if @updater.config.c.dumpFeeds 107 | 108 | return nil, -1 unless @updater.config.c.updaterEnabled -- TODO: check if this even works 109 | return nil, -2 unless @record\validateNamespace! 110 | 111 | set: (targetVersion, @addFeeds, @exhaustive, @channel, @optional) => 112 | @targetVersion = DependencyControl\parseVersion targetVersion 113 | return @ 114 | 115 | checkFeed: (feedUrl) => 116 | -- get feed contents 117 | feed = UpdateFeed feedUrl, false, nil, @feedConfig, @logger 118 | unless feed.data -- no cached data available, perform download 119 | success, err = feed\fetch! 120 | unless success 121 | return nil, msgs.checkFeed.downloadFailed\format err 122 | 123 | -- select our script and update channel 124 | updateRecord = feed\getScript @record.namespace, @record.scriptType, @record.config, false 125 | unless updateRecord 126 | return nil, msgs.checkFeed.noData\format @@terms.scriptType.singular[@record.scriptType], @record.name 127 | 128 | success, currentChannel = updateRecord\setChannel @channel 129 | unless success 130 | return nil, msgs.checkFeed.badChannel\format currentChannel 131 | 132 | -- check if an update is available and satisfies our requirements 133 | res, version = @record\checkVersion updateRecord.version 134 | if res == nil 135 | return nil, msgs.checkFeed.invalidVersion\format @@terms.scriptType.singular[@record.scriptType], 136 | @record.name, currentChannel, tostring updateRecord.version 137 | elseif res or @targetVersion > version 138 | return false, nil, version 139 | 140 | -- check if our platform is supported/files are available to download 141 | res, platform = updateRecord\checkPlatform! 142 | unless res 143 | return nil, msgs.checkFeed.unsupportedPlatform\format platform, currentChannel 144 | if #updateRecord.files == 0 145 | return nil, msgs.checkFeed.noFiles\format platform, currentChannel 146 | 147 | return true, updateRecord, version 148 | 149 | 150 | run: (waitLock, exhaustive = @updater.config.c.tryAllFeeds or @@exhaustive) => 151 | logUpdateError = (code, extErr, virtual = @virtual) -> 152 | if code < 0 153 | @logger\log @getUpdaterErrorMsg code, @record.name, @record.scriptType, virtual, extErr 154 | return code, extErr 155 | 156 | with @record do @logger\log msgs.run.starting, @@terms.isInstall[.virtual], 157 | @@terms.scriptType.singular[.scriptType], .name 158 | 159 | -- don't perform update of a script when another one is already running for the same script 160 | return logUpdateError -10 if @running 161 | 162 | -- check if the script was already updated 163 | if @updated and not exhaustive and @record\checkVersion @targetVersion 164 | @logger\log msgs.run.alreadyUpdated, @record.name, DependencyControl\getVersionString @record.version 165 | return 2 166 | 167 | -- build feed list 168 | userFeed, haveFeeds, feeds = @record.config.c.userFeed, {}, {} 169 | if userFeed and not @triedFeeds[userFeed] 170 | feeds[1] = userFeed 171 | else 172 | unless @triedFeeds[@record.feed] or haveFeeds[@record.feed] 173 | feeds[1] = @record.feed 174 | for feed in *@addFeeds 175 | unless @triedFeeds[feed] or haveFeeds[feed] 176 | feeds[#feeds+1] = feed 177 | haveFeeds[feed] = true 178 | 179 | for feed in *@updater.config.c.extraFeeds 180 | unless @triedFeeds[feed] or haveFeeds[feed] 181 | feeds[#feeds+1] = feed 182 | haveFeeds[feed] = true 183 | 184 | if #feeds == 0 185 | if @optional 186 | @logger\log msgs.run.skippedOptional, @record.name, 187 | @@terms.isInstall[@record.virtual], msgs.run.optionalNoFeed 188 | return 3 189 | 190 | return logUpdateError -4 191 | 192 | -- check internet connection 193 | return logUpdateError -7 unless dlm\isInternetConnected! 194 | 195 | -- get a lock on the updater 196 | success, otherHost = @updater\getLock waitLock 197 | return logUpdateError -5, otherHost unless success 198 | 199 | -- check feeds for update until we find and update or run out of feeds to check 200 | -- normal mode: check feeds until an update matching the required version is found 201 | -- exhaustive mode: check all feeds for updates and pick the highest version 202 | 203 | @logger\log msgs.run.feedCandidates, #feeds, exhaustive and "exhaustive" or "normal" 204 | @logger.indent += 1 205 | 206 | maxVer, updateRecord = 0 207 | for i, feed in ipairs feeds 208 | @logger\log msgs.run.feedTrying, i, #feeds, feed 209 | 210 | res, rec, version = @checkFeed feed 211 | @triedFeeds[feed] = true 212 | if res == nil 213 | @logger\log rec 214 | elseif version > maxVer 215 | maxVer = version 216 | if res 217 | updateRecord = rec 218 | break unless exhaustive 219 | else @logger\trace msgs.run.noUpdate 220 | else 221 | @logger\trace msgs.run.noUpdate 222 | 223 | @logger.indent -= 1 224 | 225 | local code, res 226 | wasVirtual = @record.virtual 227 | unless updateRecord 228 | -- for a script to be marked up-to-date it has to installed on the user's system 229 | -- and the version must at least be that returned by at least one feed 230 | if maxVer>0 and not @record.virtual and @targetVersion <= @record.version 231 | @logger\log msgs.run.upToDate, @@terms.scriptType.singular[@record.scriptType], 232 | @record.name, DependencyControl\getVersionString @record.version 233 | return 0 234 | 235 | res = msgs.run.noFeedAvailExt\format @targetVersion == 0 and "any" or DependencyControl\getVersionString(@targetVersion), 236 | @record.virtual and "no" or DependencyControl\getVersionString(@record.version), 237 | maxVer<1 and "none" or DependencyControl\getVersionString maxVer 238 | 239 | if @optional 240 | @logger\log msgs.run.skippedOptional, @record.name, @@terms.isInstall[@record.virtual], 241 | msgs.run.optionalNoUpdate\format res 242 | return 3 243 | 244 | return logUpdateError -6, res 245 | 246 | code, res = @performUpdate updateRecord 247 | return logUpdateError code, res, wasVirtual 248 | 249 | performUpdate: (update) => 250 | finish = (...) -> 251 | @running = false 252 | if @record.virtual or @record.recordType == @@RecordType.Unmanaged 253 | ModuleLoader.removeDummyRef @record 254 | return ... 255 | 256 | -- don't perform update of a script when another one is already running for the same script 257 | return finish -10 if @running 258 | @running = true 259 | 260 | -- set a dummy ref (which hasn't yet been set for virtual and unmanaged modules) 261 | -- and record version to allow resolving circular dependencies 262 | if @record.virtual or @record.recordType == @@RecordType.Unmanaged 263 | ModuleLoader.createDummyRef @record 264 | @record\setVersion update.version 265 | 266 | -- try to load required modules first to see if all dependencies are satisfied 267 | -- this may trigger more updates 268 | reqs = update.requiredModules 269 | if reqs and #reqs > 0 270 | @logger\log msgs.performUpdate.updateReqs 271 | @logger.indent += 1 272 | success, err = ModuleLoader.loadModules @record, reqs, {@record.feed} 273 | @logger.indent -= 1 274 | unless success 275 | @logger.indent += 1 276 | @logger\log err 277 | @logger.indent -= 1 278 | return finish -15, err 279 | 280 | -- since circular dependencies are possible, our task may have completed in the meantime 281 | -- so check again if we still need to update 282 | return finish 2 if @updated and @record\checkVersion update.version 283 | 284 | 285 | -- download updated scripts to temp directory 286 | -- check hashes before download, only update changed files 287 | 288 | tmpDir = aegisub.decode_path "?temp/l0.#{DependencyControl.__name}_#{'%04X'\format math.random 0, 16^4-1}" 289 | res, dir = fileOps.mkdir tmpDir 290 | return finish -30, "#{tmpDir} (#{dir})" if res == nil 291 | 292 | @logger\log msgs.performUpdate.updateReady, tmpDir 293 | 294 | scriptSubDir = @record.namespace 295 | scriptSubDir = scriptSubDir\gsub "%.","/" if @record.scriptType == @@ScriptType.Module 296 | 297 | dlm\clear! 298 | for file in *update.files 299 | file.type or= "script" 300 | 301 | baseName = scriptSubDir .. file.name 302 | tmpName, prettyName = "#{tmpDir}/#{file.type}/#{baseName}", baseName 303 | switch file.type 304 | when "script" 305 | file.fullName = "#{@record.automationDir}/#{baseName}" 306 | when "test" 307 | file.fullName = "#{@record.testDir}/#{baseName}" 308 | prettyName ..= " (Unit Test)" 309 | else 310 | file.unknown = true 311 | @logger\log msgs.performUpdate.unknownType, file.name, file.type 312 | continue 313 | continue if file.delete 314 | 315 | unless type(file.sha1)=="string" and #file.sha1 == 40 and tonumber(file.sha1, 16) 316 | return finish -35, "#{prettyName} (#{tostring(file.sha1)\lower!})" 317 | 318 | if dlm\checkFileSHA1 file.fullName, file.sha1 319 | @logger\trace msgs.performUpdate.fileUnchanged, prettyName 320 | continue 321 | 322 | dl, err = dlm\addDownload file.url, tmpName, file.sha1 323 | return finish -140, err unless dl 324 | dl.targetFile = file.fullName 325 | @logger\trace msgs.performUpdate.fileAddDownload, file.url, prettyName 326 | 327 | dlm\waitForFinish (progress) -> 328 | @logger\progress progress, msgs.performUpdate.filesDownloading, #dlm.downloads 329 | return true 330 | @logger\progress! 331 | 332 | if #dlm.failedDownloads>0 333 | err = @logger\format ["#{dl.url}: #{dl.error}" for dl in *dlm.failedDownloads], 1 334 | return finish -245, err 335 | 336 | 337 | -- move files to their destination directory and clean up 338 | 339 | @logger\log msgs.performUpdate.movingFiles, @record.automationDir 340 | moveErrors = {} 341 | @logger.indent += 1 342 | for dl in *dlm.downloads 343 | res, err = fileOps.move dl.outfile, dl.targetFile, true 344 | -- don't immediately error out if moving of a single file failed 345 | -- try to move as many files as possible and let the user handle the rest 346 | if res 347 | @logger\trace msgs.performUpdate.movedFile, dl.outfile, dl.targetFile 348 | else 349 | @logger\log msgs.performUpdate.moveFileFailed, dl.outfile, dl.targetFile, err 350 | moveErrors[#moveErrors+1] = err 351 | @logger.indent -= 1 352 | 353 | if #moveErrors>0 354 | return finish -50, @logger\format moveErrors, 1 355 | else lfs.rmdir tmpDir 356 | os.remove file.fullName for file in *update.files when file.delete and not file.unknown 357 | 358 | -- Nuke old module refs and reload 359 | oldVer, wasVirtual = @record.version, @record.virtual 360 | 361 | -- Update complete, refresh module information/configuration 362 | if @record.scriptType == @@ScriptType.Module 363 | ref = ModuleLoader.loadModule @record, @record, false, true 364 | unless ref 365 | if @record._error 366 | return finish -56, @logger\format @record._error, 1 367 | else return finish -55 368 | 369 | -- get a fresh version record 370 | if type(ref.version) == "table" and ref.version.__class.__name == DependencyControl.__name 371 | @record = ref.version 372 | else 373 | -- look for any compatible non-DepCtrl version records and create an unmanaged record 374 | return finish -57 unless ref.version 375 | success, rec = pcall DependencyControl, { moduleName: @record.moduleName, version: ref.version, 376 | recordType: @@RecordType.Unmanaged, name: @record.name } 377 | return finish -58, rec unless success 378 | @record = rec 379 | @ref = ref 380 | 381 | else with @record 382 | .name, .version, .virtual = @record.name, DependencyControl\parseVersion update.version 383 | @record\writeConfig! 384 | 385 | @updated = true 386 | @logger\log msgs.performUpdate.updSuccess, @@terms.capitalize(@@terms.isInstall[wasVirtual]), 387 | @@terms.scriptType.singular[@record.scriptType], 388 | @record.name, DependencyControl\getVersionString @record.version 389 | 390 | -- Diplay changelog 391 | @logger\log update\getChangelog @record, (DependencyControl\parseVersion oldVer) + 1 392 | @logger\log msgs.performUpdate.reloadNotice 393 | 394 | -- TODO: check handling of private module copies (need extra return value?) 395 | return finish 1, DependencyControl\getVersionString @record.version 396 | 397 | 398 | refreshRecord: => 399 | with @record 400 | wasVirtual, oldVersion = .virtual, .version 401 | \loadConfig true 402 | if wasVirtual and not .virtual or .version > oldVersion 403 | @updated = true 404 | @ref = ModuleLoader.loadModule @record, @record, false, true if .scriptType == @@ScriptType.Module 405 | if wasVirtual 406 | @logger\log msgs.refreshRecord.unsetVirtual, @@terms.scriptType.singular[.scriptType], .name 407 | else 408 | @logger\log msgs.refreshRecord.otherUpdate, @@terms.scriptType.singular[.scriptType], .name, 409 | DependencyControl\getVersionString @record.version 410 | 411 | class Updater extends UpdaterBase 412 | msgs = { 413 | getLock: { 414 | orphaned: "Ignoring orphaned in-progress update started by %s." 415 | waitFinished: "Waited %d seconds." 416 | abortWait: "Timeout reached after %d seconds." 417 | waiting: "Waiting for update intiated by %s to finish..." 418 | } 419 | require: { 420 | macroPassed: "%s is not a module." 421 | upToDate: "Tried to require an update for up-to-date module '%s'." 422 | } 423 | scheduleUpdate: { 424 | updaterDisabled: "Skipping update check for %s (Updater disabled)." 425 | runningUpdate: "Running scheduled update for %s '%s'..." 426 | } 427 | } 428 | new: (@host = script_namespace, @config, @logger = @@logger) => 429 | @tasks = {scriptType, {} for _, scriptType in pairs @@ScriptType when "number" == type scriptType} 430 | 431 | addTask: (record, targetVersion, addFeeds = {}, exhaustive, channel, optional) => 432 | DependencyControl or= require "l0.DependencyControl" 433 | if record.__class != DependencyControl 434 | depRec = {saveRecordToConfig: false, readGlobalScriptVars: false} 435 | depRec[k] = v for k, v in pairs record 436 | record = DependencyControl depRec 437 | 438 | task = @tasks[record.scriptType][record.namespace] 439 | if task 440 | return task\set targetVersion, addFeeds, exhaustive, channel, optional 441 | else 442 | task, err = UpdateTask record, targetVersion, addFeeds, exhaustive, channel, optional, @ 443 | @tasks[record.scriptType][record.namespace] = task 444 | return task, err 445 | 446 | require: (record, ...) => 447 | @logger\assert record.scriptType == @@ScriptType.Module, msgs.require, record.name or record.namespace 448 | @logger\log "%s module '%s'...", record.virtual and "Installing required" or "Updating outdated", record.name 449 | task, code = @addTask record, ... 450 | code, res = task\run true if task 451 | 452 | if code == 0 and not task.updated 453 | -- usually we know in advance if a module is up to date so there's no reason to block other updaters 454 | -- but we'll make sure to handle this case gracefully, anyway 455 | @logger\debug msgs.require.upToDate, task.record.name or task.record.namespace 456 | return ModuleLoader.loadModule task.record, task.record.namespace 457 | elseif code >= 0 458 | return task.ref 459 | else -- pass on update errors 460 | return nil, code, res 461 | 462 | scheduleUpdate: (record) => 463 | unless @config.c.updaterEnabled 464 | @logger\trace msgs.scheduleUpdate.updaterDisabled, record.name or record.namespace 465 | return -1 466 | 467 | -- no regular updates for non-existing or unmanaged modules 468 | if record.virtual or record.recordType == @@RecordType.Unmanaged 469 | return -3 470 | 471 | -- the update interval has not yet been passed since the last update check 472 | if record.config.c.lastUpdateCheck and (record.config.c.lastUpdateCheck + @config.c.updateInterval > os.time!) 473 | return false 474 | 475 | record.config.c.lastUpdateCheck = os.time! 476 | record.config\write! 477 | 478 | task = @addTask record -- no need to check for errors, because we've already accounted for those case 479 | @logger\trace msgs.scheduleUpdate.runningUpdate, @@terms.scriptType.singular[record.scriptType], record.name 480 | return task\run! 481 | 482 | 483 | getLock: (doWait, waitTimeout = @config.c.updateWaitTimeout) => 484 | return true if @hasLock 485 | 486 | @config\load! 487 | running, didWait = @config.c.updaterRunning 488 | 489 | if running and running.host != @host 490 | if running.time + @config.c.updateOrphanTimeout < os.time! 491 | @logger\log msgs.getLock.orphaned, running.host 492 | elseif doWait 493 | @logger\log msgs.getLock.waiting, running.host 494 | timeout, didWait = waitTimeout, true 495 | while running and timeout > 0 496 | PreciseTimer.sleep 1000 497 | timeout -= 1 498 | @config\load! 499 | running = @config.c.updaterRunning 500 | @logger\log timeout <= 0 and msgs.getLock.abortWait or msgs.getLock.waitFinished, 501 | waitTimeout - timeout 502 | 503 | else return false, running.host 504 | 505 | -- register the running update in the config file to prevent collisions 506 | -- with other scripts trying to update the same modules 507 | -- TODO: store this flag in the db 508 | 509 | @config.c.updaterRunning = host: @host, time: os.time! 510 | @config\write! 511 | @hasLock = true 512 | 513 | -- reload important module version information from configuration 514 | -- because another updater instance might have updated them in the meantime 515 | if didWait 516 | task\refreshRecord! for _,task in pairs @tasks[@@ScriptType.Module] 517 | 518 | return true 519 | 520 | releaseLock: => 521 | return false unless @hasLock 522 | @hasLock = false 523 | @config.c.updaterRunning = false 524 | @config\write! --------------------------------------------------------------------------------