├── .circleci └── config.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── encodings.xml ├── jsLibraryMappings.xml ├── misc.xml ├── modules.xml ├── parser.iml └── vcs.xml ├── .npmignore ├── CHANGELOG.md ├── LICENCE ├── README.md ├── package.json ├── src ├── AES256.ts ├── AES256GZ.ts ├── Asset.ts ├── FlatNotepad.ts ├── NPXObject.ts ├── Note.ts ├── Notepad.ts ├── SearchIndex.ts ├── Section.ts ├── Translators.ts ├── crypto.ts ├── date-formats.ts ├── index.ts ├── interfaces.ts ├── move-notes │ ├── move-notes.spec.ts │ └── move-notes.ts └── tests │ ├── Asset.spec.ts │ ├── FlatNotepad.spec.ts │ ├── Note.spec.ts │ ├── Notepad.spec.ts │ ├── SearchIndex.spec.ts │ ├── Section.spec.ts │ ├── TestSetup.ts │ ├── TestUtils.ts │ ├── Translators.spec.ts │ ├── __data__ │ ├── Broken.npx │ ├── EmptyContent.npx │ ├── Example Notepad.npx │ ├── Help.npx │ ├── notebook.ipynb │ └── sample-enex.enex │ ├── __snapshots__ │ ├── Asset.spec.ts.snap │ ├── FlatNotepad.spec.ts.snap │ ├── Note.spec.ts.snap │ ├── Notepad.spec.ts.snap │ ├── SearchIndex.spec.ts.snap │ ├── Section.spec.ts.snap │ └── Translators.spec.ts.snap │ └── crypto.spec.ts ├── tsconfig.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Javascript Node CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | test: 8 | docker: 9 | # specify the version you desire here 10 | - image: node:lts 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | - restore_cache: 23 | name: Restore Yarn Package Cache 24 | keys: 25 | - yarn-packages-{{ .Branch }}-{{ checksum "yarn.lock" }} 26 | - yarn-packages-{{ .Branch }} 27 | - yarn-packages-master 28 | - yarn-packages- 29 | 30 | - run: yarn 31 | 32 | - save_cache: 33 | name: Save Yarn Package Cache 34 | key: yarn-packages-{{ .Branch }}-{{ checksum "yarn.lock" }} 35 | paths: 36 | - node_modules/ 37 | 38 | - run: yarn test 39 | 40 | - persist_to_workspace: 41 | root: ~/repo 42 | paths: . 43 | 44 | publish: 45 | docker: 46 | - image: circleci/node:lts 47 | working_directory: ~/repo 48 | 49 | steps: 50 | - attach_workspace: 51 | at: ~/repo 52 | 53 | - run: yarn build 54 | 55 | - run: 56 | name: Authenticate with registry 57 | command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc 58 | 59 | - run: npm publish 60 | 61 | update-docs: 62 | docker: 63 | - image: circleci/node:lts 64 | working_directory: ~/repo 65 | 66 | steps: 67 | - attach_workspace: 68 | at: ~/repo 69 | 70 | - run: yarn docs 71 | 72 | - run: sudo apt install rsync 73 | 74 | - run: rsync -aue "ssh -p 1276 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" docs/* $SSH_PATH 75 | 76 | workflows: 77 | version: 2 78 | test-and-publish: 79 | jobs: 80 | - test 81 | - publish: 82 | requires: 83 | - test 84 | filters: 85 | branches: 86 | only: master 87 | - update-docs: 88 | requires: 89 | - test 90 | filters: 91 | branches: 92 | only: master 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | dist/ 3 | 4 | # Created by https://www.gitignore.io/api/node,webstorm,sublimetext 5 | 6 | ### Node ### 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | ### SublimeText ### 67 | # cache files for sublime text 68 | *.tmlanguage.cache 69 | *.tmPreferences.cache 70 | *.stTheme.cache 71 | 72 | # workspace files are user-specific 73 | *.sublime-workspace 74 | 75 | # project files should be checked into the repository, unless a significant 76 | # proportion of contributors will probably not be using SublimeText 77 | # *.sublime-project 78 | 79 | # sftp configuration file 80 | sftp-config.json 81 | 82 | # Package control specific files 83 | Package Control.last-run 84 | Package Control.ca-list 85 | Package Control.ca-bundle 86 | Package Control.system-ca-bundle 87 | Package Control.cache/ 88 | Package Control.ca-certs/ 89 | Package Control.merged-ca-bundle 90 | Package Control.user-ca-bundle 91 | oscrypto-ca-bundle.crt 92 | bh_unicode_properties.cache 93 | 94 | # Sublime-github package stores a github token in this file 95 | # https://packagecontrol.io/packages/sublime-github 96 | GitHub.sublime-settings 97 | 98 | ### WebStorm ### 99 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 100 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 101 | 102 | # User-specific stuff: 103 | workspace.xml 104 | .idea/**/workspace.xml 105 | .idea/**/tasks.xml 106 | .idea/dictionaries 107 | 108 | # Sensitive or high-churn files: 109 | .idea/**/dataSources/ 110 | .idea/**/dataSources.ids 111 | .idea/**/dataSources.xml 112 | .idea/**/dataSources.local.xml 113 | .idea/**/sqlDataSources.xml 114 | .idea/**/dynamic.xml 115 | .idea/**/uiDesigner.xml 116 | 117 | # Gradle: 118 | .idea/**/gradle.xml 119 | .idea/**/libraries 120 | 121 | # CMake 122 | cmake-build-debug/ 123 | 124 | # Mongo Explorer plugin: 125 | .idea/**/mongoSettings.xml 126 | 127 | ## File-based project format: 128 | *.iws 129 | 130 | ## Plugin-specific files: 131 | 132 | # IntelliJ 133 | /out/ 134 | 135 | # mpeltonen/sbt-idea plugin 136 | .idea_modules/ 137 | 138 | # JIRA plugin 139 | atlassian-ide-plugin.xml 140 | 141 | # Cursive Clojure plugin 142 | .idea/replstate.xml 143 | 144 | # Ruby plugin and RubyMine 145 | /.rakeTasks 146 | 147 | # Crashlytics plugin (for Android Studio and IntelliJ) 148 | com_crashlytics_export_strings.xml 149 | crashlytics.properties 150 | crashlytics-build.properties 151 | fabric.properties 152 | 153 | ### WebStorm Patch ### 154 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 155 | 156 | # *.iml 157 | # modules.xml 158 | # .idea/misc.xml 159 | # *.ipr 160 | 161 | # Sonarlint plugin 162 | .idea/sonarlint 163 | 164 | 165 | # End of https://www.gitignore.io/api/node,webstorm,sublimetext 166 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/parser.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !dist/*.* 3 | !dist/**/*.* 4 | !LICENCE 5 | !package.json 6 | !yarn.lock 7 | !README.md 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | This log will note all breaking changes and deprecations. 2 | 3 | # 7.0.0 4 | - Exported JS is now ES2017 instead of ES2015. 5 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # upad-parse 2 | This is the parser used for [MicroPad](https://getmicropad.com). 3 | 4 | You can find the docs here: . 5 | 6 | ## Install 7 | ## JavaScript 8 | `npm install --save upad-parse` or `yarn add upad-parse` 9 | ```JavaScript 10 | const Notepad = require('upad-parse').Notepad; 11 | 12 | let notepad = new Notepad('Test Notepad'); 13 | notepad.toXml().then(xml => console.log(xml)); 14 | ``` 15 | 16 | ### Typescript 17 | ```TypeScript 18 | import { Notepad } from 'upad-parse/dist'; 19 | 20 | let notepad = new Notepad('Test Notepad'); 21 | notepad.toXml().then(xml => console.log(xml)); 22 | ``` 23 | 24 | ## Browser 25 | ```html 26 | 27 | 31 | ``` 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upad-parse", 3 | "version": "7.5.2", 4 | "description": "This is a parser for the NPX file format that µPad uses", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "yarn clean && tsc -p tsconfig.json && yarn build:browser", 8 | "build:browser": "cp -r node_modules/timers-browserify node_modules/timers && esbuild src/index.ts --bundle --minify --sourcemap --global-name='NPXParser' --target=chrome86,firefox84,safari12 --outfile=dist/upad-parse.browser.js", 9 | "clean": "rm -rf ./dist && rm -rf node_modules/timers", 10 | "test": "TZ=NZ jest", 11 | "docs": "rm -rf ./dist; npx typedoc --out docs/ --excludePrivate src/index.ts" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/MicroPad/Web-Parser.git" 16 | }, 17 | "author": "Nick Webster", 18 | "license": "MPL-2.0", 19 | "bugs": { 20 | "url": "https://github.com/MicroPad/Web-Parser/issues" 21 | }, 22 | "homepage": "https://getmicropad.com", 23 | "keywords": [ 24 | "uPad", 25 | "µPad", 26 | "parse", 27 | "npx", 28 | "parser" 29 | ], 30 | "jest": { 31 | "rootDir": "./src", 32 | "transform": { 33 | "^.+\\.tsx?$": "ts-jest" 34 | }, 35 | "testRegex": "((\\.|/)(test|spec))\\.(jsx?|tsx?)$", 36 | "moduleFileExtensions": [ 37 | "ts", 38 | "tsx", 39 | "js", 40 | "jsx", 41 | "json", 42 | "node" 43 | ], 44 | "testURL": "http://localhost", 45 | "setupFilesAfterEnv": [ 46 | "./tests/TestSetup.ts" 47 | ] 48 | }, 49 | "dependencies": { 50 | "aes-js": "^3.1.2", 51 | "buffer": "^6.0.3", 52 | "date-fns": "^2.21.3", 53 | "events": "^3.3.0", 54 | "json-stringify-safe": "^5.0.1", 55 | "lz-string": "^1.4.4", 56 | "scrypt-js": "^3.0.0", 57 | "string_decoder": "^1.3.0", 58 | "timers-browserify": "^2.0.12", 59 | "turndown": "^7.0.0", 60 | "turndown-plugin-gfm": "^1.0.2", 61 | "xml2js": "^0.4.19" 62 | }, 63 | "devDependencies": { 64 | "@types/jest": "^26.0.24", 65 | "@types/json-stringify-safe": "^5.0.0", 66 | "@types/lz-string": "^1.3.33", 67 | "@types/xml2js": "^0.4.4", 68 | "esbuild": "~0.14.11", 69 | "jest": "^26.6.3", 70 | "ts-jest": "^26.5.6", 71 | "typedoc": "^0.20.0", 72 | "typescript": "~4.2" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/AES256.ts: -------------------------------------------------------------------------------- 1 | import { NotepadShell } from './interfaces'; 2 | import Notepad from './Notepad'; 3 | import buffer from 'scrypt-js/thirdparty/buffer'; 4 | import * as AES from 'aes-js'; 5 | import { Translators } from './Translators'; 6 | import { EncryptionMethodImpl } from './crypto'; 7 | import * as scrypt from 'scrypt-js'; 8 | 9 | 10 | export class AES256 implements EncryptionMethodImpl { 11 | async decrypt(notepad: NotepadShell, passkey: string): Promise { 12 | const cipherText = notepad.sections as string; 13 | const key = await this.keyGenerator(passkey); 14 | const controller = new AES.ModeOfOperation.ctr(key); 15 | 16 | const plainText = AES.utils.utf8.fromBytes(controller.decrypt(AES.utils.hex.toBytes(cipherText))); 17 | notepad = { ...notepad, sections: JSON.parse(plainText) }; 18 | 19 | return Translators.Json.toNotepadFromNotepad(notepad); 20 | } 21 | 22 | async encrypt(notepad: Notepad, passkey: string): Promise { 23 | const plainText = AES256.stringifyNotepadObj(notepad.sections); 24 | const key = await this.keyGenerator(passkey); 25 | const controller = new AES.ModeOfOperation.ctr(key); 26 | 27 | const cipherText = AES.utils.hex.fromBytes(controller.encrypt(AES.utils.utf8.toBytes(plainText))); 28 | return { ...notepad, sections: cipherText }; 29 | } 30 | 31 | protected keyGenerator(passkey: string): Promise { 32 | passkey = passkey.normalize('NFKC'); 33 | const passkeyBuff = new buffer.SlowBuffer(passkey); 34 | 35 | return scrypt.scrypt(passkeyBuff, new buffer.SlowBuffer(''), 1024, 8, 1, 32); 36 | } 37 | 38 | protected static stringifyNotepadObj(obj: object): string { 39 | return JSON.stringify(obj, (key, value) => { 40 | return (key === 'parent') ? undefined : value; 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/AES256GZ.ts: -------------------------------------------------------------------------------- 1 | import { AES256 } from './AES256'; 2 | import { EncryptionMethodImpl } from './crypto'; 3 | import { NotepadShell } from './interfaces'; 4 | import Notepad from './Notepad'; 5 | import { Translators } from './Translators'; 6 | import * as AES from 'aes-js'; 7 | import { compressToUTF16, decompressFromUTF16 } from 'lz-string'; 8 | 9 | export class AES256GZ extends AES256 implements EncryptionMethodImpl { 10 | async decrypt(notepad: NotepadShell, passkey: string): Promise { 11 | const cipherText = notepad.sections as string; 12 | const key = await this.keyGenerator(passkey); 13 | const controller = new AES.ModeOfOperation.ctr(key); 14 | 15 | const plainTextGz = AES.utils.utf8.fromBytes(controller.decrypt(AES.utils.hex.toBytes(cipherText))); 16 | const plainText = decompressFromUTF16(plainTextGz); 17 | if (!plainText) throw new Error(`The notebook couldn't be decrypted`); 18 | 19 | try { 20 | notepad = { ...notepad, sections: JSON.parse(plainText) }; 21 | } catch(e) { 22 | throw new Error(`The notebook couldn't be decrypted`); 23 | } 24 | 25 | return Translators.Json.toNotepadFromNotepad(notepad); 26 | } 27 | 28 | async encrypt(notepad: Notepad, passkey: string): Promise { 29 | const plainText = AES256.stringifyNotepadObj(notepad.sections); 30 | const plainTextGz = compressToUTF16(plainText); 31 | 32 | const key = await this.keyGenerator(passkey); 33 | const controller = new AES.ModeOfOperation.ctr(key); 34 | 35 | const cipherText = AES.utils.hex.fromBytes(controller.encrypt(AES.utils.utf8.toBytes(plainTextGz))); 36 | return { ...notepad, sections: cipherText }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Asset.ts: -------------------------------------------------------------------------------- 1 | import { FileReaderEventTarget } from "./interfaces"; 2 | 3 | export default class Asset { 4 | public readonly uuid: string; 5 | 6 | constructor( 7 | public data: Blob, 8 | uuid?: string 9 | ) { 10 | this.uuid = uuid || this.generateGuid(); 11 | } 12 | 13 | /** 14 | * @returns {Promise} The Asset's content in Base64 15 | */ 16 | public toString(): Promise { 17 | return new Promise(resolve => { 18 | try { 19 | const reader = new FileReader(); 20 | reader.onload = event => resolve((event.target as FileReaderEventTarget).result); 21 | reader.readAsDataURL(this.data); 22 | } catch (e) { 23 | resolve(''); 24 | } 25 | }); 26 | } 27 | 28 | /** 29 | * @returns {Promise} A version of the Asset that the XML generator can parse 30 | */ 31 | public async toXmlObject(): Promise { 32 | const b64 = await this.toString(); 33 | 34 | return { 35 | $: { 36 | uuid: this.uuid 37 | }, 38 | _: b64 39 | }; 40 | } 41 | 42 | private generateGuid(): string { 43 | function s4() { 44 | return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); 45 | } 46 | return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/FlatNotepad.ts: -------------------------------------------------------------------------------- 1 | import { Note, Notepad, Section, Trie } from './index'; 2 | import { format, parse } from 'date-fns'; 3 | import { EncryptionMethod } from './crypto'; 4 | import { LAST_MODIFIED_FORMAT } from './date-formats'; 5 | 6 | export type FlatNotepadOptions = { 7 | lastModified?: Date; 8 | notepadAssets?: string[]; 9 | sections?: { [internalRef: string]: FlatSection }; 10 | notes?: { [internalRef: string]: Note }; 11 | crypto?: EncryptionMethod; 12 | }; 13 | 14 | export type FlatSection = { 15 | title: string; 16 | internalRef: string; 17 | parentRef?: string; 18 | }; 19 | 20 | /** 21 | * A FlatNotepad is similar to the {@link Notepad} class, but it stores all the notes/sections 22 | * as in flat structures. FlatNotepads will likely be better for internal use in many situations. 23 | * 24 | * Something to remember is that all operations on this class like addSection, will return a new 25 | * object of this class, and not modify the existing one. 26 | */ 27 | export default class FlatNotepad { 28 | public readonly lastModified: string; 29 | public readonly sections: { [internalRef: string]: FlatSection }; 30 | public readonly notes: { [internalRef: string]: Note }; 31 | public readonly notepadAssets: string[]; 32 | public readonly crypto?: EncryptionMethod; 33 | 34 | constructor( 35 | public readonly title: string, 36 | opts: FlatNotepadOptions = {} 37 | ) { 38 | this.lastModified = format(opts.lastModified || new Date(), LAST_MODIFIED_FORMAT); 39 | this.sections = opts.sections || {}; 40 | this.notes = opts.notes || {}; 41 | this.notepadAssets = opts.notepadAssets || []; 42 | if (opts.crypto) this.crypto = opts.crypto; 43 | } 44 | 45 | static makeFlatSection(title: string, parentRef?: string): FlatSection { 46 | const tmpSection = new Section(title); 47 | return { title: tmpSection.title, internalRef: tmpSection.internalRef, parentRef }; 48 | } 49 | 50 | public addSection(section: FlatSection): FlatNotepad { 51 | return this.clone({ 52 | sections: { 53 | ...this.sections, 54 | [section.internalRef]: section 55 | } 56 | }); 57 | } 58 | 59 | public addNote(note: Note): FlatNotepad { 60 | // Ensure our parent is just a string for the section's internalRef, not the whole Parent object 61 | if (typeof note.parent !== 'string') { 62 | note = note.clone({ 63 | parent: (note.parent as Section).internalRef 64 | }); 65 | } 66 | 67 | return this.clone({ 68 | notes: { 69 | ...this.notes, 70 | [note.internalRef]: note 71 | } 72 | }); 73 | } 74 | 75 | public addAsset(uuid: string): FlatNotepad { 76 | return this.clone({ 77 | notepadAssets: [ 78 | ...this.notepadAssets, 79 | uuid 80 | ] 81 | }); 82 | } 83 | 84 | public modified(lastModified: Date = new Date()): FlatNotepad { 85 | return this.clone({ 86 | lastModified 87 | }); 88 | } 89 | 90 | /** 91 | * Unlike the {@link Notepad}, this uses an indexed lookup system. This should be faster than a 92 | * traditional notepad search. 93 | * 94 | * @param {Trie} trie The search trie for the notepad 95 | * @param {string} query Can either be a title-search or a hashtag-search 96 | * @returns {Note[]} 97 | */ 98 | public search(trie: Trie, query: string): Note[] { 99 | return Trie.search(trie, query).map(ref => this.notes[ref]); 100 | } 101 | 102 | /** 103 | * This will convert everything into the formal {@link Notepad} structure, however no {@link Asset}s will 104 | * be restored. The client should rebuild the assets after this using the values in {@link notepadAssets} 105 | * @returns {Notepad} 106 | */ 107 | public toNotepad(): Notepad { 108 | const buildSection = (flat: FlatSection): Section => { 109 | let section = new Section(flat.title, [], [], flat.internalRef); 110 | 111 | // Restore sub-sections 112 | Object.values(this.sections) 113 | .filter(s => s.parentRef === flat.internalRef) 114 | .map(s => section = section.addSection(buildSection(s))); 115 | 116 | // Restore notes 117 | Object.values(this.notes) 118 | .filter(n => n.parent === flat.internalRef) 119 | .map(n => section = section.addNote(n)); 120 | 121 | return section; 122 | }; 123 | 124 | let notepad = new Notepad(this.title, { 125 | lastModified: parse(this.lastModified, LAST_MODIFIED_FORMAT, new Date()), 126 | notepadAssets: this.notepadAssets, 127 | crypto: this.crypto 128 | }); 129 | 130 | // Add all the sections + notes 131 | Object.values(this.sections) 132 | .filter(s => !s.parentRef) 133 | .forEach(s => notepad = notepad.addSection(buildSection(s))); 134 | 135 | return notepad; 136 | } 137 | 138 | public clone(opts: Partial = {}, title: string = this.title): FlatNotepad { 139 | return new FlatNotepad(title, { 140 | lastModified: parse(this.lastModified, LAST_MODIFIED_FORMAT, new Date()), 141 | sections: this.sections, 142 | notes: this.notes, 143 | notepadAssets: this.notepadAssets, 144 | crypto: this.crypto, 145 | ...opts 146 | }); 147 | } 148 | 149 | public pathFrom(obj: Note | FlatSection): (FlatSection | FlatNotepad)[] { 150 | const parents: FlatSection[] = []; 151 | 152 | if (!!(obj as Note).parent) { // This is a note 153 | obj = this.sections[(obj as Note).parent as string]; 154 | } else { 155 | const parent = (obj as FlatSection).parentRef; 156 | if (!parent) return [this]; 157 | 158 | obj = this.sections[(obj as FlatSection).parentRef!]; 159 | } 160 | 161 | 162 | let tmp: FlatSection = obj; 163 | while (true) { 164 | parents.unshift(tmp); 165 | 166 | if (!tmp.parentRef) return [ this, ...parents ]; 167 | tmp = this.sections[tmp.parentRef]; 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/NPXObject.ts: -------------------------------------------------------------------------------- 1 | import { Parent } from './interfaces'; 2 | import { Note } from './index'; 3 | 4 | export abstract class NPXObject { 5 | public parent: Parent | string | undefined; 6 | public readonly title: string; 7 | public readonly internalRef: string; 8 | 9 | protected constructor( 10 | title: string, 11 | internalRef?: string, 12 | parent?: Parent | string 13 | ) { 14 | this.title = this.clean(title); 15 | this.internalRef = internalRef || this.generateGuid(); 16 | this.parent = parent; 17 | } 18 | 19 | public abstract search(query: string): Note[]; 20 | 21 | /** 22 | * @returns {Promise} A version of the object that the XML generator can parse 23 | */ 24 | public abstract toXmlObject(): any; 25 | 26 | protected generateGuid(): string { 27 | function s4() { 28 | return Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); 29 | } 30 | return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4(); 31 | } 32 | 33 | /** 34 | * @param {string} str 35 | * @returns {string} The string without certain values that could cause parsing issues in the future 36 | */ 37 | protected clean(str: string) { 38 | return str.replace(/<[^>]*>/, ""); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Note.ts: -------------------------------------------------------------------------------- 1 | import { NPXObject } from './NPXObject'; 2 | import { format } from 'date-fns'; 3 | import { Asset, Parent } from './index'; 4 | import { LAST_MODIFIED_FORMAT } from './date-formats'; 5 | 6 | export type NoteElement = { 7 | type: 'markdown' | 'image' | 'drawing' | 'file' | 'recording' | 'pdf'; 8 | content: string; 9 | args: ElementArgs; 10 | }; 11 | 12 | export type ElementArgs = { 13 | id: string; 14 | x: string; 15 | y: string; 16 | width?: string; 17 | height?: string; 18 | fontSize?: string; 19 | filename?: string; 20 | ext?: string; 21 | dueDate?: string; 22 | /** Allows the binary in this element to be optimised. Defaults to true with image elements when unspecified. */ 23 | canOptimise?: boolean; 24 | }; 25 | 26 | export type Source = { 27 | id: number; 28 | item: string; 29 | content: string; 30 | }; 31 | 32 | export type MarkdownNote = { 33 | title: string; 34 | md: string; 35 | }; 36 | 37 | export default class Note extends NPXObject { 38 | constructor( 39 | public readonly title: string, 40 | public readonly time: number = new Date().getTime(), 41 | public readonly elements: NoteElement[] = [], 42 | public readonly bibliography: Source[] = [], 43 | internalRef?: string, 44 | parent?: Parent | string 45 | ) { 46 | super(title, internalRef, parent); 47 | } 48 | 49 | public addElement(element: NoteElement): Note { 50 | return this.clone({ 51 | elements: [ 52 | ...this.elements, 53 | element 54 | ] 55 | }); 56 | } 57 | 58 | public addSource(source: Source): Note { 59 | return this.clone({ 60 | bibliography: [ 61 | ...this.bibliography, 62 | source 63 | ] 64 | }); 65 | } 66 | 67 | public search(query: string): Note[] { 68 | // Title search 69 | let pattern = new RegExp("\\b" + query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'), 'i'); 70 | if (pattern.test(this.title)) return [this]; 71 | 72 | // Hashtag search 73 | if (query.length > 1 && query.charAt(0) === '#') { 74 | pattern = new RegExp("(^|\\s)" + query.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&") + "(\\b)", 'i'); 75 | 76 | // Check if any markdown elements contain the hashtag 77 | if ( 78 | this.elements 79 | .filter(e => e.type === 'markdown') 80 | .some(e => pattern.test(e.content)) 81 | ) return [this]; 82 | } 83 | 84 | return []; 85 | } 86 | 87 | public getHashtags(): string[] { 88 | return this.elements 89 | .filter(e => e.type === 'markdown') 90 | .map(e => e.content.match(/(^|\s)(#[a-z\d-]+)/gi)) 91 | .filter(res => !!res) 92 | .map(matches => Array.from(matches!)) 93 | .reduce((acc, val) => acc.concat(val), []) 94 | .map(hashtag => hashtag.trim()); 95 | } 96 | 97 | public getHeadingWords(): Set { 98 | return new Set(this.elements 99 | .filter(e => e.type === 'markdown') 100 | .flatMap(e => Array.from(e.content.matchAll(/^#{1,3} (.+)$/gmi))) 101 | .flatMap(matches => matches[1].trim().split(' '))); 102 | } 103 | 104 | public toXmlObject(): any { 105 | const elements = {}; 106 | this.elements.forEach(e => { 107 | if (!elements[e.type]) elements[e.type] = []; 108 | elements[e.type].push({ 109 | $: { ...e.args }, 110 | _: e.content 111 | }); 112 | }); 113 | 114 | const bibliography = this.bibliography.map(source => { 115 | return { 116 | $: { 117 | id: source.id, 118 | item: source.item 119 | }, 120 | _: source.content 121 | }; 122 | }); 123 | 124 | return { 125 | note: { 126 | $: { 127 | title: this.title, 128 | time: format(new Date(this.time), LAST_MODIFIED_FORMAT) 129 | }, 130 | addons: [[]], // We aren't supporting addons in v3 of the parser but we'll keep this for NPX compatibility 131 | bibliography: (bibliography.length > 0) ? { source: bibliography } : [[]], 132 | ...elements 133 | } 134 | }; 135 | } 136 | 137 | public async toMarkdown(assets: Asset[]): Promise { 138 | const assetMap = {}; 139 | assets.forEach(a => assetMap[a.uuid] = a); 140 | 141 | const md: string = (await Promise.all(this.elements 142 | .filter(e => ['markdown', 'drawing', 'image'].includes(e.type)) 143 | .map(async e => { 144 | let md: string | undefined; 145 | 146 | switch (e.type) { 147 | case 'markdown': 148 | md = e.content + '\n\n'; 149 | break; 150 | 151 | case 'drawing': 152 | case 'image': 153 | const asset = assetMap[e.args.ext || 0]; 154 | if (!asset) return ''; 155 | 156 | md = `![](${await asset.toString()})\n\n`; 157 | break; 158 | } 159 | if (!md) return ''; 160 | 161 | // Bibliography 162 | const bib = this.bibliography 163 | .filter(s => s.item === e.args.id) 164 | .map(s => s.content); 165 | 166 | if (bib.length > 0) { 167 | md += `***Bibliography*** \n`; 168 | md += bib 169 | .map(content => `- <${content}>\n`) 170 | .reduce((str, source) => str += source, '') + '\n'; 171 | } 172 | 173 | return md; 174 | }))) 175 | .reduce((str, elementMd) => str += elementMd, ''); 176 | 177 | return { title: this.title, md }; 178 | } 179 | 180 | public clone(opts: Partial = {}): Note { 181 | return new Note( 182 | opts.title || this.title, 183 | opts.time || this.time, 184 | [ 185 | ...(opts.elements || this.elements), 186 | ], 187 | [ 188 | ...(opts.bibliography || this.bibliography), 189 | ], 190 | opts.internalRef || this.internalRef, 191 | opts.parent || this.parent 192 | ); 193 | } 194 | } 195 | 196 | export function canOptimiseElement(el: NoteElement): boolean { 197 | const isEligible = el.args.canOptimise ?? el.type === 'image'; 198 | 199 | // Only fixed sizes can be optimised 200 | const width = parseInt(el.args.width!, 10); 201 | const height = parseInt(el.args.height!, 10); 202 | if (isNaN(width) || isNaN(height)) return false; 203 | 204 | return isEligible; 205 | } 206 | -------------------------------------------------------------------------------- /src/Notepad.ts: -------------------------------------------------------------------------------- 1 | import { format, parse } from 'date-fns'; 2 | import { Asset, FlatNotepad, Note, Section } from './'; 3 | import { Builder } from 'xml2js'; 4 | import { FlatSection } from './FlatNotepad'; 5 | import { MarkdownNote } from './Note'; 6 | import { NotepadShell } from "./interfaces"; 7 | import { encrypt, EncryptionMethod } from './crypto'; 8 | import { LAST_MODIFIED_FORMAT } from './date-formats'; 9 | 10 | export type NotepadOptions = { 11 | lastModified?: Date; 12 | sections?: Section[]; 13 | notepadAssets?: string[]; 14 | assets?: Asset[]; 15 | crypto?: EncryptionMethod; 16 | }; 17 | 18 | /** 19 | * This class is identical in structure to the old Notepad class from the original parser. 20 | * This represents the notepad as a tree. If you're looking for a flatter structure you can use {@link FlatNotepad}. 21 | * 22 | * Something to remember is that all operations on this class like addSection, will return a new 23 | * object of this class, and not modify the existing one. 24 | */ 25 | export default class Notepad implements NotepadShell { 26 | public readonly lastModified: string; 27 | public readonly sections: Section[]; 28 | public readonly notepadAssets: string[]; 29 | public readonly assets: Asset[]; 30 | public readonly crypto?: EncryptionMethod; 31 | 32 | constructor( 33 | public readonly title: string, 34 | opts: NotepadOptions = {} 35 | ) { 36 | this.lastModified = format(opts.lastModified || new Date(), LAST_MODIFIED_FORMAT); 37 | this.sections = opts.sections || []; 38 | this.notepadAssets = opts.notepadAssets || []; 39 | this.assets = opts.assets || []; 40 | if (opts.crypto) this.crypto = opts.crypto; 41 | } 42 | 43 | public addSection(section: Section): Notepad { 44 | const notepad = this.clone({ 45 | sections: [ 46 | ...this.sections, 47 | section 48 | ] 49 | }); 50 | section.parent = notepad; 51 | 52 | return notepad; 53 | } 54 | 55 | public addAsset(asset: Asset): Notepad { 56 | return this.clone({ 57 | assets: [ 58 | ...this.assets, 59 | asset 60 | ], 61 | notepadAssets: [ 62 | ...this.notepadAssets, 63 | asset.uuid 64 | ] 65 | }); 66 | } 67 | 68 | /** 69 | * This updates the lastModified date on the notepad. This date is used for syncing so it's important 70 | * to call this method whenever a change is made that will need to be synced. 71 | * @param {Date} lastModified 72 | * @returns {Notepad} 73 | */ 74 | public modified(lastModified: Date = new Date()): Notepad { 75 | return this.clone({ 76 | lastModified 77 | }); 78 | } 79 | 80 | /** 81 | * @param {string} query Can either be a title-search or a hashtag-search 82 | * @returns {Note[]} 83 | */ 84 | public search(query: string): Note[] { 85 | return this.sections 86 | .map(s => s.search(query)) 87 | .reduce((acc, val) => acc.concat(val), []); 88 | } 89 | 90 | public async toJson(passkey?: string): Promise { 91 | let notepad: Notepad = { 92 | ...this, 93 | assets: undefined, 94 | 95 | // If we're given a passkey but encryption hasn't been setup on the notepad, set it up 96 | crypto: (!this.crypto && !!passkey) ? 'AES-256-GZ' : this.crypto 97 | }; 98 | 99 | let notepadToStringify: NotepadShell = notepad; 100 | if (!!notepad.crypto && !!passkey) notepadToStringify = await encrypt(notepad, passkey); 101 | 102 | if (!!notepad.crypto && !passkey) { 103 | notepadToStringify = { ...notepadToStringify, crypto: undefined }; 104 | } 105 | 106 | return JSON.stringify(notepadToStringify, (key, value) => { 107 | return (key === 'parent') ? undefined : value; 108 | }); 109 | } 110 | 111 | public async toXml(): Promise { 112 | const builder = new Builder({ 113 | cdata: true, 114 | renderOpts: { 115 | 'pretty': false 116 | }, 117 | xmldec: { 118 | version: '1.0', 119 | encoding: 'UTF-8', 120 | standalone: false 121 | } 122 | }); 123 | 124 | // Generate the XML 125 | const obj = await this.toXmlObject(); 126 | return builder.buildObject(obj).replace(/ /g, ''); 127 | } 128 | 129 | public flatten(): FlatNotepad { 130 | let notepad = new FlatNotepad(this.title, { 131 | lastModified: parse(this.lastModified, LAST_MODIFIED_FORMAT, new Date()), 132 | notepadAssets: this.notepadAssets, 133 | crypto: this.crypto 134 | }); 135 | 136 | const flattenSection = (section: Section) => { 137 | let flat: FlatSection = { title: section.title, internalRef: section.internalRef }; 138 | if (section.parent) flat.parentRef = (section.parent as Section).internalRef; 139 | 140 | // Add this flat section 141 | notepad = notepad.addSection(flat); 142 | section.notes.forEach(n => notepad = notepad.addNote(n)); 143 | 144 | // Add all of its children recursively 145 | section.sections.forEach(s => flattenSection(s)); 146 | }; 147 | this.sections.forEach(s => flattenSection(s)); 148 | 149 | return notepad; 150 | } 151 | 152 | public async toMarkdown(assets: Asset[]): Promise { 153 | return ( 154 | await Promise.all( 155 | this.sections.map(s => s.toMarkdown(assets)) 156 | ) 157 | ).reduce((acc, val) => acc.concat(val), []); 158 | } 159 | 160 | public clone(opts: Partial = {}, title: string = this.title): Notepad { 161 | return new Notepad(title, { 162 | lastModified: parse(this.lastModified, LAST_MODIFIED_FORMAT, new Date()), 163 | sections: [...this.sections], 164 | notepadAssets: [...this.notepadAssets], 165 | assets: [...this.assets], 166 | crypto: this.crypto, 167 | ...opts 168 | }); 169 | } 170 | 171 | private async toXmlObject(): Promise { 172 | return { 173 | notepad: { 174 | $: { 175 | 'xsi:noNamespaceSchemaLocation': 'https://getmicropad.com/schema.xsd', 176 | title: this.title, 177 | lastModified: this.lastModified, 178 | 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance' 179 | }, 180 | assets: [ 181 | { 182 | asset: await Promise.all(this.assets.map(a => a.toXmlObject())) 183 | } 184 | ], 185 | section: this.sections.map(s => s.toXmlObject().section) 186 | } 187 | }; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/SearchIndex.ts: -------------------------------------------------------------------------------- 1 | import { Note } from './index'; 2 | 3 | export class Trie { 4 | public static buildTrie(notes: { [internalRef: string]: Note }, date = new Date()): Trie { 5 | const trie = new Trie(date); 6 | Object.entries(notes).forEach(([noteRef, note]) => { 7 | // Add the note title 8 | const title = note.title.replace(/[()]/, ''); 9 | title.split(/[\s\/\\,]/).forEach(word => Trie.add(trie, word, noteRef)); 10 | 11 | // More explicit matching on headings than titles to limit false-positives 12 | note.getHeadingWords().forEach(headingWord => Trie.add(trie, headingWord, noteRef)); 13 | 14 | note.getHashtags().forEach(hashtag => Trie.add(trie, hashtag, noteRef)); 15 | }); 16 | 17 | return trie; 18 | } 19 | 20 | public static shouldReindex(trie: Trie, lastModified: Date, numberOfNotes: number): boolean { 21 | return lastModified.getTime() > trie.lastModified.getTime() || numberOfNotes !== trie._size; 22 | } 23 | 24 | public static add(trie: Trie, key: string, ref: string): void { 25 | key = key.toLowerCase(); 26 | if (key.charAt(0) === '#') { 27 | const notes = trie.hashtags[key] || []; 28 | if (notes.indexOf(ref) !== -1) return; 29 | 30 | trie.hashtags[key] = [...notes, ref]; 31 | return; 32 | } 33 | 34 | const keyChars = [...key]; 35 | let node: TrieNode = trie.root; 36 | 37 | for (let ch of keyChars) { 38 | if (!node.children[ch]) node.children[ch] = new TrieNode(ch); 39 | node = node.children[ch]; 40 | } 41 | 42 | node.notes.push(ref); 43 | trie._size++; 44 | } 45 | 46 | public static search(trie: Trie, query: string): string[] { 47 | query = query.toLowerCase(); 48 | if (query.charAt(0) === '#') { 49 | return trie.hashtags[query] || []; 50 | } 51 | 52 | const keyChars = [...query]; 53 | let node: TrieNode = trie.root; 54 | 55 | for (let ch of keyChars) { 56 | if (!node.children[ch]) return []; 57 | node = node.children[ch]; 58 | } 59 | 60 | return [...new Set(node.getAllFrom())]; 61 | } 62 | 63 | private _size: number = 0; 64 | 65 | private readonly root: TrieNode; 66 | private readonly lastModified: Date; 67 | private readonly hashtags: { [hashtag: string]: string[] } = {}; 68 | 69 | constructor(lastModified = new Date()) { 70 | this.root = new TrieNode(); 71 | this.lastModified = lastModified; 72 | } 73 | 74 | public get size(): number { 75 | return this._size; 76 | } 77 | 78 | public get availableHashtags(): string[] { 79 | return Object.keys(this.hashtags); 80 | } 81 | 82 | /** 83 | * @deprecated Use static methods instead 84 | */ 85 | public shouldReindex(lastModified: Date, numberOfNotes: number): boolean { 86 | return Trie.shouldReindex(this, lastModified, numberOfNotes); 87 | } 88 | 89 | /** 90 | * @deprecated Use static methods instead 91 | */ 92 | public add(key: string, ref: string): void { 93 | return Trie.add(this, key, ref); 94 | } 95 | 96 | /** 97 | * @deprecated Use static methods instead 98 | */ 99 | public search(query: string): string[] { 100 | return Trie.search(this, query); 101 | } 102 | } 103 | 104 | class TrieNode { 105 | public static getAllFrom(node: TrieNode): string[] { 106 | return [ 107 | ...node.notes, 108 | ...(Object.values(node.children)) 109 | .reduce((acc, val) => acc.concat(val.getAllFrom()), [] as string[]) 110 | ]; 111 | } 112 | 113 | public readonly key: string; 114 | public readonly notes: string[] = []; 115 | public readonly children: { [ch: string]: TrieNode } = {}; 116 | 117 | constructor(key = '\0') { 118 | this.key = key; 119 | } 120 | 121 | public getAllFrom(): string[] { 122 | return TrieNode.getAllFrom(this); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Section.ts: -------------------------------------------------------------------------------- 1 | import { Parent } from './interfaces'; 2 | import { Asset, Note } from './index'; 3 | import { NPXObject } from './NPXObject'; 4 | import { MarkdownNote } from './Note'; 5 | 6 | export default class Section extends NPXObject implements Parent { 7 | constructor( 8 | public readonly title: string, 9 | public readonly sections: Section[] = [], 10 | public readonly notes: Note[] = [], 11 | internalRef?: string, 12 | parent?: Parent | string 13 | ) { 14 | super(title, internalRef, parent); 15 | } 16 | 17 | public addSection(section: Section): Section { 18 | const sectionClone = section.clone(); 19 | 20 | const parent = this.clone({ 21 | sections: [ 22 | ...this.sections, 23 | sectionClone 24 | ] 25 | }); 26 | sectionClone.parent = parent; 27 | 28 | return parent; 29 | } 30 | 31 | public addNote(note: Note): Section { 32 | const noteClone = note.clone(); 33 | 34 | const parent = this.clone({ 35 | notes: [ 36 | ...this.notes, 37 | noteClone 38 | ] 39 | }); 40 | noteClone.parent = parent; 41 | 42 | return parent; 43 | } 44 | 45 | public search(query: string): Note[] { 46 | const subSectionNotes: Note[] = this.sections 47 | .map(s => s.search(query)) 48 | .reduce((acc, val) => acc.concat(val), []); 49 | 50 | return [ 51 | ...this.notes.filter(n => n.search(query).length > 0), 52 | ...subSectionNotes 53 | ]; 54 | } 55 | 56 | public toXmlObject(): any { 57 | return { 58 | section: { 59 | $: { 60 | title: this.title 61 | }, 62 | section: this.sections.map(s => s.toXmlObject().section), 63 | note: this.notes.map(n => n.toXmlObject().note) 64 | } 65 | }; 66 | } 67 | 68 | public async toMarkdown(assets: Asset[]): Promise { 69 | const subSectionNotes: MarkdownNote[] = ( 70 | await Promise.all( 71 | this.sections.map(s => s.toMarkdown(assets)) 72 | )).reduce((acc, val) => acc.concat(val), []); 73 | 74 | return [ 75 | ...await Promise.all([ 76 | ...this.notes.map(n => n.toMarkdown(assets)), 77 | ]), 78 | ...subSectionNotes 79 | ]; 80 | } 81 | 82 | public clone(opts: Partial
= {}): Section { 83 | return new Section( 84 | opts.title || this.title, 85 | [ 86 | ...(opts.sections || this.sections), 87 | ], 88 | [ 89 | ...(opts.notes || this.notes), 90 | ], 91 | opts.internalRef || this.internalRef, 92 | opts.parent || this.parent 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Translators.ts: -------------------------------------------------------------------------------- 1 | import { Asset, FlatNotepad, Note, Notepad, Section } from './index'; 2 | import { format, parse, parseISO } from 'date-fns'; 3 | import { OptionsV2, parseString } from 'xml2js'; 4 | import { NoteElement, Source } from './Note'; 5 | import { gfm } from 'turndown-plugin-gfm'; 6 | import TurndownService from 'turndown'; 7 | import { NotepadShell } from './interfaces'; 8 | import { decrypt } from './crypto'; 9 | import { 10 | ENEX_FORMAT, 11 | IMPORTED_EVERNOTE_NOTEBOOK_TIME, 12 | IMPORTED_MARKDOWN_NOTE_TIME, 13 | LAST_MODIFIED_FORMAT 14 | } from './date-formats'; 15 | 16 | export namespace Translators { 17 | export namespace Json { 18 | /** 19 | * @param {string | object} json A {@link Notepad} object in JSON format or as a plain object 20 | * @param {string | undefined} passkey The passkey to decrypt the notepad if it's encrypted. 21 | * @returns {Notepad} 22 | */ 23 | export async function toNotepadFromNotepad(json: string | object, passkey?: string): Promise { 24 | const jsonObj: NotepadShell = (typeof json === 'string') ? JSON.parse(json) : json; 25 | let notepad = new Notepad(jsonObj.title, { 26 | lastModified: parse(jsonObj.lastModified, LAST_MODIFIED_FORMAT, new Date()), 27 | notepadAssets: jsonObj.notepadAssets || [], 28 | crypto: jsonObj.crypto 29 | }); 30 | 31 | if (typeof jsonObj.sections === 'string') { 32 | if (!passkey) throw new Error('This notepad is encrypted. A passkey is needed to unlock it.'); 33 | return await decrypt(jsonObj, passkey); 34 | } 35 | 36 | // Restore sections 37 | jsonObj.sections.forEach(section => notepad = notepad.addSection(restoreSection(section))); 38 | 39 | return notepad; 40 | 41 | function restoreSection(section: Section): Section { 42 | let restored = new Section(section.title).clone({ internalRef: section.internalRef }); 43 | section.sections.forEach(s => restored = restored.addSection(restoreSection(s))); 44 | section.notes.forEach(n => restored = restored.addNote(restoreNote(n))); 45 | 46 | return restored; 47 | } 48 | 49 | function restoreNote(note: Note) { 50 | return new Note(note.title, note.time, note.elements, note.bibliography, note.internalRef); 51 | } 52 | } 53 | 54 | /** 55 | * @param {string | object} json A {@link Notepad} object in JSON format or as a plain object 56 | * @param {string | undefined} passkey The passkey to decrypt the notepad if it's encrypted. 57 | * @returns {FlatNotepad} 58 | */ 59 | export async function toFlatNotepadFromNotepad(json: string | object, passkey?: string): Promise { 60 | return (await toNotepadFromNotepad(json, passkey)).flatten(); 61 | } 62 | 63 | /** 64 | * @param {string} json A Jupyter notebook (.ipynb) 65 | * @returns {string} A Markdown translation of that notebook 66 | */ 67 | export function toMarkdownFromJupyter(json: string): string { 68 | const np = JSON.parse(json); 69 | 70 | let mdString = ''; 71 | np.cells.forEach(cell => { 72 | if (cell.cell_type === 'markdown') cell.source.forEach(line => mdString += line+'\n'); 73 | 74 | if (cell.cell_type === 'code') { 75 | mdString += '\n```\n'; 76 | cell.source.forEach(line => mdString += line+'\n'); 77 | 78 | cell.outputs.forEach(output => { 79 | if (!output.text) return; 80 | mdString += '\n--------------------\n'; 81 | mdString += 'Output:\n'; 82 | output.text.forEach(t => mdString += t); 83 | mdString += '\n--------------------\n'; 84 | }); 85 | 86 | mdString += '```\n'; 87 | } 88 | }); 89 | 90 | return mdString; 91 | } 92 | } 93 | 94 | export namespace Xml { 95 | /** 96 | * @param {string} xml The NPX file's contents as a string 97 | * @returns {Promise} 98 | */ 99 | export async function toNotepadFromNpx(xml: string): Promise { 100 | const res = await parseXml(xml); 101 | 102 | const lastModifiedStr: string = res.notepad.$.lastModified; 103 | let notepad = new Notepad(res.notepad.$.title, { lastModified: lastModifiedStr ? parse(lastModifiedStr, LAST_MODIFIED_FORMAT, new Date()) : new Date() }); 104 | 105 | // Parse sections/notes 106 | if (res.notepad.section) { 107 | res.notepad.section.forEach(s => notepad = notepad.addSection(parseSection(s))); 108 | } 109 | 110 | // Parse assets 111 | if (res.notepad.assets) { 112 | const assets = await Promise.all(((res.notepad.assets[0] || {}).asset || []).map(async item => { 113 | try { 114 | return new Asset(await dataURItoBlob(item._), item.$.uuid); 115 | } catch (e) { 116 | console.warn(`Can't parse the asset ${item.$.uuid}\n${e}`); 117 | return null; 118 | } 119 | })); 120 | 121 | assets 122 | .filter(asset => asset !== null) 123 | .forEach(asset => notepad = notepad.addAsset(asset!)); 124 | } 125 | 126 | // Convert inline assets to full-assets 127 | const flatNotepad = notepad.flatten(); 128 | const convertedAssets: Asset[] = []; 129 | const convertedNotes = await Promise.all(Object.values(flatNotepad.notes) 130 | .map(async note => { 131 | let elements: NoteElement[] = []; 132 | 133 | // Import inline assets 134 | for (const element of note.elements) { 135 | // If it's not a binary asset with inline base64, skip 136 | if (element.type === 'markdown' || element.content === 'AS') { 137 | elements.push(element); 138 | continue; 139 | } 140 | 141 | try { 142 | const asset = new Asset(await dataURItoBlob(element.content)); 143 | elements.push({ 144 | ...element, 145 | content: 'AS', 146 | args: { 147 | ...element.args, 148 | ext: asset.uuid 149 | } 150 | }); 151 | convertedAssets.push(asset); 152 | 153 | } catch (e) { 154 | console.warn(`Can't parse asset\n${e}`); 155 | } 156 | } 157 | 158 | // Update the note with the new elements 159 | return flatNotepad.notes[note.internalRef].clone({ 160 | elements 161 | }); 162 | })); 163 | 164 | convertedNotes.forEach(note => flatNotepad.notes[note.internalRef] = note); 165 | 166 | return flatNotepad.toNotepad().clone({ 167 | assets: [ 168 | ...notepad.assets, 169 | ...convertedAssets 170 | ], 171 | notepadAssets: [ 172 | ...notepad.notepadAssets, 173 | ...convertedAssets.map(asset => asset.uuid) 174 | ] 175 | }); 176 | 177 | function parseSection(sectionObj: any): Section { 178 | let section = new Section(sectionObj.$.title); 179 | 180 | // Insert sub-sections recursively because notepads are trees 181 | (sectionObj.section || []).forEach(item => section = section.addSection(parseSection(item))); 182 | 183 | // Parse notes 184 | (sectionObj.note || []).forEach(item => { 185 | const posixTimeStr: string = item.$.time; 186 | 187 | section = section.addNote(new Note( 188 | item.$.title, 189 | parseISO(posixTimeStr).getTime(), 190 | [ 191 | ...([ 192 | 'markdown', 193 | 'drawing', 194 | 'image', 195 | 'file', 196 | 'recording', 197 | 'pdf' 198 | ] 199 | .map(type => 200 | (item[type] || []).map(e => { 201 | return { 202 | type: type, 203 | args: { 204 | ...e.$, 205 | canOptimise: e.$.canOptimise && (e.$.canOptimise.toLowerCase() == 'true') 206 | }, 207 | content: e._ || '' 208 | } as NoteElement; 209 | }) 210 | ) 211 | ).reduce((acc: NoteElement[], element: NoteElement[]) => acc.concat(element)) 212 | ], 213 | [ 214 | ...(item.bibliography[0].source || []).map(s => { 215 | return { 216 | id: s.$.id, 217 | item: s.$.item, 218 | content: s._ 219 | } as Source; 220 | }) 221 | ] 222 | )) 223 | }); 224 | 225 | return section; 226 | } 227 | } 228 | 229 | /** 230 | * @param {string} xml The exported Evernote notepad's XML as a string 231 | * @returns {Promise} 232 | */ 233 | export async function toNotepadFromEnex(xml: string): Promise { 234 | const res = await parseXml(xml, { trim: true, normalize: false }); 235 | const exported = res['en-export']; 236 | 237 | let notepad = new Notepad(`${exported.$.application} Import ${format(parse(exported.$['export-date'], ENEX_FORMAT, new Date()), IMPORTED_EVERNOTE_NOTEBOOK_TIME)}`); 238 | let section = new Section('Imported Notes'); 239 | 240 | (await Promise.all((exported.note || []) 241 | .map(async enexNote => { 242 | let note = new Note((enexNote.title || ['Imported Note'])[0], parse(enexNote.created[0], ENEX_FORMAT, new Date()).getTime(), [ 243 | // Add the general note content (text/to-do) 244 | { 245 | type: 'markdown', 246 | args: { 247 | id: 'markdown1', 248 | x: '10px', 249 | y: '10px', 250 | width: '600px', 251 | height: 'auto', 252 | fontSize: '16px' 253 | }, 254 | content: enmlToMarkdown(enexNote.content[0]) 255 | } 256 | ]); 257 | 258 | let fileCount = 0; 259 | let imageCount = 0; 260 | 261 | await Promise.all( 262 | (enexNote.resource || []).reverse().map(async resource => { 263 | const asset = new Asset(await dataURItoBlob( 264 | `data:${resource.mime};base64,${resource.data[0]._.replace(/\r?\n|\r/g, '')}` 265 | )); 266 | notepad = notepad.addAsset(asset); 267 | 268 | const y = 10 + (335 * (fileCount + imageCount)); 269 | 270 | if (resource.mime[0].includes('image')) { 271 | note = note.addElement({ 272 | type: 'image', 273 | args: { 274 | id: `image${++imageCount}`, 275 | x: '650px', 276 | y: y + 'px', 277 | width: 'auto', 278 | height: '300px', 279 | ext: asset.uuid 280 | }, 281 | content: 'AS' 282 | }); 283 | } else { 284 | // Add the resource as a file 285 | let filename; 286 | try { 287 | if (resource['resource-attributes'][0]['file-name']) { 288 | filename = resource['resource-attributes'][0]['file-name'][0]; 289 | } else if (resource['resource-attributes'][0]['source-url']) { 290 | filename = resource['resource-attributes'][0]['source-url'][0].split('/').pop(); 291 | } else { 292 | filename = `file${fileCount}.${resource.mime[0].split('/').pop()}` 293 | } 294 | } catch (e) { 295 | filename = 'imported-file' + fileCount; 296 | } 297 | 298 | note = note.addElement({ 299 | type: 'file', 300 | args: { 301 | id: `file${++fileCount}`, 302 | x: '650px', 303 | y: y + 'px', 304 | width: 'auto', 305 | height: 'auto', 306 | ext: asset.uuid, 307 | filename 308 | }, 309 | content: 'AS' 310 | }); 311 | } 312 | }) 313 | ); 314 | 315 | return note; 316 | }))) 317 | .forEach(note => section = section.addNote(note)); 318 | 319 | return notepad.addSection(section); 320 | 321 | function enmlToMarkdown(enml: string): string { 322 | // Init Turndown service 323 | const service = new TurndownService(); 324 | service.use(gfm); 325 | 326 | // Setup rules, these are like converters from the old to-markdown lib 327 | service.addRule('en-media', { 328 | filter: 'en-media', 329 | replacement: () => { 330 | return '`there was an attachment here`'; 331 | } 332 | }); 333 | 334 | service.addRule('crypt', { 335 | filter: 'en-crypt', 336 | replacement: () => '`there was encrypted text here`' 337 | }); 338 | 339 | service.addRule('todo', { 340 | filter: 'en-todo', 341 | replacement: (content, node) => 342 | `- [${(node.getAttributeNode('checked') && node.getAttributeNode('checked').value === 'true') ? 'x' : ' '}] ${content}` 343 | }); 344 | 345 | // Clean input 346 | let lines = enml.split('\n'); 347 | lines = lines.slice(3, lines.length - 1); 348 | const html = lines 349 | .map(line => line.trim()) 350 | .join('\n') 351 | .replace(/^$/gmi, tag => `${tag.substr(0, tag.length - 2)}>t`); 352 | 353 | // Convert to markdown 354 | return service.turndown(html); 355 | } 356 | } 357 | 358 | function parseXml(xml: string, opts: OptionsV2 = {}): Promise { 359 | return new Promise((resolve, reject) => { 360 | parseString(xml, { trim: true, ...opts }, (err, res) => { 361 | if (err) reject(err); 362 | resolve(res); 363 | }); 364 | }); 365 | } 366 | } 367 | 368 | export namespace Markdown { 369 | /** 370 | * @param {Array} markdown A list of all the markdown to be imported into notes 371 | */ 372 | export function toNotepadFromMarkdown(markdown: MarkdownImport[]): Notepad { 373 | let importCounter = 0; 374 | 375 | const section = markdown 376 | .map(({ title, content }) => new Note(`${title} (Import ${++importCounter})`).clone({ 377 | elements: [ 378 | { 379 | type: 'markdown', 380 | content, 381 | args: { id: 'markdown1', x: '10px', y: '10px', width: '550px', height: 'auto', fontSize: '16px' } 382 | } 383 | ] 384 | })) 385 | .reduce((section, note) => section.addNote(note), new Section('Imported Notes')); 386 | 387 | return new Notepad('Markdown Import ' + format(new Date(), IMPORTED_MARKDOWN_NOTE_TIME)).addSection(section); 388 | } 389 | 390 | export type MarkdownImport = { title: string, content: string }; 391 | } 392 | 393 | // Thanks to http://stackoverflow.com/a/12300351/998467 394 | async function dataURItoBlob(dataURI: string) { 395 | // convert base64 to raw binary data held in a string 396 | // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this 397 | let byteString = atob(dataURI.split(',')[1]); 398 | 399 | // separate out the mime component 400 | let mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; 401 | 402 | // write the bytes of the string to an ArrayBuffer 403 | let ab = new ArrayBuffer(byteString.length); 404 | let ia = new Uint8Array(ab); 405 | for (let i = 0; i < byteString.length; i++) { 406 | ia[i] = byteString.charCodeAt(i); 407 | } 408 | 409 | // write the ArrayBuffer to a blob, and you're done 410 | return new Blob([ia], { type: mimeString }); 411 | } 412 | } 413 | -------------------------------------------------------------------------------- /src/crypto.ts: -------------------------------------------------------------------------------- 1 | import { NotepadShell } from './interfaces'; 2 | import Notepad from './Notepad'; 3 | import { AES256 } from './AES256'; 4 | import { AES256GZ } from './AES256GZ'; 5 | 6 | export type EncryptionMethod = 'AES-256' | 'AES-256-GZ'; 7 | 8 | export async function decrypt(notepad: NotepadShell, passkey: string): Promise { 9 | return getMethod(notepad).decrypt(notepad, passkey); 10 | } 11 | 12 | export async function encrypt(notepad: Notepad, passkey: string): Promise { 13 | return getMethod(notepad).encrypt(notepad, passkey); 14 | } 15 | 16 | function getMethod(notepad: NotepadShell): EncryptionMethodImpl { 17 | if (!notepad.crypto) throw new Error(`This notepad isn't encrypted.`); 18 | 19 | const method = methods[notepad.crypto]; 20 | if (!method) throw new Error(`No such method: '${notepad.crypto}' exists.`); 21 | 22 | return method; 23 | } 24 | 25 | const methods: { [K in EncryptionMethod]: EncryptionMethodImpl } = { 26 | 'AES-256': new AES256(), 27 | 'AES-256-GZ': new AES256GZ() 28 | }; 29 | 30 | export interface EncryptionMethodImpl { 31 | encrypt(notepad: Notepad, passkey: string): Promise; 32 | decrypt(notepad: NotepadShell, passkey: string): Promise; 33 | } 34 | -------------------------------------------------------------------------------- /src/date-formats.ts: -------------------------------------------------------------------------------- 1 | export const LAST_MODIFIED_FORMAT = 'yyyy-MM-dd\'T\'HH:mm:ss.SSSxxx'; 2 | export const ENEX_FORMAT = 'yyyyMMdd\'T\'HHmmssX'; 3 | export const IMPORTED_EVERNOTE_NOTEBOOK_TIME = 'dd LLL h:mma'; 4 | export const IMPORTED_MARKDOWN_NOTE_TIME = IMPORTED_EVERNOTE_NOTEBOOK_TIME; 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Notepad } from './Notepad'; 2 | export { default as FlatNotepad } from './FlatNotepad'; 3 | export { default as Section } from './Section'; 4 | export { default as Note } from './Note'; 5 | export { default as Asset } from './Asset'; 6 | export { Translators } from './Translators'; 7 | export type { Parent } from './interfaces'; 8 | export { Trie } from './SearchIndex'; 9 | export * from './move-notes/move-notes'; 10 | export * from './date-formats'; 11 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import Section from './Section'; 2 | import Note, { MarkdownNote } from './Note'; 3 | import Asset from './Asset'; 4 | import { EncryptionMethod } from './crypto'; 5 | 6 | export interface Parent { 7 | title: string; 8 | addSection: (section: Section) => Parent; 9 | search: (query: string) => Note[]; 10 | toMarkdown: (asset: Asset[]) => Promise; 11 | } 12 | 13 | export interface NotepadShell { 14 | title: string; 15 | lastModified: string; 16 | notepadAssets: string[]; 17 | assets?: Asset[]; 18 | 19 | /** 20 | * This is the either the cypher-text of the {@link Section} array or the actual {@link Section} array 21 | */ 22 | sections: string | Section[]; 23 | 24 | crypto?: EncryptionMethod; 25 | } 26 | 27 | export interface FileReaderEventTarget extends EventTarget { 28 | result: string; 29 | } 30 | -------------------------------------------------------------------------------- /src/move-notes/move-notes.spec.ts: -------------------------------------------------------------------------------- 1 | import { moveNote, moveSection } from './move-notes'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { Translators } from '../Translators'; 5 | import FlatNotepad from '../FlatNotepad'; 6 | 7 | describe('Move Notes', () => { 8 | const helpNpx = fs.readFileSync(path.join(__dirname, '..', 'tests', '__data__', 'Help.npx')).toString(); 9 | 10 | describe('moveNote', () => { 11 | it('should move the note and all of its asset references to the other notebook', async () => { 12 | // Arrange 13 | const source = (await Translators.Xml.toNotepadFromNpx(helpNpx)).flatten(); 14 | const dest = new FlatNotepad('Destination', { 15 | lastModified: new Date(0), 16 | notepadAssets: ['fake-asset'] 17 | }); 18 | 19 | const noteToMove = Object.values(source.notes).find(note => note.title === 'Notepad Structure')!; 20 | const expectedAsset = '75dffe4d-bb75-2975-984d-9e43f911d675'; 21 | 22 | // Act 23 | const { source: sourceRes, destination: destRes } = moveNote(noteToMove.internalRef, source, dest); 24 | 25 | // Assert 26 | expect(sourceRes.notes[noteToMove.internalRef]).toBeUndefined(); 27 | expect(sourceRes.notepadAssets).not.toContain(expectedAsset); 28 | 29 | expect(destRes.notes[noteToMove.internalRef]).toEqual(noteToMove.clone({ 30 | parent: Object.values(destRes.sections).find(s => s.title = 'Moved Notes')!.internalRef 31 | })); 32 | expect(destRes.notepadAssets).toContain(expectedAsset); 33 | }); 34 | }); 35 | 36 | describe('moveSection', () => { 37 | it('should move the section, all the notes, and assets to the other notebook', async () => { 38 | // Arrange 39 | const source = (await Translators.Xml.toNotepadFromNpx(helpNpx)).flatten(); 40 | const dest = new FlatNotepad('Destination', { 41 | lastModified: new Date(0), 42 | notepadAssets: ['fake-asset'] 43 | }); 44 | 45 | const sectionToMove = Object.values(source.sections).find(section => section.title === 'General Use')!; 46 | const expectedAsset = '75dffe4d-bb75-2975-984d-9e43f911d675'; 47 | 48 | // Act 49 | const { source: sourceRes, destination: destRes } = moveSection(sectionToMove.internalRef, source, dest); 50 | 51 | // Assert 52 | expect(sourceRes.sections[sectionToMove.internalRef]).toBeUndefined(); 53 | expect(sourceRes.notepadAssets).not.toContain(expectedAsset); 54 | 55 | expect(destRes.sections[sectionToMove.internalRef]).toEqual({ 56 | ...sectionToMove, 57 | parentRef: Object.values(destRes.sections).find(s => s.title = 'Moved Notes')!.internalRef 58 | }); 59 | expect(destRes.notepadAssets).toContain(expectedAsset); 60 | }); 61 | 62 | it('should move sub-sections', async () => { 63 | // Arrange 64 | const source = (await Translators.Xml.toNotepadFromNpx(helpNpx)).flatten(); 65 | const dest = new FlatNotepad('Destination', { 66 | lastModified: new Date(0), 67 | notepadAssets: ['fake-asset'] 68 | }); 69 | 70 | const sectionToMove = Object.values(source.sections).find(section => section.title === 'Development')!; 71 | const subSection = Object.values(source.sections).find(section => section.parentRef === sectionToMove.internalRef)!; 72 | 73 | // Act 74 | const { source: sourceRes, destination: destRes } = moveSection(sectionToMove.internalRef, source, dest); 75 | 76 | // Assert 77 | expect(sourceRes.sections[sectionToMove.internalRef]).toBeUndefined(); 78 | expect(sourceRes.sections[subSection.internalRef]).toBeUndefined(); 79 | 80 | expect(destRes.sections[sectionToMove.internalRef]).toEqual({ 81 | ...sectionToMove, 82 | parentRef: Object.values(destRes.sections).find(s => s.title = 'Moved Notes')!.internalRef 83 | }); 84 | expect(destRes.sections[subSection.internalRef]).toEqual(subSection); 85 | }); 86 | }); 87 | }); -------------------------------------------------------------------------------- /src/move-notes/move-notes.ts: -------------------------------------------------------------------------------- 1 | import FlatNotepad from '../FlatNotepad'; 2 | 3 | export type RestructuredNotepads = { 4 | source: FlatNotepad, 5 | destination: FlatNotepad 6 | }; 7 | 8 | // All notes get moved into a section called "Moved Notes" 9 | const MOVED_NOTES_SECTION_TITLE = 'Moved Notes'; 10 | 11 | /** 12 | * Move a note between notebooks. 13 | * 14 | * @param internalRef The reference for the note that is being moved 15 | * @param source The source notebook 16 | * @param destination The destination notebook 17 | */ 18 | export function moveNote(internalRef: string, source: FlatNotepad, destination: FlatNotepad): RestructuredNotepads { 19 | let destSection = Object.values(destination.sections).find(section => section.title === MOVED_NOTES_SECTION_TITLE); 20 | if (!destSection) { 21 | destSection = FlatNotepad.makeFlatSection(MOVED_NOTES_SECTION_TITLE); 22 | destination = destination.addSection(destSection); 23 | } 24 | 25 | source = source.clone({ lastModified: new Date() }); 26 | destination = destination.clone({ lastModified: new Date() }); 27 | 28 | return moveNoteHelper(internalRef, source, destination, destSection.internalRef); 29 | } 30 | 31 | /** 32 | * Move a section between notebooks. 33 | * 34 | * @param internalRef The reference for the section that is being moved 35 | * @param source The source notebook 36 | * @param destination The destination notebook 37 | */ 38 | export function moveSection(internalRef: string, source: FlatNotepad, destination: FlatNotepad): RestructuredNotepads { 39 | let destSection = Object.values(destination.sections).find(section => section.title === MOVED_NOTES_SECTION_TITLE); 40 | if (!destSection) { 41 | destSection = FlatNotepad.makeFlatSection(MOVED_NOTES_SECTION_TITLE); 42 | destination = destination.addSection(destSection); 43 | } 44 | 45 | source = source.clone({ lastModified: new Date() }); 46 | destination = destination.clone({ lastModified: new Date() }); 47 | 48 | return moveSectionHelper(internalRef, source, destination, destSection.internalRef); 49 | } 50 | 51 | function moveNoteHelper(internalRef: string, source: FlatNotepad, destination: FlatNotepad, destSectionRef: string): RestructuredNotepads { 52 | /* Moving a note is basically a two-step process 53 | * 1. Copy over the note 54 | * 2. Copy over the changed asset refs 55 | * 56 | * Note: this relies on FlatNotepad behaviour to avoid actually transferring any asset blobs. 57 | */ 58 | 59 | // Get the note 60 | const note = source.notes[internalRef]; 61 | if (!note) { 62 | throw new Error('Error moving note: The note does not exist in the source notebook.'); 63 | } 64 | 65 | // Move the note 66 | const sourceNotes = { ...source.notes }; 67 | delete sourceNotes[internalRef]; 68 | source = source.clone({ notes: sourceNotes }); 69 | 70 | destination = destination.addNote(note.clone({ 71 | parent: destSectionRef 72 | })); 73 | 74 | // Get the used asset refs 75 | const usedRefs: Set = note.elements 76 | .map(element => element.args) 77 | .map(args => args.ext) 78 | .filter((assetRef): assetRef is string => !!assetRef) 79 | .reduce((acc, assetRef) => acc.add(assetRef), new Set()); 80 | 81 | // Move the asset refs 82 | source = source.clone({ 83 | notepadAssets: source.notepadAssets.filter(assetRef => !usedRefs.has(assetRef)) 84 | }); 85 | destination = destination.clone({ 86 | notepadAssets: [...destination.notepadAssets, ...usedRefs] 87 | }); 88 | 89 | return { source, destination }; 90 | } 91 | 92 | function moveSectionHelper(internalRef: string, source: FlatNotepad, destination: FlatNotepad, destSectionRef: string): RestructuredNotepads { 93 | // First, move this section 94 | destination = destination.addSection({ 95 | ...source.sections[internalRef], 96 | parentRef: destSectionRef 97 | }); 98 | 99 | const sourceSections = { ...source.sections }; 100 | delete sourceSections[internalRef]; 101 | source = source.clone({ sections: sourceSections }); 102 | 103 | // Move all the notes in this section 104 | Object.values(source.notes) 105 | .filter(note => note.parent === internalRef) 106 | .forEach(note => { 107 | const res = moveNoteHelper(note.internalRef, source, destination, internalRef); 108 | source = res.source; 109 | destination = res.destination; 110 | }); 111 | 112 | // Move all the subsections in this section 113 | Object.values(source.sections) 114 | .filter(section => section.parentRef === internalRef) 115 | .forEach(section => { 116 | const res = moveSectionHelper(section.internalRef, source, destination, internalRef); 117 | source = res.source; 118 | destination = res.destination; 119 | }); 120 | 121 | return { source, destination }; 122 | } 123 | -------------------------------------------------------------------------------- /src/tests/Asset.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestUtils } from './TestUtils'; 2 | import { Asset } from '../index'; 3 | 4 | describe('Asset', () => { 5 | it('should construct', () => { 6 | // Arrange 7 | // Act 8 | const res = TestUtils.makeAsset(); 9 | 10 | // Assert 11 | expect(res).toBeInstanceOf(Asset); 12 | }); 13 | 14 | describe('toString', () => { 15 | it('should generate base64 from the asset', async () => { 16 | // Arrange 17 | const asset = TestUtils.makeAsset(); 18 | 19 | // Act 20 | const res = await asset.toString(); 21 | 22 | // Assert 23 | expect(res).toMatchSnapshot(); 24 | }); 25 | 26 | it('should return an empty string on invalid data', async () => { 27 | // Arrange 28 | const asset = new Asset(null as any, 'abc'); 29 | 30 | // Act 31 | const res = await asset.toString(); 32 | 33 | // Assert 34 | expect(res).toEqual(''); 35 | }); 36 | }); 37 | 38 | it('should generate XML Object with required data', async () => { 39 | // Arrange 40 | const asset = TestUtils.makeAsset(); 41 | 42 | // Act 43 | const res = await asset.toXmlObject(); 44 | 45 | // Assert 46 | expect(res).toMatchSnapshot(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/tests/FlatNotepad.spec.ts: -------------------------------------------------------------------------------- 1 | import { FlatNotepad, Note, Section } from '../index'; 2 | import { FlatNotepadOptions, FlatSection } from '../FlatNotepad'; 3 | 4 | describe('FlatNotepad', () => { 5 | let options = getOptions(); 6 | 7 | beforeEach(() => { 8 | options = getOptions(); 9 | }); 10 | 11 | describe('constructor', () => { 12 | it('should construct with just a title', () => { 13 | // Arrange 14 | const title = 'test'; 15 | 16 | // Act 17 | const n = new FlatNotepad(title); 18 | 19 | // Assert 20 | expect(n).toBeInstanceOf(FlatNotepad); 21 | expect(n.title).toEqual(title); 22 | }); 23 | 24 | Object.entries(options).forEach(option => 25 | it(`should construct with ${option[0]}`, () => { 26 | // Arrange 27 | const title = 'test'; 28 | 29 | // Act 30 | const n = new FlatNotepad(title, { 31 | [option[0]]: option[1] 32 | }); 33 | 34 | // Assert 35 | expect(n).toBeInstanceOf(FlatNotepad); 36 | expect(n.title).toEqual(title); 37 | expect(n[option[0]]).toMatchSnapshot(); 38 | }) 39 | ); 40 | }); 41 | 42 | it('should make a flat section', () => { 43 | // Arrange 44 | const expected = 'test section'; 45 | 46 | // Act 47 | const res = FlatNotepad.makeFlatSection(expected); 48 | 49 | // Assert 50 | expect(res.title).toEqual(expected); 51 | expect(res.internalRef).toBeDefined(); 52 | expect(res.parentRef).toBeUndefined(); 53 | }); 54 | 55 | describe('addSection', () => { 56 | let notepad: FlatNotepad; 57 | let section: FlatSection; 58 | 59 | beforeEach(() => { 60 | notepad = new FlatNotepad('test'); 61 | section = { title: 'test', internalRef: 'abc' }; 62 | }); 63 | 64 | it('should add a new section', () => { 65 | //Arrange 66 | // Act 67 | const res = notepad.addSection(section); 68 | 69 | // Assert 70 | expect(res.sections[section.internalRef]).toEqual(section); 71 | }); 72 | 73 | it('should create a new object', () => { 74 | //Arrange 75 | // Act 76 | const res = notepad.addSection(section); 77 | 78 | // Assert 79 | expect(res).not.toBe(notepad); 80 | }); 81 | }); 82 | 83 | describe('addNote', () => { 84 | let notepad: FlatNotepad; 85 | let section: FlatSection; 86 | let note: Note; 87 | 88 | beforeEach(() => { 89 | notepad = new FlatNotepad('test'); 90 | section = { title: 'test', internalRef: 'abc' }; 91 | 92 | note = new Note('test', new Date(1).getTime()).clone({ internalRef: 'etc' }); 93 | note.parent = 'abc'; 94 | }); 95 | 96 | it('should add a new note', () => { 97 | //Arrange 98 | // Act 99 | const res = notepad.addNote(note); 100 | 101 | // Assert 102 | expect(res.notes[note.internalRef]).toEqual(note); 103 | }); 104 | 105 | it(`should add a new note and convert the parent if it isn't a string`, () => { 106 | //Arrange 107 | note.parent = new Section('test').clone({ internalRef: 'abc' }); 108 | 109 | // Act 110 | const res = notepad.addNote(note); 111 | 112 | // Assert 113 | expect(res.notes[note.internalRef].parent).toEqual('abc'); 114 | }); 115 | 116 | it('should create a new object', () => { 117 | //Arrange 118 | // Act 119 | const res = notepad.addNote(note); 120 | 121 | // Assert 122 | expect(res).not.toBe(notepad); 123 | }); 124 | }); 125 | 126 | describe('addAsset', () => { 127 | let notepad: FlatNotepad; 128 | const asset = 'test'; 129 | 130 | beforeEach(() => { 131 | notepad = new FlatNotepad('test'); 132 | }); 133 | 134 | it('should add a new asset', () => { 135 | //Arrange 136 | // Act 137 | const res = notepad.addAsset(asset); 138 | 139 | // Assert 140 | expect(res.notepadAssets[0]).toEqual(asset); 141 | }); 142 | 143 | it('should create a new object', () => { 144 | //Arrange 145 | // Act 146 | const res = notepad.addAsset(asset); 147 | 148 | // Assert 149 | expect(res).not.toBe(notepad); 150 | }); 151 | }); 152 | 153 | describe('modified', () => { 154 | it('should update lastModified', () => { 155 | // Arrange 156 | const expected = new FlatNotepad('expected', { lastModified: new Date(32) }); 157 | let notepad = new FlatNotepad('test'); 158 | 159 | // Act 160 | notepad = notepad.modified(new Date(32)); 161 | 162 | // Assert 163 | expect(notepad.lastModified).toEqual(expected.lastModified); 164 | }); 165 | }); 166 | 167 | describe('toNotepad', () => { 168 | it('should convert to a full Notepad object', () => { 169 | // Arrange 170 | let notepad = new FlatNotepad('test', options); 171 | notepad = notepad.addSection({ title: 'one-deep', internalRef: '1d', parentRef: 'abc' }); 172 | notepad = notepad.addSection({ title: 'another root one', internalRef: 'r' }); 173 | 174 | // Act 175 | const res = notepad.toNotepad(); 176 | 177 | // Assert 178 | expect(res).toMatchSnapshot(); 179 | }); 180 | 181 | it('should be repeatable', () => { 182 | // Arrange 183 | let notepad = new FlatNotepad('test', options); 184 | notepad = notepad.addSection({ title: 'one-deep', internalRef: '1d', parentRef: 'abc' }); 185 | notepad = notepad.addSection({ title: 'another root one', internalRef: 'r' }); 186 | 187 | // Act 188 | const res = notepad.toNotepad().flatten().toNotepad().flatten(); 189 | 190 | // Assert 191 | expect(res).toEqual(notepad); 192 | }); 193 | }); 194 | 195 | describe('pathFrom', () => { 196 | const testNote = new Note('test note'); 197 | testNote.parent = '1d'; 198 | 199 | const notepad = new FlatNotepad('test', options) 200 | .addSection({ title: 'one-deep', internalRef: '1d', parentRef: 'abc' }) 201 | .addSection({ title: 'another root one', internalRef: 'r' }) 202 | .addNote(testNote); 203 | 204 | const mapToTitle = (obj: FlatNotepad | FlatSection): string => obj.title; 205 | 206 | it('should return a list of parents for a note', () => { 207 | // Arrange 208 | // Act 209 | const res = notepad.pathFrom(testNote); 210 | 211 | // Assert 212 | expect(res.map(mapToTitle)).toEqual([notepad.title, 'test section', 'one-deep']); 213 | }); 214 | 215 | it('should return a list of parents for a FlatSection', () => { 216 | // Arrange 217 | // Act 218 | const res = notepad.pathFrom({ parentRef: 'r' } as FlatSection); 219 | 220 | // Assert 221 | expect(res.map(mapToTitle)).toEqual([notepad.title, 'another root one']); 222 | }); 223 | 224 | it('should return an empty list for root-level items', () => { 225 | // Arrange 226 | // Act 227 | const res = notepad.pathFrom({} as FlatSection); 228 | 229 | // Assert 230 | expect(res.map(mapToTitle)).toEqual([notepad.title]); 231 | }); 232 | }); 233 | }); 234 | 235 | function getOptions(): FlatNotepadOptions { 236 | const testSection: FlatSection = { title: 'test section', internalRef: 'abc' }; 237 | const testNote = new Note('test note', new Date(1).getTime()).clone({ internalRef: 'etc' }); 238 | testNote.parent = 'abc'; 239 | 240 | return { 241 | lastModified: new Date(1), 242 | sections: { abc: testSection }, 243 | notes: { etc: testNote }, 244 | notepadAssets: ['test'], 245 | crypto: 'AES-256' 246 | }; 247 | } 248 | -------------------------------------------------------------------------------- /src/tests/Note.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestUtils } from './TestUtils'; 2 | import { Note } from '../index'; 3 | import { canOptimiseElement, ElementArgs, NoteElement, Source } from '../Note'; 4 | 5 | describe('Note', () => { 6 | let element: NoteElement; 7 | let source: Source; 8 | 9 | beforeEach(() => { 10 | element = { 11 | content: 'Hello, World!', 12 | type: 'markdown', 13 | args: { 14 | id: 'markdown1', 15 | x: '10px', 16 | y: '10px' 17 | } 18 | }; 19 | 20 | source = { 21 | id: 1, 22 | item: 'markdown1', 23 | content: 'https://nick.geek.nz' 24 | }; 25 | }); 26 | 27 | it('should construct', () => { 28 | // Arrange 29 | const title = 'test'; 30 | 31 | // Act 32 | const res = TestUtils.makeNote(title); 33 | 34 | // Assert 35 | expect(res).toBeInstanceOf(Note); 36 | expect(res.title).toEqual(title); 37 | }); 38 | 39 | describe('addElement', () => { 40 | let note: Note; 41 | 42 | beforeEach(() => { 43 | note = TestUtils.makeNote('test note'); 44 | }); 45 | 46 | it('should add a new element', () => { 47 | //Arrange 48 | // Act 49 | const res = note.addElement(element); 50 | 51 | // Assert 52 | expect(res.elements[0]).toEqual(element); 53 | }); 54 | 55 | it('should create a new object', () => { 56 | //Arrange 57 | // Act 58 | const res = note.addElement(element); 59 | 60 | // Assert 61 | expect(res).not.toBe(note); 62 | }); 63 | }); 64 | 65 | describe('addSource', () => { 66 | let note: Note; 67 | 68 | beforeEach(() => { 69 | note = TestUtils.makeNote('test note'); 70 | (note as any).bibliography = []; 71 | }); 72 | 73 | it('should add a new source', () => { 74 | //Arrange 75 | // Act 76 | const res = note.addSource(source); 77 | 78 | // Assert 79 | expect(res.bibliography[0]).toEqual(source); 80 | }); 81 | 82 | it('should store sources under one bibliography array', () => { 83 | //Arrange 84 | // Act 85 | const res = note 86 | .addSource(source) 87 | .addSource({ 88 | ...source, 89 | id: 2 90 | }); 91 | 92 | // Assert 93 | expect(res.bibliography).toHaveLength(2); 94 | }); 95 | 96 | 97 | it('should create a new object', () => { 98 | //Arrange 99 | // Act 100 | const res = note.addSource(source); 101 | 102 | // Assert 103 | expect(res).not.toBe(note); 104 | }); 105 | }); 106 | 107 | describe('search', () => { 108 | describe('title search', () => { 109 | it('should return empty array on no match', () => { 110 | // Arrange 111 | const note = TestUtils.makeNote('test'); 112 | 113 | // Act 114 | const res = note.search('invalid'); 115 | 116 | // Assert 117 | expect(res).toEqual([]); 118 | }); 119 | 120 | it('should return an array with the note on a match', () => { 121 | // Arrange 122 | const note = TestUtils.makeNote('test'); 123 | 124 | // Act 125 | const res = note.search('te'); 126 | 127 | // Assert 128 | expect(res).toEqual([note]); 129 | }); 130 | 131 | it('should return an array with the note on an empty query', () => { 132 | // Arrange 133 | const note = TestUtils.makeNote('test'); 134 | 135 | // Act 136 | const res = note.search(''); 137 | 138 | // Assert 139 | expect(res).toEqual([note]); 140 | }); 141 | }); 142 | 143 | describe('hashtag search', () => { 144 | it('should return empty array on no match', () => { 145 | // Arrange 146 | const note = TestUtils.makeNote('test'); 147 | 148 | // Act 149 | const res = note.search('#test'); 150 | 151 | // Assert 152 | expect(res).toEqual([]); 153 | }); 154 | 155 | it('should not accept a partial match', () => { 156 | // Arrange 157 | let note = TestUtils.makeNote('test'); 158 | note = note.addElement({ 159 | type: 'markdown', 160 | args: {} as ElementArgs, 161 | content: '#match' 162 | }); 163 | 164 | // Act 165 | const res = note.search('#mat'); 166 | 167 | // Assert 168 | expect(res).toEqual([]); 169 | }); 170 | 171 | it('should return an array with the note on a full match', () => { 172 | // Arrange 173 | let note = TestUtils.makeNote('test'); 174 | note = note.addElement({ 175 | type: 'markdown', 176 | args: {} as ElementArgs, 177 | content: '#match' 178 | }); 179 | 180 | // Act 181 | const res = note.search('#match'); 182 | 183 | // Assert 184 | expect(res).toEqual([note]); 185 | }); 186 | }); 187 | }); 188 | 189 | describe('getHashTags', () => { 190 | it('should return no matches with no hashtags', () => { 191 | // Arrange 192 | let note = TestUtils.makeNote('test'); 193 | note = note.addElement({ 194 | type: 'markdown', 195 | args: {} as ElementArgs, 196 | content: 'Hello.\n\nThere are no hashtags here.' 197 | }); 198 | 199 | // Act 200 | const res = note.getHashtags(); 201 | 202 | // Assert 203 | expect(res).toEqual([]); 204 | }); 205 | 206 | it('should return all the hashtags in all the elements', () => { 207 | // Arrange 208 | let note = TestUtils.makeNote('test'); 209 | note = note 210 | .addElement({ 211 | type: 'markdown', 212 | args: {} as ElementArgs, 213 | content: 'Hello.\n\nThere is #todo here.' 214 | }) 215 | .addElement({ 216 | type: 'markdown', 217 | args: {} as ElementArgs, 218 | content: 'We have #blah and #bloop here.' 219 | }); 220 | 221 | // Act 222 | const res = note.getHashtags(); 223 | 224 | // Assert 225 | expect(res).toEqual(['#todo', '#blah', '#bloop']); 226 | }); 227 | }); 228 | 229 | describe('getHeadingWords', () => { 230 | it('should return no matches with no headings', () => { 231 | // Arrange 232 | let note = TestUtils.makeNote('test'); 233 | note = note.addElement({ 234 | type: 'markdown', 235 | args: {} as ElementArgs, 236 | content: 'Hello.\n\nThere are no headings here.' 237 | }); 238 | 239 | // Act 240 | const res = note.getHeadingWords(); 241 | 242 | // Assert 243 | expect(res.size).toBe(0); 244 | }); 245 | 246 | it('should return all the heading words in all the elements', () => { 247 | // Arrange 248 | let note = TestUtils.makeNote('test'); 249 | note = note 250 | .addElement({ 251 | type: 'markdown', 252 | args: {} as ElementArgs, 253 | content: '# Hello\n\nThere is #todo here.' 254 | }) 255 | .addElement({ 256 | type: 'markdown', 257 | args: {} as ElementArgs, 258 | content: 'This\n\n## Has a sub-heading' 259 | }) 260 | .addElement({ 261 | type: 'markdown', 262 | args: {} as ElementArgs, 263 | content: 'This\n\n## Has a sub-heading\n# And a primary' 264 | }); 265 | 266 | // Act 267 | const res = note.getHeadingWords(); 268 | 269 | // Assert 270 | expect(res).toEqual(new Set(['Hello', 'Has', 'a', 'sub-heading', 'And', 'primary'])); 271 | }); 272 | }); 273 | 274 | it('should generate XML Object with required data', () => { 275 | // Arrange 276 | let note = TestUtils.makeNote('test note'); 277 | note = note.addElement(element); 278 | 279 | // Act 280 | const res = note.toXmlObject(); 281 | 282 | // Assert 283 | expect(res).toMatchSnapshot(); 284 | }); 285 | 286 | describe('canOptimiseElement', () => { 287 | it('should be true for image elements when not specified', () => { 288 | expect(canOptimiseElement({ 289 | type: 'image', 290 | content: 'AS', 291 | args: { 292 | id: 'image46-ghjg', 293 | x: '0', 294 | y: '0', 295 | width: '100px', 296 | height: '100px', 297 | } 298 | })).toBe(true); 299 | }); 300 | 301 | it('should be true for any element when tagged', () => { 302 | expect(canOptimiseElement({ 303 | type: 'markdown', 304 | content: 'AS', 305 | args: { 306 | id: 'markdown46-ghjg', 307 | x: '0', 308 | y: '0', 309 | width: '100px', 310 | height: '100px', 311 | canOptimise: true 312 | } 313 | })).toBe(true); 314 | }); 315 | 316 | it('should be false for image elements when width is auto', () => { 317 | expect(canOptimiseElement({ 318 | type: 'image', 319 | content: 'AS', 320 | args: { 321 | id: 'image46-ghjg', 322 | x: '0', 323 | y: '0', 324 | width: 'auto', 325 | height: '100px', 326 | } 327 | })).toBe(false); 328 | }); 329 | 330 | it('should be false for image elements when height is auto', () => { 331 | expect(canOptimiseElement({ 332 | type: 'image', 333 | content: 'AS', 334 | args: { 335 | id: 'image46-ghjg', 336 | x: '0', 337 | y: '0', 338 | width: '100px', 339 | height: 'auto', 340 | } 341 | })).toBe(false); 342 | }); 343 | 344 | it('should be false for image elements when width and height is auto', () => { 345 | expect(canOptimiseElement({ 346 | type: 'image', 347 | content: 'AS', 348 | args: { 349 | id: 'image46-ghjg', 350 | x: '0', 351 | y: '0', 352 | width: 'auto', 353 | height: 'auto', 354 | } 355 | })).toBe(false); 356 | }); 357 | 358 | it('should be false for image elements when tagged as false', () => { 359 | expect(canOptimiseElement({ 360 | type: 'image', 361 | content: 'AS', 362 | args: { 363 | id: 'image46-ghjg', 364 | x: '0', 365 | y: '0', 366 | width: 'auto', 367 | canOptimise: false 368 | } 369 | })).toBe(false); 370 | }); 371 | 372 | it('should be false for any element when not tagged', () => { 373 | expect(canOptimiseElement({ 374 | type: 'markdown', 375 | content: 'AS', 376 | args: { 377 | id: 'markdown46-ghjg', 378 | x: '0', 379 | y: '0', 380 | width: 'auto' 381 | } 382 | })).toBe(false); 383 | }); 384 | }); 385 | }); 386 | -------------------------------------------------------------------------------- /src/tests/Notepad.spec.ts: -------------------------------------------------------------------------------- 1 | import { Asset, Note, Notepad, Section, Translators } from '../'; 2 | import { NotepadOptions } from '../Notepad'; 3 | import { TestUtils } from './TestUtils'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | 7 | describe('Notepad', () => { 8 | let options = getOptions(); 9 | 10 | beforeEach(() => { 11 | options = getOptions(); 12 | }); 13 | 14 | describe('constructor', () => { 15 | it('should construct with just a title', () => { 16 | // Arrange 17 | const title = 'test'; 18 | 19 | // Act 20 | const n = new Notepad(title); 21 | 22 | // Assert 23 | expect(n).toBeInstanceOf(Notepad); 24 | expect(n.title).toEqual(title); 25 | }); 26 | 27 | Object.entries(options).forEach(option => 28 | it(`should construct with ${option[0]}`, () => { 29 | // Arrange 30 | const title = 'test'; 31 | 32 | // Act 33 | const n = new Notepad(title, { 34 | [option[0]]: option[1] 35 | }); 36 | 37 | // Assert 38 | expect(n).toBeInstanceOf(Notepad); 39 | expect(n.title).toEqual(title); 40 | expect(n[option[0]]).toMatchSnapshot(); 41 | }) 42 | ); 43 | }); 44 | 45 | describe('addSection', () => { 46 | let notepad: Notepad; 47 | let section: Section; 48 | 49 | beforeEach(() => { 50 | notepad = new Notepad('test'); 51 | section = new Section('test'); 52 | }); 53 | 54 | it('should add a new section', () => { 55 | //Arrange 56 | // Act 57 | const res = notepad.addSection(section); 58 | 59 | // Assert 60 | expect(res.sections[0]).toEqual(section); 61 | }); 62 | 63 | it('should create a new object', () => { 64 | //Arrange 65 | // Act 66 | const res = notepad.addSection(section); 67 | 68 | // Assert 69 | expect(res).not.toBe(notepad); 70 | }); 71 | }); 72 | 73 | describe('addAsset', () => { 74 | let notepad: Notepad; 75 | let asset: Asset; 76 | 77 | beforeEach(() => { 78 | notepad = new Notepad('test'); 79 | asset = TestUtils.makeAsset(); 80 | }); 81 | 82 | it('should add a new asset', () => { 83 | //Arrange 84 | // Act 85 | const res = notepad.addAsset(asset); 86 | 87 | // Assert 88 | expect(res.assets[0]).toEqual(asset); 89 | }); 90 | 91 | it('should add a new value to notepadAssets', () => { 92 | //Arrange 93 | // Act 94 | const res = notepad.addAsset(asset); 95 | 96 | // Assert 97 | expect(res.notepadAssets[0]).toEqual(asset.uuid); 98 | }); 99 | 100 | it('should create a new object', () => { 101 | //Arrange 102 | // Act 103 | const res = notepad.addAsset(asset); 104 | 105 | // Assert 106 | expect(res).not.toBe(notepad); 107 | }); 108 | }); 109 | 110 | describe('modified', () => { 111 | it('should update lastModified', () => { 112 | // Arrange 113 | const expected = new Notepad('expected', { lastModified: new Date(32) }); 114 | let notepad = new Notepad('test'); 115 | 116 | // Act 117 | notepad = notepad.modified(new Date(32)); 118 | 119 | // Assert 120 | expect(notepad.lastModified).toEqual(expected.lastModified); 121 | }); 122 | }); 123 | 124 | describe('search', () => { 125 | it('should call search on all notes', () => { 126 | // Arrange 127 | let n = new Notepad('test', options); 128 | n = n.clone({ 129 | sections: [ 130 | n.sections[0].addNote(TestUtils.makeNote('test note')) 131 | ] 132 | }); 133 | 134 | Note.prototype.search = jest.fn(() => [TestUtils.makeNote('test note')]); 135 | 136 | // Act 137 | const res = n.search('t'); 138 | 139 | // Assert 140 | expect(Note.prototype.search).toHaveBeenCalledWith('t'); 141 | }); 142 | }); 143 | 144 | describe('toJson', () => { 145 | it('should generate a JSON object of the notepad', async () => { 146 | // Arrange 147 | const title = 'test'; 148 | 149 | // Act 150 | const n = new Notepad(title, options); 151 | 152 | // Assert 153 | expect(await n.toJson()).toMatchSnapshot(); 154 | }); 155 | 156 | it('should generate a JSON object of the notepad with encrypted sections', async () => { 157 | // Arrange 158 | const title = 'test'; 159 | let n = new Notepad(title, { 160 | ...options, 161 | crypto: 'AES-256' 162 | }); 163 | 164 | n = n.addSection(TestUtils.makeSection('this is a test section')); 165 | 166 | // Act 167 | const json = await n.toJson('test'); 168 | 169 | // Assert 170 | expect(json).toMatchSnapshot(); 171 | }); 172 | }); 173 | 174 | describe('toXml', () => { 175 | it(`should generate the notepad's NPX file`, async () => { 176 | // Arrange 177 | let n = new Notepad('test', options); 178 | n = n.clone({ 179 | sections: [ 180 | n.sections[0].addNote(TestUtils.makeNote('test note')) 181 | ] 182 | }); 183 | 184 | // Act 185 | const res = await n.toXml(); 186 | 187 | // Assert 188 | expect(res).toMatchSnapshot(); 189 | }); 190 | }); 191 | 192 | describe('flatten', () => { 193 | it(`should generate a FlatNotepad correctly`, async () => { 194 | // Arrange 195 | let n = new Notepad('test', options); 196 | n = n.clone({ 197 | sections: [ 198 | n.sections[0] 199 | .addNote(TestUtils.makeNote('test note').clone({ internalRef: 'unique' })) 200 | .addSection(TestUtils.makeSection('nested').clone({ internalRef: 'deep' })) 201 | ] 202 | }); 203 | 204 | // Act 205 | const res = n.flatten(); 206 | 207 | // Assert 208 | expect(res).toMatchSnapshot(); 209 | expect(await res.toNotepad().toXml()).toEqual(await n.clone({ assets: [] }).toXml()); 210 | }); 211 | }); 212 | 213 | it('should convert a notepad to an array of MarkdownNotes', async () => { 214 | // Arrange 215 | const helpNpx = fs.readFileSync(path.join(__dirname, '__data__', 'Help.npx')).toString(); 216 | const notepad = await Translators.Xml.toNotepadFromNpx(helpNpx); 217 | 218 | // Act 219 | const res = await notepad.toMarkdown(notepad.assets); 220 | 221 | // Assert 222 | expect(res).toMatchSnapshot(); 223 | }); 224 | }); 225 | 226 | function getOptions(): NotepadOptions { 227 | const testSection = new Section('test section'); 228 | ( testSection).internalRef = 'abc'; 229 | 230 | return { 231 | lastModified: new Date(1), 232 | sections: [testSection], 233 | 234 | assets: [TestUtils.makeAsset()], 235 | notepadAssets: ['test'] 236 | }; 237 | } 238 | -------------------------------------------------------------------------------- /src/tests/SearchIndex.spec.ts: -------------------------------------------------------------------------------- 1 | import { FlatNotepad, Translators, Trie } from '../index'; 2 | import { TestUtils } from './TestUtils'; 3 | import { ElementArgs } from '../Note'; 4 | 5 | describe('SearchIndex', () => { 6 | let trie: Trie; 7 | 8 | beforeEach(() => { 9 | trie = new Trie(); 10 | }); 11 | 12 | it('should return the notes that match the search', () => { 13 | // Arrange 14 | let notepad = new FlatNotepad('test'); 15 | notepad = notepad.clone({ 16 | lastModified: new Date(1), 17 | notes: { 18 | abc: TestUtils.makeNote('hi'), 19 | abc2: TestUtils.makeNote('nope'), 20 | abc3: TestUtils.makeNote('hello') 21 | } 22 | }); 23 | const trie = Trie.buildTrie(notepad.notes); 24 | 25 | // Act 26 | const res = notepad.search(trie, 'h'); 27 | 28 | // Assert 29 | expect(res).toMatchSnapshot(); 30 | }); 31 | 32 | it('should search by word', () => { 33 | // Arrange 34 | let notepad = new FlatNotepad('test'); 35 | notepad = notepad.clone({ 36 | lastModified: new Date(1), 37 | notes: { 38 | abc: TestUtils.makeNote('hi'), 39 | abc2: TestUtils.makeNote('nope'), 40 | abc3: TestUtils.makeNote('hello there') 41 | } 42 | }); 43 | const trie = Trie.buildTrie(notepad.notes); 44 | 45 | // Act 46 | const res = notepad.search(trie, 'there'); 47 | 48 | // Assert 49 | expect(res).toMatchSnapshot(); 50 | }); 51 | 52 | it('should ignore brackets', () => { 53 | // Arrange 54 | const expected = TestUtils.makeNote('hello (there)'); 55 | let notepad = new FlatNotepad('test'); 56 | notepad = notepad.clone({ 57 | lastModified: new Date(1), 58 | notes: { 59 | abc: TestUtils.makeNote('hi'), 60 | abc2: TestUtils.makeNote('nope'), 61 | abc3: expected 62 | } 63 | }); 64 | const trie = Trie.buildTrie(notepad.notes); 65 | 66 | // Act 67 | const res = notepad.search(trie, 'there'); 68 | 69 | // Assert 70 | expect(res).toEqual([expected]); 71 | }); 72 | 73 | it('should split by slashes and commas', () => { 74 | // Arrange 75 | const expected = [ 76 | TestUtils.makeNote('hello/that'), 77 | TestUtils.makeNote('hi\\that'), 78 | TestUtils.makeNote('hi,that') 79 | ]; 80 | 81 | let notepad = new FlatNotepad('test'); 82 | notepad = notepad.clone({ 83 | lastModified: new Date(1), 84 | notes: { 85 | abc: TestUtils.makeNote('hi'), 86 | abc2: TestUtils.makeNote('nope'), 87 | abc3: expected[0], 88 | abc4: expected[1], 89 | abc5: expected[2] 90 | } 91 | }); 92 | const trie = Trie.buildTrie(notepad.notes); 93 | 94 | // Act 95 | const res = notepad.search(trie, 'that'); 96 | 97 | // Assert 98 | expect(res).toEqual(expected); 99 | }); 100 | 101 | it('should search by hashtag', () => { 102 | // Arrange 103 | const notepad = new FlatNotepad('test', { 104 | lastModified: new Date(1), 105 | notes: { 106 | abc: TestUtils.makeNote('hi'), 107 | abc2: TestUtils.makeNote('nope'), 108 | abc3: TestUtils.makeNote('hello') 109 | .addElement({ 110 | type: 'markdown', 111 | args: {} as ElementArgs, 112 | content: 'Sup #test' 113 | }) 114 | } 115 | }); 116 | const trie = Trie.buildTrie(notepad.notes); 117 | 118 | // Act 119 | const res = notepad.search(trie, '#test'); 120 | 121 | // Assert 122 | expect(res).toMatchSnapshot(); 123 | }); 124 | 125 | describe('heading search', () => { 126 | it('should search by heading', () => { 127 | // Arrange 128 | const notepad = new FlatNotepad('test', { 129 | lastModified: new Date(1), 130 | notes: { 131 | abc: TestUtils.makeNote('hi') 132 | .addElement({ 133 | type: 'markdown', 134 | args: {} as ElementArgs, 135 | content: '# Nothing interesting here\nNot at _all_' 136 | }), 137 | abc2: TestUtils.makeNote('nope') 138 | .addElement({ 139 | type: 'markdown', 140 | args: {} as ElementArgs, 141 | content: '# This is a heading' 142 | }), 143 | abc3: TestUtils.makeNote('hello') 144 | .addElement({ 145 | type: 'markdown', 146 | args: {} as ElementArgs, 147 | content: '## This is also a heading' 148 | }), 149 | abc4: TestUtils.makeNote('nah') 150 | .addElement({ 151 | type: 'markdown', 152 | args: {} as ElementArgs, 153 | content: '## This #heading will not match because hashtag' 154 | }) 155 | } 156 | }); 157 | const trie = Trie.buildTrie(notepad.notes); 158 | 159 | // Act 160 | const res = notepad.search(trie, 'headi'); 161 | 162 | // Assert 163 | expect(res).toMatchSnapshot(); 164 | }); 165 | }); 166 | 167 | it('should be able to return a list of all known hashtags', () => { 168 | // Arrange 169 | const notepad = new FlatNotepad('test', { 170 | lastModified: new Date(1), 171 | notes: { 172 | abc: TestUtils.makeNote('hi'), 173 | abc2: TestUtils.makeNote('nope'), 174 | abc3: TestUtils.makeNote('hello') 175 | .addElement({ 176 | type: 'markdown', 177 | args: {} as ElementArgs, 178 | content: 'Sup #test' 179 | }) 180 | } 181 | }); 182 | const trie = Trie.buildTrie(notepad.notes); 183 | 184 | const expected = ['#test']; 185 | 186 | // Act 187 | const res = trie.availableHashtags; 188 | 189 | // Assert 190 | expect(res).toEqual(expected); 191 | }); 192 | 193 | it('should not partial match hashtags', () => { 194 | // Arrange 195 | const notepad = new FlatNotepad('test', { 196 | lastModified: new Date(1), 197 | notes: { 198 | abc: TestUtils.makeNote('hi'), 199 | abc2: TestUtils.makeNote('nope'), 200 | abc3: TestUtils.makeNote('hello') 201 | .addElement({ 202 | type: 'markdown', 203 | args: {} as ElementArgs, 204 | content: 'Sup #test' 205 | }) 206 | } 207 | }); 208 | const trie = Trie.buildTrie(notepad.notes); 209 | 210 | // Act 211 | const res = notepad.search(trie, '#te'); 212 | 213 | // Assert 214 | expect(res).toEqual([]); 215 | }); 216 | 217 | // See https://github.com/MicroPad/MicroPad-Core/issues/215 218 | it('should handle sections with trailing spaces in their name', async () => { 219 | // Arrange 220 | const npx = ` 221 | 222 | 223 | 224 |
225 | 226 | 227 | 228 | 229 |
230 |
`; 231 | 232 | const notepad = await Translators.Xml.toNotepadFromNpx(npx); 233 | const expected = notepad.sections[0]?.notes[0]?.internalRef; 234 | if (!expected) throw new Error('Missing internalRef'); 235 | 236 | const trie = Trie.buildTrie(notepad.flatten().notes); 237 | 238 | // Act 239 | const res = Trie.search(trie, "Click"); 240 | 241 | // Assert 242 | expect(res).toEqual([expected]) 243 | }); 244 | 245 | describe('shouldReindex', () => { 246 | it('should reindex if the notepad has changed in date', () => { 247 | // Arrange 248 | const notepad = new FlatNotepad('test', { 249 | lastModified: new Date(1), 250 | notes: { 251 | abc: TestUtils.makeNote('hi'), 252 | abc2: TestUtils.makeNote('nope'), 253 | abc3: TestUtils.makeNote('hello') 254 | .addElement({ 255 | type: 'markdown', 256 | args: {} as ElementArgs, 257 | content: 'Sup #test' 258 | }) 259 | } 260 | }); 261 | const trie = Trie.buildTrie(notepad.notes, new Date(1)); 262 | 263 | // Act 264 | const res = Trie.shouldReindex(trie, new Date(5), 3); 265 | 266 | // Assert 267 | expect(res).toEqual(true); 268 | }); 269 | 270 | it('should reindex if the notepad has changed in number of notes', () => { 271 | // Arrange 272 | const notepad = new FlatNotepad('test', { 273 | lastModified: new Date(1), 274 | notes: { 275 | abc: TestUtils.makeNote('hi'), 276 | abc2: TestUtils.makeNote('nope'), 277 | abc3: TestUtils.makeNote('hello') 278 | .addElement({ 279 | type: 'markdown', 280 | args: {} as ElementArgs, 281 | content: 'Sup #test' 282 | }) 283 | } 284 | }); 285 | const trie = Trie.buildTrie(notepad.notes, new Date(1)); 286 | 287 | // Act 288 | const res = Trie.shouldReindex(trie, new Date(1), 5); 289 | 290 | // Assert 291 | expect(res).toEqual(true); 292 | }); 293 | 294 | it(`should not reindex if the notepad hasn't changed`, () => { 295 | // Arrange 296 | const notepad = new FlatNotepad('test', { 297 | lastModified: new Date(1), 298 | notes: { 299 | abc: TestUtils.makeNote('hi'), 300 | abc2: TestUtils.makeNote('nope'), 301 | abc3: TestUtils.makeNote('hello') 302 | .addElement({ 303 | type: 'markdown', 304 | args: {} as ElementArgs, 305 | content: 'Sup #test' 306 | }) 307 | } 308 | }); 309 | const trie = Trie.buildTrie(notepad.notes, new Date(1)); 310 | 311 | // Act 312 | const res = Trie.shouldReindex(trie, new Date(1), 3); 313 | 314 | // Assert 315 | expect(res).toEqual(false); 316 | }); 317 | }); 318 | }); 319 | -------------------------------------------------------------------------------- /src/tests/Section.spec.ts: -------------------------------------------------------------------------------- 1 | import { Note, Section } from '../index'; 2 | import { TestUtils } from './TestUtils'; 3 | 4 | describe('Section', () => { 5 | it('should construct', () => { 6 | // Arrange 7 | const title = 'test'; 8 | 9 | // Act 10 | const res = TestUtils.makeSection(title); 11 | 12 | // Assert 13 | expect(res).toBeInstanceOf(Section); 14 | expect(res.title).toEqual(title); 15 | }); 16 | 17 | describe('addSection', () => { 18 | let parent: Section; 19 | let child: Section; 20 | 21 | beforeEach(() => { 22 | parent = TestUtils.makeSection('test parent'); 23 | child = TestUtils.makeSection('test child'); 24 | }); 25 | 26 | it('should add a new section', () => { 27 | //Arrange 28 | // Act 29 | const res = parent.addSection(child); 30 | 31 | // Assert 32 | expect(res.sections[0]).toEqual(child.clone({ parent: res })); 33 | }); 34 | 35 | it('should create a new object', () => { 36 | //Arrange 37 | // Act 38 | const res = parent.addSection(child); 39 | 40 | // Assert 41 | expect(res).not.toBe(parent); 42 | }); 43 | }); 44 | 45 | describe('addNote', () => { 46 | let parent: Section; 47 | let child: Note; 48 | 49 | beforeEach(() => { 50 | parent = TestUtils.makeSection('test parent'); 51 | child = TestUtils.makeNote('test child'); 52 | }); 53 | 54 | it('should add a new note', () => { 55 | //Arrange 56 | // Act 57 | const res = parent.addNote(child); 58 | 59 | // Assert 60 | expect(res.notes[0]).toEqual(child.clone({ parent: res })); 61 | }); 62 | 63 | it('should create a new object', () => { 64 | //Arrange 65 | // Act 66 | const res = parent.addNote(child); 67 | 68 | // Assert 69 | expect(res).not.toBe(parent); 70 | }); 71 | }); 72 | 73 | describe('search', () => { 74 | it('should call search on all notes', () => { 75 | // Arrange 76 | let section = TestUtils.makeSection('test'); 77 | section = section.addNote(TestUtils.makeNote('hi')); 78 | 79 | let subSection = TestUtils.makeSection('sub'); 80 | subSection = subSection.addNote(TestUtils.makeNote('hello')); 81 | section = section.addSection(subSection); 82 | 83 | Note.prototype.search = jest.fn(() => [TestUtils.makeNote('hi')]); 84 | 85 | // Act 86 | section.search('h'); 87 | 88 | // Assert 89 | expect(Note.prototype.search).toHaveBeenCalledWith('h'); 90 | expect(Note.prototype.search).toHaveBeenCalledTimes(2); 91 | }); 92 | }); 93 | 94 | 95 | it('should generate XML Object with required data', () => { 96 | // Arrange 97 | let section = TestUtils.makeSection('test parent'); 98 | let child = TestUtils.makeSection('test child'); 99 | child = child.addNote(TestUtils.makeNote('test note')); 100 | 101 | section = section.addSection(child); 102 | 103 | // Act 104 | const res = section.toXmlObject(); 105 | 106 | // Assert 107 | expect(res).toMatchSnapshot(); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/tests/TestSetup.ts: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | global.fetch = async (url) => Promise.resolve({ 3 | blob: () => dataURItoBlob(url as string) 4 | }); 5 | 6 | // Thanks to http://stackoverflow.com/a/12300351/998467 7 | function dataURItoBlob(dataURI: string) { 8 | // convert base64 to raw binary data held in a string 9 | // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this 10 | let byteString = atob(dataURI.split(',')[1]); 11 | 12 | // separate out the mime component 13 | let mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]; 14 | 15 | // write the bytes of the string to an ArrayBuffer 16 | let ab = new ArrayBuffer(byteString.length); 17 | let ia = new Uint8Array(ab); 18 | for (let i = 0; i < byteString.length; i++) { 19 | ia[i] = byteString.charCodeAt(i); 20 | } 21 | 22 | // write the ArrayBuffer to a blob, and you're done 23 | return new Blob([ab], { type: mimeString }); 24 | } 25 | -------------------------------------------------------------------------------- /src/tests/TestUtils.ts: -------------------------------------------------------------------------------- 1 | import { Asset, Note, Section } from '../index'; 2 | import { Source } from '../Note'; 3 | 4 | export namespace TestUtils { 5 | export function makeSection(title: string, sections?: Section[], notes?: Note[], ref: string = 'abc'): Section { 6 | const section = new Section(title, sections, notes); 7 | (section as any).internalRef = ref; 8 | 9 | return section; 10 | } 11 | 12 | export function makeNote(title: string, time: Date = new Date(1), ref: string = 'abc'): Note { 13 | const source: Omit = { 14 | content: 'test', 15 | item: 'markdown1' 16 | }; 17 | 18 | const note = new Note(title, time.getTime()) 19 | .addSource({ ...source, id: 1 }) 20 | .addSource({ ...source, id: 2 }); 21 | 22 | (note as any).internalRef = ref; 23 | 24 | return note; 25 | } 26 | 27 | export function makeAsset(data: string = 'test', ref: string = 'abc'): Asset { 28 | // @ts-ignore Ignore the error here, Jest will manage this 29 | return new Asset(new Blob([data]), ref); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/tests/Translators.spec.ts: -------------------------------------------------------------------------------- 1 | import { Notepad, Translators } from '../index'; 2 | import { TestUtils } from './TestUtils'; 3 | import * as fs from 'fs'; 4 | import * as path from 'path'; 5 | import Asset from '../Asset'; 6 | import MarkdownImport = Translators.Markdown.MarkdownImport; 7 | import makeSection = TestUtils.makeSection; 8 | import makeNote = TestUtils.makeNote; 9 | 10 | describe('Translators', () => { 11 | describe('Json', () => { 12 | describe('toNotepad', () => { 13 | it('should return a Notepad object from JSON', async () => { 14 | // Arrange 15 | let expected: Notepad = new Notepad('test', { 16 | lastModified: new Date(1), 17 | notepadAssets: ['test'] 18 | }); 19 | 20 | let section = TestUtils.makeSection('test'); 21 | section = section.addSection(TestUtils.makeSection('sub')); 22 | section = section.addNote(TestUtils.makeNote('hello')); 23 | expected = expected.addSection(section); 24 | 25 | const json = await expected.toJson(); 26 | 27 | // Act 28 | const res = await Translators.Json.toNotepadFromNotepad(json); 29 | 30 | // Assert 31 | expect(res).toEqual(expected); 32 | }); 33 | 34 | it('should return a Notepad object from a plain object', async () => { 35 | // Arrange 36 | let expected: Notepad = new Notepad('test', { 37 | lastModified: new Date(1), 38 | notepadAssets: ['test'], 39 | crypto: 'AES-256' 40 | }); 41 | 42 | let section = TestUtils.makeSection('test'); 43 | section = section.addSection(TestUtils.makeSection('sub')); 44 | section = section.addNote(TestUtils.makeNote('hello')); 45 | expected = expected.addSection(section); 46 | 47 | const obj = { ...expected }; 48 | 49 | // Act 50 | const res = await Translators.Json.toNotepadFromNotepad(obj); 51 | 52 | // Assert 53 | expect(res).toEqual(expected); 54 | }); 55 | }); 56 | 57 | describe('toFlatNotepad', () => { 58 | it('should return a FlatNotepad object from JSON', async () => { 59 | // Arrange 60 | let testNotepad: Notepad = new Notepad('test', { 61 | lastModified: new Date(1), 62 | notepadAssets: ['test'] 63 | }); 64 | 65 | let section = TestUtils.makeSection('test', [], [], '1'); 66 | section = section.addSection(TestUtils.makeSection('sub', [], [], '2')); 67 | section = section.addNote(TestUtils.makeNote('hello', new Date(1), '3')); 68 | testNotepad = testNotepad.addSection(section); 69 | 70 | const expected = testNotepad.flatten(); 71 | const json = await testNotepad.toJson(); 72 | 73 | // Act 74 | const res = await Translators.Json.toFlatNotepadFromNotepad(json); 75 | 76 | // Assert 77 | expect(res).toEqual(expected); 78 | expect(res.toNotepad()).toEqual(testNotepad); 79 | }); 80 | 81 | it('should return a FlatNotepad object from a plain object', async () => { 82 | // Arrange 83 | let testNotepad: Notepad = new Notepad('test', { 84 | lastModified: new Date(1), 85 | notepadAssets: ['test'] 86 | }); 87 | 88 | let section = TestUtils.makeSection('test', [], [], '1'); 89 | section = section.addSection(TestUtils.makeSection('sub', [], [], '2')); 90 | section = section.addNote(TestUtils.makeNote('hello', new Date(1), '3')); 91 | testNotepad = testNotepad.addSection(section); 92 | 93 | const expected = testNotepad.flatten(); 94 | const obj = { ...testNotepad }; 95 | 96 | // Act 97 | const res = await Translators.Json.toFlatNotepadFromNotepad(obj); 98 | 99 | // Assert 100 | expect(res).toEqual(expected); 101 | expect(res.toNotepad()).toEqual(testNotepad); 102 | }); 103 | 104 | it('should have identical results to going via a Notepad object', async () => { 105 | // Arrange 106 | const npx = fs.readFileSync(path.join(__dirname, '__data__', 'Broken.npx')).toString(); 107 | console.warn = jest.fn(() => { return; }); 108 | 109 | // Act 110 | const notepad = await Translators.Xml.toNotepadFromNpx(npx); 111 | const flat = notepad.flatten(); 112 | const flatViaJson = await Translators.Json.toFlatNotepadFromNotepad(await notepad.toJson()); 113 | 114 | // Assert 115 | expect(flat.toNotepad()).toEqual(notepad); 116 | expect(flatViaJson.toNotepad()).toEqual(notepad); 117 | }); 118 | }); 119 | 120 | describe('toMarkdownFromJupyter', () => { 121 | const notebook = fs.readFileSync(path.join(__dirname, '__data__', 'notebook.ipynb')).toString(); 122 | 123 | it('should convert to the correct markdown', () => { 124 | // Arrange 125 | // Act 126 | const res = Translators.Json.toMarkdownFromJupyter(notebook); 127 | 128 | // Assert 129 | expect(res).toMatchSnapshot(); 130 | }); 131 | }); 132 | }); 133 | 134 | describe('Xml', () => { 135 | describe('toNotepadFromNpx', () => { 136 | const helpNpx = fs.readFileSync(path.join(__dirname, '__data__', 'Help.npx')).toString(); 137 | const brokenNpx = fs.readFileSync(path.join(__dirname, '__data__', 'Broken.npx')).toString(); 138 | const exampleNpx = fs.readFileSync(path.join(__dirname, '__data__', 'Example Notepad.npx')).toString(); 139 | 140 | it('should be identical to the source data', async () => { 141 | // Arrange 142 | // Act 143 | const parsed = await Translators.Xml.toNotepadFromNpx(helpNpx); 144 | const res = await parsed.toXml(); 145 | 146 | // Assert 147 | expect('' + res).toEqual(helpNpx); 148 | }); 149 | 150 | it('should still parse correctly even with invalid assets', async () => { 151 | // Arrange 152 | console.warn = jest.fn(() => { return; }); 153 | 154 | // Act 155 | const parsed = await Translators.Xml.toNotepadFromNpx(brokenNpx); 156 | 157 | // Assert 158 | expect(parsed.notepadAssets).toHaveLength(0); 159 | }); 160 | 161 | it('should convert inline assets (v1) to use the v2 asset system', async () => { 162 | // Arrange 163 | // Act 164 | const parsed = await Translators.Xml.toNotepadFromNpx(exampleNpx); 165 | 166 | // Assert 167 | expect(parsed.notepadAssets).toHaveLength(3); 168 | }); 169 | 170 | it('should never allow for element content to be undefined', async () => { 171 | // Arrange 172 | const emptyContent = fs.readFileSync(path.join(__dirname, '__data__', 'EmptyContent.npx')).toString(); 173 | 174 | // Act 175 | const notepad = await Translators.Xml.toNotepadFromNpx(emptyContent); 176 | 177 | // Assert 178 | expect(notepad.sections[0].notes[0].elements[0].content).toEqual(''); 179 | }); 180 | 181 | describe('should keep canOptimise consistent', () => { 182 | it(`should not be present in the element if it's not in the XML`, async () => { 183 | // Arrange 184 | let n = new Notepad('test', { lastModified: new Date(0) }); 185 | n = n.clone({ 186 | sections: [ 187 | makeSection('foo', [], [ 188 | makeNote('test note').clone({ 189 | elements: [{ 190 | type: 'image', 191 | content: 'AS', 192 | args: { 193 | id: 'image12234-4546', 194 | ext: 'fedsrf', 195 | x: '0', 196 | y: '0' 197 | } 198 | }] 199 | }) 200 | ]) 201 | ] 202 | }); 203 | const npx = await n.toXml(); 204 | 205 | // Act 206 | const res = await Translators.Xml.toNotepadFromNpx(npx); 207 | 208 | // Assert 209 | expect(res.sections[0].notes[0].elements[0].args.canOptimise).toBeUndefined(); 210 | }); 211 | 212 | it(`should be false on the element if it's false in the XML`, async () => { 213 | // Arrange 214 | let n = new Notepad('test', { lastModified: new Date(0) }); 215 | n = n.clone({ 216 | sections: [ 217 | makeSection('foo', [], [ 218 | makeNote('test note').clone({ 219 | elements: [{ 220 | type: 'image', 221 | content: 'AS', 222 | args: { 223 | id: 'image12234-4546', 224 | ext: 'fedsrf', 225 | x: '0', 226 | y: '0', 227 | canOptimise: false 228 | } 229 | }] 230 | }) 231 | ]) 232 | ] 233 | }); 234 | const npx = await n.toXml(); 235 | 236 | // Act 237 | const res = await Translators.Xml.toNotepadFromNpx(npx); 238 | 239 | // Assert 240 | expect(res.sections[0].notes[0].elements[0].args.canOptimise).toBe(false); 241 | }); 242 | 243 | it(`should be true on the element if it's false in the XML`, async () => { 244 | // Arrange 245 | let n = new Notepad('test', { lastModified: new Date(0) }); 246 | n = n.clone({ 247 | sections: [ 248 | makeSection('foo', [], [ 249 | makeNote('test note').clone({ 250 | elements: [{ 251 | type: 'image', 252 | content: 'AS', 253 | args: { 254 | id: 'image12234-4546', 255 | ext: 'fedsrf', 256 | x: '0', 257 | y: '0', 258 | canOptimise: true 259 | } 260 | }] 261 | }) 262 | ]) 263 | ] 264 | }); 265 | const npx = await n.toXml(); 266 | 267 | // Act 268 | const res = await Translators.Xml.toNotepadFromNpx(npx); 269 | 270 | // Assert 271 | expect(res.sections[0].notes[0].elements[0].args.canOptimise).toBe(true); 272 | }); 273 | }); 274 | }); 275 | 276 | describe('toNotepadFromEnex', () => { 277 | const sampleEnex = fs.readFileSync(path.join(__dirname, '__data__', 'sample-enex.enex')).toString(); 278 | 279 | it('should convert the notepad correctly', async () => { 280 | // Arrange 281 | let guidCounter = 0; 282 | (Asset as any).prototype.generateGuid = jest.fn(() => 'abc' + ++guidCounter); 283 | 284 | // Act 285 | const res = await Translators.Xml.toNotepadFromEnex(sampleEnex); 286 | 287 | // Assert 288 | expect(await (res.clone({ lastModified: new Date(1) })).toXml()).toMatchSnapshot(); 289 | }) 290 | }); 291 | }); 292 | 293 | describe('Markdown', () => { 294 | it('should convert the notepad correctly', async () => { 295 | // Arrange 296 | const md: MarkdownImport[] = [ 297 | { 298 | title: 'Test Import', 299 | content: '# This is some md\n\n**yeet**' 300 | }, 301 | { 302 | title: 'Test Duplicate', 303 | content: 'yeet' 304 | }, 305 | { 306 | title: 'Test Duplicate', 307 | content: 'yeet' 308 | } 309 | ]; 310 | 311 | // Act 312 | const res = Translators.Markdown.toNotepadFromMarkdown(md); 313 | res.sections[0].notes.forEach(note => (note).time = 1); 314 | 315 | let t = await res.clone({ lastModified: new Date(1) }, 'Import Test').toXml(); 316 | 317 | // Assert 318 | expect(await res.clone({ lastModified: new Date(1) }, 'Import Test').toXml()).toMatchSnapshot(); 319 | }); 320 | }); 321 | }); 322 | -------------------------------------------------------------------------------- /src/tests/__data__/Broken.npx: -------------------------------------------------------------------------------- 1 | this is very invalid -------------------------------------------------------------------------------- /src/tests/__data__/EmptyContent.npx: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 | -------------------------------------------------------------------------------- /src/tests/__data__/Example Notepad.npx: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | asciimath 11 | 12 | 13 | 14 | https://www.flickr.com/photos/othree/15576500626/ 15 | 16 | I like using [GitHub](https://github.com). 21 | 22 | * Bullet lists are easy to create 23 | * I can also do fancy mathy stuff! 24 | - ===2Mg + O_2 -> 2MgO=== 25 | - ===a/sin (A) = b/sin(B) = c/sin(C)=== 26 | - ===(-b +-sqrt(b^2-4ac))/(2a)===, easy as ===pi===! 27 | * As you can see math and science is easy with [AsciiMath](http://asciimath.org/). Just put three equal signs around your equation. 28 | 29 | Oh! Tables work too: 30 | 31 | |Cool Projects | Reason for Development| 32 | |--|--| 33 | |μPad| I had an exam I had to study for|]]> 34 | I like that you can place stuff **anywhere** 35 | 36 | 37 | ===A = cos^-1 ((b^2+c^2-a^2)/(2bc))=== 38 | 39 | 40 | data:image/png;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAtxyCH/H5Ddpawsy9S/AAAAAElFTkSuQmCC 41 | data:image/jpeg;base64,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 42 | 43 | 44 | 45 | 46 | https://github.com/NickGeek/uPad/graphs/contributors 47 | 48 | [Me](https://nick.geek.nz). Just a silly idea that could work. 49 | 50 |
51 |
52 |
53 |
54 | 55 | 56 | 57 | # Yup 😄 58 | 59 |
60 |
61 | 62 | 63 | 64 | tag for images so editors can tell the difference.]]> 68 | data:image/png;base64,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 69 | 70 |
71 |
72 | -------------------------------------------------------------------------------- /src/tests/__data__/sample-enex.enex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Test Note for Export 6 | 7 | 8 | 9 | 10 | Hello, World. 11 |
12 |
13 |

Test of mass nesting

14 |
15 |
16 | 17 |
18 |
19 |
20 |
21 | An item that I haven't completed yet. 22 |
23 | An completed item. 24 |
25 | ]]> 26 |
27 | 20130730T205204Z 28 | 20130730T205624Z 29 | fake-tag 30 | 31 | 33.88394692352314 32 | -117.9191355110099 33 | 96 34 | Brett Kelly 35 | 36 | 37 | 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 38 | image/gif 39 | 200 40 | 200 41 | 42 | test.gif 43 | 44 | 45 | 46 | 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 47 | image/gif 48 | 200 49 | 200 50 | 51 | test2.gif 52 | 53 | 54 |
55 |
56 | 57 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/Asset.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Asset should generate XML Object with required data 1`] = ` 4 | Object { 5 | "$": Object { 6 | "uuid": "abc", 7 | }, 8 | "_": "data:application/octet-stream;base64,dGVzdA==", 9 | } 10 | `; 11 | 12 | exports[`Asset toString should generate base64 from the asset 1`] = `"data:application/octet-stream;base64,dGVzdA=="`; 13 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/FlatNotepad.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`FlatNotepad constructor should construct with crypto 1`] = `"AES-256"`; 4 | 5 | exports[`FlatNotepad constructor should construct with lastModified 1`] = `"1970-01-01T12:00:00.001+12:00"`; 6 | 7 | exports[`FlatNotepad constructor should construct with notepadAssets 1`] = ` 8 | Array [ 9 | "test", 10 | ] 11 | `; 12 | 13 | exports[`FlatNotepad constructor should construct with notes 1`] = ` 14 | Object { 15 | "etc": Note { 16 | "bibliography": Array [], 17 | "elements": Array [], 18 | "internalRef": "etc", 19 | "parent": "abc", 20 | "time": 1, 21 | "title": "test note", 22 | }, 23 | } 24 | `; 25 | 26 | exports[`FlatNotepad constructor should construct with sections 1`] = ` 27 | Object { 28 | "abc": Object { 29 | "internalRef": "abc", 30 | "title": "test section", 31 | }, 32 | } 33 | `; 34 | 35 | exports[`FlatNotepad toNotepad should convert to a full Notepad object 1`] = ` 36 | Notepad { 37 | "assets": Array [], 38 | "crypto": "AES-256", 39 | "lastModified": "1970-01-01T12:00:00.001+12:00", 40 | "notepadAssets": Array [ 41 | "test", 42 | ], 43 | "sections": Array [ 44 | Section { 45 | "internalRef": "abc", 46 | "notes": Array [ 47 | Note { 48 | "bibliography": Array [], 49 | "elements": Array [], 50 | "internalRef": "etc", 51 | "parent": [Circular], 52 | "time": 1, 53 | "title": "test note", 54 | }, 55 | ], 56 | "parent": Notepad { 57 | "assets": Array [], 58 | "crypto": "AES-256", 59 | "lastModified": "1970-01-01T12:00:00.001+12:00", 60 | "notepadAssets": Array [ 61 | "test", 62 | ], 63 | "sections": Array [ 64 | [Circular], 65 | ], 66 | "title": "test", 67 | }, 68 | "sections": Array [ 69 | Section { 70 | "internalRef": "1d", 71 | "notes": Array [], 72 | "parent": Section { 73 | "internalRef": "abc", 74 | "notes": Array [], 75 | "parent": undefined, 76 | "sections": Array [ 77 | [Circular], 78 | ], 79 | "title": "test section", 80 | }, 81 | "sections": Array [], 82 | "title": "one-deep", 83 | }, 84 | ], 85 | "title": "test section", 86 | }, 87 | Section { 88 | "internalRef": "r", 89 | "notes": Array [], 90 | "parent": [Circular], 91 | "sections": Array [], 92 | "title": "another root one", 93 | }, 94 | ], 95 | "title": "test", 96 | } 97 | `; 98 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/Note.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Note should generate XML Object with required data 1`] = ` 4 | Object { 5 | "note": Object { 6 | "$": Object { 7 | "time": "1970-01-01T12:00:00.001+12:00", 8 | "title": "test note", 9 | }, 10 | "addons": Array [ 11 | Array [], 12 | ], 13 | "bibliography": Object { 14 | "source": Array [ 15 | Object { 16 | "$": Object { 17 | "id": 1, 18 | "item": "markdown1", 19 | }, 20 | "_": "test", 21 | }, 22 | Object { 23 | "$": Object { 24 | "id": 2, 25 | "item": "markdown1", 26 | }, 27 | "_": "test", 28 | }, 29 | ], 30 | }, 31 | "markdown": Array [ 32 | Object { 33 | "$": Object { 34 | "id": "markdown1", 35 | "x": "10px", 36 | "y": "10px", 37 | }, 38 | "_": "Hello, World!", 39 | }, 40 | ], 41 | }, 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/SearchIndex.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchIndex heading search should search by heading 1`] = ` 4 | Array [ 5 | Note { 6 | "bibliography": Array [ 7 | Object { 8 | "content": "test", 9 | "id": 1, 10 | "item": "markdown1", 11 | }, 12 | Object { 13 | "content": "test", 14 | "id": 2, 15 | "item": "markdown1", 16 | }, 17 | ], 18 | "elements": Array [ 19 | Object { 20 | "args": Object {}, 21 | "content": "# This is a heading", 22 | "type": "markdown", 23 | }, 24 | ], 25 | "internalRef": "abc", 26 | "parent": undefined, 27 | "time": 1, 28 | "title": "nope", 29 | }, 30 | Note { 31 | "bibliography": Array [ 32 | Object { 33 | "content": "test", 34 | "id": 1, 35 | "item": "markdown1", 36 | }, 37 | Object { 38 | "content": "test", 39 | "id": 2, 40 | "item": "markdown1", 41 | }, 42 | ], 43 | "elements": Array [ 44 | Object { 45 | "args": Object {}, 46 | "content": "## This is also a heading", 47 | "type": "markdown", 48 | }, 49 | ], 50 | "internalRef": "abc", 51 | "parent": undefined, 52 | "time": 1, 53 | "title": "hello", 54 | }, 55 | ] 56 | `; 57 | 58 | exports[`SearchIndex should return the notes that match the search 1`] = ` 59 | Array [ 60 | Note { 61 | "bibliography": Array [ 62 | Object { 63 | "content": "test", 64 | "id": 1, 65 | "item": "markdown1", 66 | }, 67 | Object { 68 | "content": "test", 69 | "id": 2, 70 | "item": "markdown1", 71 | }, 72 | ], 73 | "elements": Array [], 74 | "internalRef": "abc", 75 | "parent": undefined, 76 | "time": 1, 77 | "title": "hi", 78 | }, 79 | Note { 80 | "bibliography": Array [ 81 | Object { 82 | "content": "test", 83 | "id": 1, 84 | "item": "markdown1", 85 | }, 86 | Object { 87 | "content": "test", 88 | "id": 2, 89 | "item": "markdown1", 90 | }, 91 | ], 92 | "elements": Array [], 93 | "internalRef": "abc", 94 | "parent": undefined, 95 | "time": 1, 96 | "title": "hello", 97 | }, 98 | ] 99 | `; 100 | 101 | exports[`SearchIndex should search by hashtag 1`] = ` 102 | Array [ 103 | Note { 104 | "bibliography": Array [ 105 | Object { 106 | "content": "test", 107 | "id": 1, 108 | "item": "markdown1", 109 | }, 110 | Object { 111 | "content": "test", 112 | "id": 2, 113 | "item": "markdown1", 114 | }, 115 | ], 116 | "elements": Array [ 117 | Object { 118 | "args": Object {}, 119 | "content": "Sup #test", 120 | "type": "markdown", 121 | }, 122 | ], 123 | "internalRef": "abc", 124 | "parent": undefined, 125 | "time": 1, 126 | "title": "hello", 127 | }, 128 | ] 129 | `; 130 | 131 | exports[`SearchIndex should search by word 1`] = ` 132 | Array [ 133 | Note { 134 | "bibliography": Array [ 135 | Object { 136 | "content": "test", 137 | "id": 1, 138 | "item": "markdown1", 139 | }, 140 | Object { 141 | "content": "test", 142 | "id": 2, 143 | "item": "markdown1", 144 | }, 145 | ], 146 | "elements": Array [], 147 | "internalRef": "abc", 148 | "parent": undefined, 149 | "time": 1, 150 | "title": "hello there", 151 | }, 152 | ] 153 | `; 154 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/Section.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Section should generate XML Object with required data 1`] = ` 4 | Object { 5 | "section": Object { 6 | "$": Object { 7 | "title": "test parent", 8 | }, 9 | "note": Array [], 10 | "section": Array [ 11 | Object { 12 | "$": Object { 13 | "title": "test child", 14 | }, 15 | "note": Array [ 16 | Object { 17 | "$": Object { 18 | "time": "1970-01-01T12:00:00.001+12:00", 19 | "title": "test note", 20 | }, 21 | "addons": Array [ 22 | Array [], 23 | ], 24 | "bibliography": Object { 25 | "source": Array [ 26 | Object { 27 | "$": Object { 28 | "id": 1, 29 | "item": "markdown1", 30 | }, 31 | "_": "test", 32 | }, 33 | Object { 34 | "$": Object { 35 | "id": 2, 36 | "item": "markdown1", 37 | }, 38 | "_": "test", 39 | }, 40 | ], 41 | }, 42 | }, 43 | ], 44 | "section": Array [], 45 | }, 46 | ], 47 | }, 48 | } 49 | `; 50 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/Translators.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Translators Json toMarkdownFromJupyter should convert to the correct markdown 1`] = ` 4 | "# Lists 5 | ## Creating a list 6 | 7 | 8 | 9 | * A string is a sequence of characters 10 | 11 | * A list contains a sequence of any type 12 | 13 | * A list is denoted with brackets [ and ] 14 | 15 | * Can contain a nested list 16 | 17 | \`\`\` 18 | mylist = [\\"a\\", \\"b\\", \\"c\\"] 19 | 20 | print (mylist) 21 | 22 | print (len(mylist)) 23 | 24 | print (mylist[0]) 25 | 26 | -------------------- 27 | Output: 28 | ['a', 'b', 'c'] 29 | 3 30 | a 31 | 32 | -------------------- 33 | \`\`\` 34 | 35 | \`\`\` 36 | vocabulary = [\\"iteration\\", \\"selection\\", \\"control\\"] 37 | 38 | numbers = [17, 123] 39 | 40 | empty = [] 41 | 42 | mixedlist = [\\"hello\\", 2.0, 5*2, [10, 20]] 43 | 44 | 45 | 46 | print (numbers) 47 | 48 | print (mixedlist) 49 | 50 | print (empty) 51 | 52 | newlist = [numbers, vocabulary,['test string', 'and' ,'another']] 53 | 54 | print (newlist) 55 | 56 | 57 | -------------------- 58 | Output: 59 | [17, 123] 60 | ['hello', 2.0, 10, [10, 20]] 61 | [] 62 | [[17, 123], ['iteration', 'selection', 'control'], ['test string', 'and', 'another']] 63 | 64 | -------------------- 65 | \`\`\` 66 | 67 | \`\`\` 68 | print (len(newlist)) 69 | 70 | -------------------- 71 | Output: 72 | 3 73 | 74 | -------------------- 75 | \`\`\` 76 | ## Common operations 77 | 78 | 79 | 80 | * \`len\` returns the length of the list 81 | 82 | * concatentation (\`+\`) and repetition (\`*\`) 83 | 84 | * _creates new list objects_ 85 | 86 | * access elements (\`[i]\` where \`i=>0\`) 87 | 88 | * slicing (\`[i:j]\` for elements between \`i\` and up to but not including \`j\`) 89 | 90 | * membership tests (\`a in b\`) 91 | 92 | 93 | \`\`\` 94 | browncoats = [\\"Zoe\\", \\"Malcolm\\"] 95 | 96 | crew = [\\"Hoban\\", \\"Kaylee\\"] 97 | 98 | passengers = [\\"River\\", \\"Shepherd\\", \\"Simon\\", \\"Inara\\"] 99 | 100 | cargo = [\\"Contrabrand\\"] 101 | 102 | print (len(passengers)) 103 | 104 | print (len(cargo)) 105 | 106 | print('hello') 107 | 108 | -------------------- 109 | Output: 110 | 4 111 | 1 112 | hello 113 | 114 | -------------------- 115 | \`\`\` 116 | 117 | \`\`\` 118 | firefly = browncoats + crew + passengers 119 | 120 | firefly 121 | \`\`\` 122 | 123 | \`\`\` 124 | firefly = firefly + cargo*2 125 | 126 | print (firefly) 127 | 128 | -------------------- 129 | Output: 130 | ['Zoe', 'Malcolm', 'Hoban', 'Kaylee', 'River', 'Shepherd', 'Simon', 'Inara', 'Contrabrand', 'Contrabrand'] 131 | 132 | -------------------- 133 | \`\`\` 134 | 135 | \`\`\` 136 | firefly[0] 137 | \`\`\` 138 | 139 | \`\`\` 140 | firefly[-3] 141 | \`\`\` 142 | 143 | \`\`\` 144 | print (firefly[1:3]) 145 | 146 | firefly[1:3][0] 147 | 148 | -------------------- 149 | Output: 150 | ['Malcolm', 'Hoban'] 151 | 152 | -------------------- 153 | \`\`\` 154 | 155 | \`\`\` 156 | if 'River' in firefly: 157 | 158 | print (firefly) 159 | 160 | -------------------- 161 | Output: 162 | ['Zoe', 'Malcolm', 'Hoban', 'Kaylee', 'River', 'Shepherd', 'Simon', 'Inara', 'Contrabrand', 'Contrabrand'] 163 | 164 | -------------------- 165 | \`\`\` 166 | 167 | \`\`\` 168 | if 'River' not in browncoats: 169 | 170 | print ('moo') 171 | 172 | -------------------- 173 | Output: 174 | moo 175 | 176 | -------------------- 177 | \`\`\` 178 | ## Lists are mutable objects 179 | 180 | 181 | 182 | >Unlike strings, you can modify lists. 183 | 184 | \`\`\` 185 | # Replace a value 186 | 187 | firefly[1] = \\"Kirk\\" 188 | 189 | print (firefly) 190 | 191 | -------------------- 192 | Output: 193 | ['Zoe', 'Kirk', 'Hoban', 'Kaylee', 'River', 'Shepherd', 'Simon', 'Inara', 'Contrabrand', 'Contrabrand'] 194 | 195 | -------------------- 196 | \`\`\` 197 | >Del deletes elements 198 | 199 | \`\`\` 200 | print(firefly) 201 | 202 | del firefly[0] 203 | 204 | print(firefly) 205 | 206 | -------------------- 207 | Output: 208 | ['Zoe', 'Kirk', 'Hoban', 'Kaylee', 'River', 'Shepherd', 'Simon', 'Inara', 'Contrabrand', 'Contrabrand'] 209 | ['Kirk', 'Hoban', 'Kaylee', 'River', 'Shepherd', 'Simon', 'Inara', 'Contrabrand', 'Contrabrand'] 210 | 211 | -------------------- 212 | \`\`\` 213 | 214 | \`\`\` 215 | del firefly[2:4] 216 | 217 | print(firefly) 218 | 219 | -------------------- 220 | Output: 221 | ['Kirk', 'Hoban', 'Shepherd', 'Simon', 'Inara', 'Contrabrand', 'Contrabrand'] 222 | 223 | -------------------- 224 | \`\`\` 225 | 226 | \`\`\` 227 | del firefly[-2:] 228 | 229 | print(firefly) 230 | 231 | -------------------- 232 | Output: 233 | ['Kirk', 'Hoban', 'Shepherd', 'Simon', 'Inara'] 234 | 235 | -------------------- 236 | \`\`\` 237 | > Lists can be considered objects. 238 | 239 | **Objects** are like animals: they know how to do stuff (like eat and sleep), they know how to interact with others (like make children), and they have characteristics (like height, weight). 240 | 241 | 242 | 243 | > \\"Knowing how to do stuff\\" with itself is called a method. In this case \\"append\\" is a method which, when invoked, is an action that changes the characteristics (the data vector of the list itself). 244 | > Append is used to add new elements to the list 245 | 246 | \`\`\` 247 | firefly.append(\\"Reaver\\") 248 | 249 | firefly.append([\\"Reaver\\", \\"Spock\\"]) # inserts a list 250 | 251 | print(firefly) 252 | 253 | 254 | -------------------- 255 | Output: 256 | ['Kirk', 'Hoban', 'Shepherd', 'Simon', 'Inara', 'Reaver', ['Reaver', 'Spock']] 257 | 258 | -------------------- 259 | \`\`\` 260 | > Extending the list allows new elements from another list to be added 261 | 262 | \`\`\` 263 | firefly.extend([\\"Sulu\\", \\"McCoy\\"]) 264 | 265 | print(firefly) 266 | 267 | -------------------- 268 | Output: 269 | ['Kirk', 'Hoban', 'Shepherd', 'Simon', 'Inara', 'Reaver', ['Reaver', 'Spock'], 'Sulu', 'McCoy'] 270 | 271 | -------------------- 272 | \`\`\` 273 | > Another way to extend a list is using the addition operator 274 | 275 | \`\`\` 276 | firefly += [\\"Uhura\\"] # this is almost the same as extend but doesn't use a function call so its slightly faster 277 | 278 | print(firefly) 279 | 280 | -------------------- 281 | Output: 282 | ['Kirk', 'Hoban', 'Shepherd', 'Simon', 'Inara', 'Reaver', ['Reaver', 'Spock'], 'Sulu', 'McCoy', 'Uhura'] 283 | 284 | -------------------- 285 | \`\`\` 286 | > We can also treat a list a bit like a queue, remove the last element 287 | 288 | \`\`\` 289 | print(firefly) 290 | 291 | whatwaspopped = firefly.pop() 292 | 293 | print(whatwaspopped) 294 | 295 | -------------------- 296 | Output: 297 | ['Kirk', 'Hoban', 'Shepherd', 'Simon', 'Inara', 'Reaver', ['Reaver', 'Spock'], 'Sulu', 'McCoy', 'Uhura'] 298 | Uhura 299 | 300 | -------------------- 301 | \`\`\` 302 | > ... or remove the first element (or any we like as indicated by the index) 303 | 304 | \`\`\` 305 | firefly.pop(0) 306 | \`\`\` 307 | > We can also insert elements at arbitrary points 308 | 309 | \`\`\` 310 | firefly.insert(1, \\"Zaphod\\") 311 | 312 | print(firefly) 313 | 314 | -------------------- 315 | Output: 316 | ['Hoban', 'Zaphod', 'Shepherd', 'Simon', 'Inara', 'Reaver', ['Reaver', 'Spock'], 'Sulu', 'McCoy'] 317 | 318 | -------------------- 319 | \`\`\` 320 | ## Copying Lists 321 | 322 | \`\`\` 323 | a = [1,2,3] 324 | 325 | print(a) 326 | 327 | -------------------- 328 | Output: 329 | [1, 2, 3] 330 | 331 | -------------------- 332 | \`\`\` 333 | 334 | \`\`\` 335 | b = a 336 | 337 | print (b) 338 | 339 | -------------------- 340 | Output: 341 | [1, 2, 3] 342 | 343 | -------------------- 344 | \`\`\` 345 | 346 | \`\`\` 347 | a[0] = 'change' 348 | 349 | print (a) 350 | 351 | print (b) 352 | 353 | -------------------- 354 | Output: 355 | ['change', 2, 3] 356 | ['change', 2, 3] 357 | 358 | -------------------- 359 | \`\`\` 360 | 361 | \`\`\` 362 | a = [1,2,3] 363 | 364 | b = a[:] # clone a 365 | 366 | print (b) 367 | 368 | -------------------- 369 | Output: 370 | [1, 2, 3] 371 | 372 | -------------------- 373 | \`\`\` 374 | 375 | \`\`\` 376 | a[0] = 'test' 377 | 378 | print (a) 379 | 380 | print (b) 381 | 382 | -------------------- 383 | Output: 384 | ['test', 2, 3] 385 | [1, 2, 3] 386 | 387 | -------------------- 388 | \`\`\` 389 | ## Searching, sorting, & counting 390 | * sort a list in ascending or descending order 391 | 392 | * find the index of a given element 393 | 394 | * count the number of matching elements 395 | 396 | * remove a given element 397 | 398 | \`\`\` 399 | v = [1, 3, 2, 3, 4, 2] 400 | 401 | v.sort() 402 | 403 | print(v) 404 | 405 | -------------------- 406 | Output: 407 | [1, 2, 2, 3, 3, 4] 408 | 409 | -------------------- 410 | \`\`\` 411 | > \`reverse\` is a keyword of the \`.sort()\` method 412 | 413 | \`\`\` 414 | v.sort(reverse=True) 415 | 416 | print(v) 417 | 418 | -------------------- 419 | Output: 420 | [4, 3, 3, 2, 2, 1] 421 | 422 | -------------------- 423 | \`\`\` 424 | > \`.sort()\` changes the the list in place but does NOT return it 425 | 426 | \`\`\` 427 | v = [1,1,1,1,5,5,3,2,4,2] 428 | 429 | test = v.sort() 430 | 431 | print (v) 432 | 433 | print (test) 434 | 435 | -------------------- 436 | Output: 437 | [1, 1, 1, 1, 2, 2, 3, 4, 5, 5] 438 | None 439 | 440 | -------------------- 441 | \`\`\` 442 | >Count will return how many times something is featured in a list 443 | 444 | \`\`\` 445 | print (v.count(1)) 446 | 447 | print (v.count(2)) 448 | 449 | print (v.count(3)) 450 | 451 | -------------------- 452 | Output: 453 | 4 454 | 2 455 | 1 456 | 457 | -------------------- 458 | \`\`\` 459 | >Remove removes a value from the list but doesn't return it 460 | 461 | \`\`\` 462 | num = v.remove(3) 463 | 464 | print (num) 465 | 466 | print (v) 467 | 468 | -------------------- 469 | Output: 470 | None 471 | [1, 1, 1, 1, 2, 2, 4, 5, 5] 472 | 473 | -------------------- 474 | \`\`\` 475 | ## Iterating Over Lists 476 | Iterate over the items in the list can be done in a number of ways. 477 | >If you want to know the index of each item you can use enumerate() 478 | 479 | \`\`\` 480 | a = ['cat', 'window', 'defenestrate'] 481 | 482 | for x, val in enumerate(a): 483 | 484 | print(x,val) 485 | 486 | -------------------- 487 | Output: 488 | 0 cat 489 | 1 window 490 | 2 defenestrate 491 | 492 | -------------------- 493 | \`\`\` 494 | >Or if you don't care about the index, just use a for loop 495 | 496 | \`\`\` 497 | for val in a: 498 | 499 | print (val) 500 | 501 | -------------------- 502 | Output: 503 | cat 504 | window 505 | defenestrate 506 | 507 | -------------------- 508 | \`\`\` 509 | >Or you can create a for loop using the length of the list 510 | 511 | \`\`\` 512 | for x in range(0, len(a)): 513 | 514 | print(x,a[x]) 515 | 516 | -------------------- 517 | Output: 518 | 0 cat 519 | 1 window 520 | 2 defenestrate 521 | 522 | -------------------- 523 | \`\`\` 524 | 525 | \`\`\` 526 | a = ['Mary', 'had', 'a', 'little', 'lamb'] 527 | 528 | for i in range(len(a)): 529 | 530 | print (i, a[i]) 531 | 532 | -------------------- 533 | Output: 534 | 0 Mary 535 | 1 had 536 | 2 a 537 | 3 little 538 | 4 lamb 539 | 540 | -------------------- 541 | \`\`\` 542 | >The range() function returns a sequence of numbers: from START to STOP by STEP 543 | 544 | \`\`\` 545 | a = [1,2,3,4,5,6,7,8,9,10] 546 | 547 | print (a) 548 | 549 | for z in range(1, 10, 2): # from 1 to 10 each step is 2 550 | 551 | print (a[z]) 552 | 553 | -------------------- 554 | Output: 555 | [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 556 | 2 557 | 4 558 | 6 559 | 8 560 | 10 561 | 562 | -------------------- 563 | \`\`\` 564 | Iterating backwards is done with 'reversed()' 565 | 566 | \`\`\` 567 | for z in range(1, 10, 2): # from 1 to 10 each step is 2 568 | 569 | print (z) 570 | 571 | 572 | 573 | print ('backwards:') 574 | 575 | for z in reversed(range(1, 10, 2)): # from 1 to 10 each step is 2 576 | 577 | print (z) 578 | 579 | -------------------- 580 | Output: 581 | 1 582 | 3 583 | 5 584 | 7 585 | 9 586 | backwards: 587 | 9 588 | 7 589 | 5 590 | 3 591 | 1 592 | 593 | -------------------- 594 | \`\`\` 595 | ## Casting Back and Forth 596 | > Take a simple list 597 | 598 | \`\`\` 599 | a = [1,2,3,(\\"b\\",1)] 600 | 601 | print (a) 602 | 603 | print (type(a)) 604 | 605 | -------------------- 606 | Output: 607 | [1, 2, 3, ('b', 1)] 608 | 609 | 610 | -------------------- 611 | \`\`\` 612 | > Convert it to a tuple (immutable list -- a list with values you can't change) 613 | 614 | \`\`\` 615 | b = tuple(a) 616 | 617 | print (b) 618 | 619 | print (type(b)) 620 | 621 | -------------------- 622 | Output: 623 | (1, 2, 3, ('b', 1)) 624 | 625 | 626 | -------------------- 627 | \`\`\` 628 | > Convert it back to a list 629 | 630 | \`\`\` 631 | c = list(b) 632 | 633 | print (c) 634 | 635 | print (type(c)) 636 | 637 | -------------------- 638 | Output: 639 | [1, 2, 3, ('b', 1)] 640 | 641 | 642 | -------------------- 643 | \`\`\` 644 | > Convert it to a set (an unordered collection of unique elements) 645 | 646 | \`\`\` 647 | d = set(a) 648 | 649 | print (d) 650 | 651 | print (type(d)) 652 | 653 | -------------------- 654 | Output: 655 | {('b', 1), 1, 2, 3} 656 | 657 | 658 | -------------------- 659 | \`\`\` 660 | > Make a list out of a set 661 | 662 | \`\`\` 663 | print (set('spam')) 664 | 665 | print (list(set(\\"spam\\"))) 666 | 667 | -------------------- 668 | Output: 669 | {'m', 's', 'a', 'p'} 670 | ['m', 's', 'a', 'p'] 671 | 672 | -------------------- 673 | \`\`\` 674 | > Make a list out of a string 675 | 676 | \`\`\` 677 | list(\\"abracadabra\\") 678 | \`\`\` 679 | > casting only affects top-level structure, not the elements 680 | ## The split function 681 | 682 | >VERY useful. Split strings based on a delimeter (e.g. split on \\" \\", or \\",\\", or \\"\\\\n\\") 683 | 684 | \`\`\` 685 | \\"Kirk Spock McCoy\\".split() 686 | \`\`\` 687 | > Then you can join it back together with a delimter 688 | 689 | \`\`\` 690 | ''.join([\\"1\\",\\"2\\",\\"3\\"]) 691 | \`\`\` 692 | 693 | \`\`\` 694 | text = \\"These,are,comma,seperated\\" 695 | 696 | print (text) 697 | 698 | textlist = text.split(\\",\\") 699 | 700 | print (textlist) 701 | 702 | print (\\" \\".join(textlist)) 703 | 704 | print (\\"\\\\t\\".join(textlist)) 705 | 706 | -------------------- 707 | Output: 708 | These,are,comma,seperated 709 | ['These', 'are', 'comma', 'seperated'] 710 | These are comma seperated 711 | These are comma seperated 712 | 713 | -------------------- 714 | \`\`\` 715 | ## List Comprehension ## 716 | 717 | 718 | 719 | You can create lists \\"on the fly\\" by asking simple questions of other iterateable data structures. Although the following examples are somewhat trivial, this can be extremely powerful and is a very useful tool. 720 | >example: I want a list of squared numbers 721 | 722 | \`\`\` 723 | # You can make the list of squared numbers using a loop: 724 | 725 | mylist = [] 726 | 727 | for num in range(10): 728 | 729 | mylist.append(num**2) 730 | 731 | print (mylist) 732 | 733 | -------------------- 734 | Output: 735 | [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 736 | 737 | -------------------- 738 | \`\`\` 739 | 740 | \`\`\` 741 | # or you can do it inline like so: 742 | 743 | mylist= [x**2 for x in range(10)] 744 | 745 | print (mylist) 746 | 747 | -------------------- 748 | Output: 749 | [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] 750 | 751 | -------------------- 752 | \`\`\` 753 | >example: I want a list of all mesons whose masses are between 100 and 1000 MeV 754 | 755 | \`\`\` 756 | particles = \\\\ 757 | 758 | [{\\"name\\":\\"π+\\" ,\\"mass\\": 139.57018}, {\\"name\\":\\"π0\\" ,\\"mass\\": 134.9766}, 759 | 760 | {\\"name\\":\\"η5\\" ,\\"mass\\": 47.853}, {\\"name\\":\\"η′(958)\\",\\"mass\\": 957.78}, 761 | 762 | {\\"name\\":\\"ηc(1S)\\", \\"mass\\": 2980.5}, {\\"name\\": \\"ηb(1S)\\",\\"mass\\": 9388.9}, 763 | 764 | {\\"name\\":\\"K+\\", \\"mass\\": 493.677}, {\\"name\\":\\"K0\\" ,\\"mass\\": 497.614}, 765 | 766 | {\\"name\\":\\"K0S\\" ,\\"mass\\": 497.614}, {\\"name\\":\\"K0L\\" ,\\"mass\\": 497.614}, 767 | 768 | {\\"name\\":\\"D+\\" ,\\"mass\\": 1869.62}, {\\"name\\":\\"D0\\" ,\\"mass\\": 1864.84}, 769 | 770 | {\\"name\\":\\"D+s\\" ,\\"mass\\": 1968.49}, {\\"name\\":\\"B+\\" ,\\"mass\\": 5279.15}, 771 | 772 | {\\"name\\":\\"B0\\" ,\\"mass\\": 5279.5}, {\\"name\\":\\"B0s\\" ,\\"mass\\": 5366.3}, 773 | 774 | {\\"name\\":\\"B+c\\" ,\\"mass\\": 6277}] 775 | 776 | 777 | 778 | # data source: http://en.wikipedia.org/wiki/List_of_mesons 779 | 780 | 781 | 782 | my_mesons = [ (x['name'],x['mass']) for x in particles if x['mass'] <= 1000.0 and x['mass'] >= 100.0] 783 | 784 | print (my_mesons) 785 | 786 | -------------------- 787 | Output: 788 | [('π+', 139.57018), ('π0', 134.9766), ('η′(958)', 957.78), ('K+', 493.677), ('K0', 497.614), ('K0S', 497.614), ('K0L', 497.614)] 789 | 790 | -------------------- 791 | \`\`\` 792 | 793 | \`\`\` 794 | # get the average 795 | 796 | tot = 0.0 797 | 798 | for x in my_mesons: 799 | 800 | tot += x[1] 801 | 802 | print (\\"The average meson mass in this range is \\" + str(tot/len(my_mesons)) + \\" MeV/c^2.\\") 803 | 804 | -------------------- 805 | Output: 806 | The average meson mass in this range is 459.83511142857145 MeV/c^2. 807 | 808 | -------------------- 809 | \`\`\` 810 | 811 | \`\`\` 812 | my_mesons[0][0] 813 | \`\`\` 814 |   815 | " 816 | `; 817 | 818 | exports[`Translators Markdown should convert the notepad correctly 1`] = ` 819 | "
# This is some md 820 | 821 | **yeet**yeetyeet
" 822 | `; 823 | 824 | exports[`Translators Xml toNotepadFromEnex should convert the notepad correctly 1`] = ` 825 | "data:image/gif;base64,R0lGODlhPQBEAPeoAJosM//AwO/AwHVYZ/z595kzAP/s7P+goOXMv8+fhw/v739/f+8PD98fH/8mJl+fn/9ZWb8/PzWlwv///6wWGbImAPgTEMImIN9gUFCEm/gDALULDN8PAD6atYdCTX9gUNKlj8wZAKUsAOzZz+UMAOsJAP/Z2ccMDA8PD/95eX5NWvsJCOVNQPtfX/8zM8+QePLl38MGBr8JCP+zs9myn/8GBqwpAP/GxgwJCPny78lzYLgjAJ8vAP9fX/+MjMUcAN8zM/9wcM8ZGcATEL+QePdZWf/29uc/P9cmJu9MTDImIN+/r7+/vz8/P8VNQGNugV8AAF9fX8swMNgTAFlDOICAgPNSUnNWSMQ5MBAQEJE3QPIGAM9AQMqGcG9vb6MhJsEdGM8vLx8fH98AANIWAMuQeL8fABkTEPPQ0OM5OSYdGFl5jo+Pj/+pqcsTE78wMFNGQLYmID4dGPvd3UBAQJmTkP+8vH9QUK+vr8ZWSHpzcJMmILdwcLOGcHRQUHxwcK9PT9DQ0O/v70w5MLypoG8wKOuwsP/g4P/Q0IcwKEswKMl8aJ9fX2xjdOtGRs/Pz+Dg4GImIP8gIH0sKEAwKKmTiKZ8aB/f39Wsl+LFt8dgUE9PT5x5aHBwcP+AgP+WltdgYMyZfyywz78AAAAAAAD///8AAP9mZv///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAKgALAAAAAA9AEQAAAj/AFEJHEiwoMGDCBMqXMiwocAbBww4nEhxoYkUpzJGrMixogkfGUNqlNixJEIDB0SqHGmyJSojM1bKZOmyop0gM3Oe2liTISKMOoPy7GnwY9CjIYcSRYm0aVKSLmE6nfq05QycVLPuhDrxBlCtYJUqNAq2bNWEBj6ZXRuyxZyDRtqwnXvkhACDV+euTeJm1Ki7A73qNWtFiF+/gA95Gly2CJLDhwEHMOUAAuOpLYDEgBxZ4GRTlC1fDnpkM+fOqD6DDj1aZpITp0dtGCDhr+fVuCu3zlg49ijaokTZTo27uG7Gjn2P+hI8+PDPERoUB318bWbfAJ5sUNFcuGRTYUqV/3ogfXp1rWlMc6awJjiAAd2fm4ogXjz56aypOoIde4OE5u/F9x199dlXnnGiHZWEYbGpsAEA3QXYnHwEFliKAgswgJ8LPeiUXGwedCAKABACCN+EA1pYIIYaFlcDhytd51sGAJbo3onOpajiihlO92KHGaUXGwWjUBChjSPiWJuOO/LYIm4v1tXfE6J4gCSJEZ7YgRYUNrkji9P55sF/ogxw5ZkSqIDaZBV6aSGYq/lGZplndkckZ98xoICbTcIJGQAZcNmdmUc210hs35nCyJ58fgmIKX5RQGOZowxaZwYA+JaoKQwswGijBV4C6SiTUmpphMspJx9unX4KaimjDv9aaXOEBteBqmuuxgEHoLX6Kqx+yXqqBANsgCtit4FWQAEkrNbpq7HSOmtwag5w57GrmlJBASEU18ADjUYb3ADTinIttsgSB1oJFfA63bduimuqKB1keqwUhoCSK374wbujvOSu4QG6UvxBRydcpKsav++Ca6G8A6Pr1x2kVMyHwsVxUALDq/krnrhPSOzXG1lUTIoffqGR7Goi2MAxbv6O2kEG56I7CSlRsEFKFVyovDJoIRTg7sugNRDGqCJzJgcKE0ywc0ELm6KBCCJo8DIPFeCWNGcyqNFE06ToAfV0HBRgxsvLThHn1oddQMrXj5DyAQgjEHSAJMWZwS3HPxT/QMbabI/iBCliMLEJKX2EEkomBAUCxRi42VDADxyTYDVogV+wSChqmKxEKCDAYFDFj4OmwbY7bDGdBhtrnTQYOigeChUmc1K3QTnAUfEgGFgAWt88hKA6aCRIXhxnQ1yg3BCayK44EWdkUQcBByEQChFXfCB776aQsG0BIlQgQgE8qO26X1h8cEUep8ngRBnOy74E9QgRgEAC8SvOfQkh7FDBDmS43PmGoIiKUUEGkMEC/PJHgxw0xH74yx/3XnaYRJgMB8obxQW6kL9QYEJ0FIFgByfIL7/IQAlvQwEpnAC7DtLNJCKUoO/w45c44GwCXiAFB/OXAATQryUxdN4LfFiwgjCNYg+kYMIEFkCKDs6PKAIJouyGWMS1FSKJOMRB/BoIxYJIUXFUxNwoIkEKPAgCBZSQHQ1A2EWDfDEUVLyADj5AChSIQW6gu10bE/JG2VnCZGfo4R4d0sdQoBAHhPjhIB94v/wRoRKQWGRHgrhGSQJxCS+0pCZbEhAAOw==data:image/gif;base64,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
Hello, World. 826 | 827 | 828 | 829 | Test _of_ mass **nesting** 830 | ========================== 831 | 832 | \`there was an attachment here\` 833 | 834 | 835 | 836 | - [ ] An item that I haven't completed yet. 837 | - [x] An completed item.ASAS
" 838 | `; 839 | -------------------------------------------------------------------------------- /src/tests/crypto.spec.ts: -------------------------------------------------------------------------------- 1 | import { decrypt, encrypt, EncryptionMethod } from '../crypto'; 2 | import Notepad, { NotepadOptions } from '../Notepad'; 3 | import { TestUtils } from './TestUtils'; 4 | import { Section } from '../index'; 5 | 6 | describe('Crypto', () => { 7 | ( [ 8 | 'AES-256', 9 | 'AES-256-GZ' 10 | ]).forEach(type => { 11 | let notepad: Notepad; 12 | const passkey = 'this is a bad password'; 13 | 14 | beforeEach(async () => { 15 | notepad = new Notepad('test', getOptions()); 16 | notepad = notepad.clone({ 17 | sections: [ 18 | notepad.sections[0].addNote(TestUtils.makeNote('test note')) 19 | ], 20 | crypto: type 21 | }); 22 | notepad.sections[0].parent = notepad; 23 | }); 24 | 25 | it(`should encrypt with ${type} and decrypt correctly`, async () => { 26 | // Arrange 27 | // Act 28 | const encrypted = await encrypt(notepad, passkey); 29 | const decrypted = await decrypt(encrypted, passkey); 30 | 31 | // Assert 32 | expect(decrypted).toEqual(notepad); 33 | }); 34 | 35 | // it(`should decrypt with ${type}`, async () => { 36 | // // Arrange 37 | // const cipherTexts = { 38 | // 'AES-256': 'a4bc7633fe1c3ab0af9b3735656ec452c359d65cc7f372812d49e375e1fd7583bd181b70701bffa44fd747b1b5097f4a41958c54330aa9ca0f186cc626afd8dd352a95f8f1e4945d51ca79447334ea132f482cf3da8ae1e10851bac53bb3842e99ad5039455b6536ba53be619a6cf5ba73681a39da6041682318eef298df5ec824a2be858a6c96d2109e7e7344a19f4e0e0ba37b49f71a25b0fe9b3e588565099d8e400f4d737a822368f3c652d9e67484fb68', 39 | // 'AES-256-GZ': '1e6af7a427d92a352b20d2d1e2bb08be34d90d8e4d3dbc6a968d08987608a0087cd3aa99b6d9713ecc178c77258beeb2a0295fddddd0694cf4a6ce60ae4f1c58e5bf4e050a217f91c517b187ec86e395f9bec72957743627add2372aee307bcb186b97bd9ed3b9be4ed16cf853ed033c998396cd59bcde9585c9294f6d709d58a549731575ab0c72804d93c6f6542ea0af8e7f931274d6f8eb3a3ce2e128c48c4765cfeaa0f3816db2903d05f02a59897765b2c45d25d3122cfeb5f3ddc6d6139858ef9bc7' 40 | // }; 41 | // 42 | // const encryptedNotepad = { ...notepad, sections: cipherTexts[type] }; 43 | // 44 | // // Act 45 | // const res = await decrypt(encryptedNotepad, passkey); 46 | // 47 | // // Assert 48 | // expect(res).toMatchSnapshot(); 49 | // }); 50 | }); 51 | }); 52 | 53 | function getOptions(): NotepadOptions { 54 | const testSection = new Section('test section'); 55 | ( testSection).internalRef = 'abc'; 56 | 57 | return { 58 | lastModified: new Date(1), 59 | sections: [testSection], 60 | 61 | assets: [], 62 | notepadAssets: ['test'] 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["esnext", "dom"], /* Specify library files to be included in the compilation. */ 7 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 8 | "outDir": "./dist", /* Redirect output structure to the directory. */ 9 | // "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 10 | "removeComments": false, /* Do not emit comments to output. */ 11 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 12 | "preserveConstEnums": true, 13 | 14 | /* Strict Type-Checking Options */ 15 | "strict": true, /* Enable all strict type-checking options. */ 16 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 17 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 18 | 19 | /* Module Resolution Options */ 20 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 21 | 22 | /* Source Map Options */ 23 | "sourceMap": true, /* Generates corresponding '.map' file. */ 24 | "isolatedModules": true 25 | }, 26 | "exclude": [ 27 | "**/*.spec.ts", 28 | "**/TestUtils.ts", 29 | "**/TestSetup.ts" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------