├── .editorconfig ├── .gitignore ├── .htaccess ├── Dockerfile ├── README.md ├── angular.json ├── animals-71.png ├── backend.php ├── data ├── files │ └── bookmarks.html └── pages │ ├── bookmarks.md │ ├── developmentnotes.md │ ├── developmentnotes___javascriptsnippets.md │ ├── developmentnotes___markdownsyntax.md │ └── quicknotes.md ├── docker-dev ├── Dockerfile ├── docker-compose.yml └── php.ini ├── package-lock.json ├── package.json ├── proxy.conf.json ├── screenshot_1.jpg ├── screenshot_2.jpg ├── screenshot_3.jpg ├── src ├── app │ ├── app.component.css │ ├── app.component.html │ ├── app.component.ts │ ├── app.router.ts │ ├── components │ │ ├── alert-error │ │ │ ├── alert-error.component.css │ │ │ ├── alert-error.component.html │ │ │ └── alert-error.component.ts │ │ ├── alert-sticky │ │ │ ├── alert-sticky.component.css │ │ │ ├── alert-sticky.component.html │ │ │ └── alert-sticky.component.ts │ │ ├── dtos │ │ │ ├── file-dto.ts │ │ │ ├── page-dto.ts │ │ │ └── to-rename-dto.ts │ │ ├── files │ │ │ ├── files.component.css │ │ │ ├── files.component.html │ │ │ └── files.component.ts │ │ ├── home │ │ │ ├── home.component.css │ │ │ ├── home.component.html │ │ │ └── home.component.ts │ │ ├── markdown-editor │ │ │ ├── markdown-editor.component.css │ │ │ ├── markdown-editor.component.html │ │ │ └── markdown-editor.component.ts │ │ ├── page-edit │ │ │ ├── page-edit.component.css │ │ │ ├── page-edit.component.html │ │ │ └── page-edit.component.ts │ │ ├── page-show │ │ │ ├── page-show.component.css │ │ │ ├── page-show.component.html │ │ │ └── page-show.component.ts │ │ ├── page-tree │ │ │ ├── page-tree.component.css │ │ │ ├── page-tree.component.html │ │ │ └── page-tree.component.ts │ │ ├── passwords │ │ │ ├── passwords.component.css │ │ │ ├── passwords.component.html │ │ │ └── passwords.component.ts │ │ ├── search │ │ │ ├── search.component.css │ │ │ ├── search.component.html │ │ │ └── search.component.ts │ │ ├── vocabulary-edit │ │ │ ├── vocabulary-edit.component.css │ │ │ ├── vocabulary-edit.component.html │ │ │ └── vocabulary-edit.component.ts │ │ ├── vocabulary-exercise-result │ │ │ ├── vocabulary-exercise-result.component.css │ │ │ ├── vocabulary-exercise-result.component.html │ │ │ └── vocabulary-exercise-result.component.ts │ │ ├── vocabulary-exercise │ │ │ ├── vocabulary-exercise.component.css │ │ │ ├── vocabulary-exercise.component.html │ │ │ └── vocabulary-exercise.component.ts │ │ ├── vocabulary-list │ │ │ ├── vocabulary-list.component.css │ │ │ ├── vocabulary-list.component.html │ │ │ └── vocabulary-list.component.ts │ │ └── vocabulary │ │ │ ├── vocabulary.component.css │ │ │ ├── vocabulary.component.html │ │ │ └── vocabulary.component.ts │ ├── models │ │ ├── page.ts │ │ ├── password-entry.ts │ │ ├── vocabulary-card.ts │ │ ├── vocabulary-entry.ts │ │ └── vocabulary-exercise-result.ts │ ├── pipes │ │ ├── bookmarks.pipe.ts │ │ ├── file-size.pipe.ts │ │ ├── highlight.pipe.ts │ │ ├── markdown.pipe.ts │ │ ├── nl2br.pipe.ts │ │ └── searchresult.pipe.ts │ └── services │ │ ├── aes.service.ts │ │ ├── backend.service.ts │ │ └── icon.service.ts ├── assets │ ├── bookmark.png │ └── logo.png ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── global.d.ts ├── index.html ├── main.ts ├── polyfills.ts ├── styles.css └── test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | markdownnotes*.zip 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | .angular 41 | backend_apikeys.php 42 | /data/audio -------------------------------------------------------------------------------- /.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | # RewriteBase /notes/ 3 | RewriteRule ^api/(.*)$ backend.php [L] 4 | RewriteCond %{REQUEST_FILENAME} !-d 5 | RewriteCond %{REQUEST_FILENAME} !-f 6 | RewriteRule ^ index.html [QSA,L] -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.3.22RC1-apache 2 | LABEL maintainer="Tobias Zeising " 3 | RUN apt-get update 4 | RUN a2enmod rewrite 5 | ADD dist /var/www/html 6 | RUN rm -rf /var/www/html/data -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MarkdownNotes 2 | 3 | MarkdownNotes is a self hosted tool for organizing notes, files, lists, ideas by using markdown syntax. 4 | 5 | ![MarkdownNote Screenshot](https://github.com/SSilence/markdownnotes/raw/master/screenshot_1.jpg "MarkdownNotes Screenshot") 6 | 7 | ## requirements 8 | 9 | * PHP webspace (just 4 a small backend script, runs everywhere with PHP5+) or Docker 10 | * all modern browsers 11 | * mobile browsers 12 | 13 | ## installation 14 | 15 | 1. Download latest stable release ZIP file from https://github.com/SSilence/markdownnotes/releases and unzip 16 | 2. Upload all files of this folder (IMPORTANT: also upload the invisible .htaccess files) 17 | 3. Make the directories data/files and data/pages writeable 18 | 4. If you are using MarkdownNotes on a subpath (e.g. http://example.com/markdownnotes) then add in .htaccess the line ``RewriteBase /markdownnotes/`` and add in index.html ```` 19 | 5. MarkdownNotes saves all data in files in the data dir. No database is necessary. 20 | 21 | ## installation using docker 22 | 23 | Start the [MarkdownNotes docker image](https://hub.docker.com/r/ssilence/markdownnotes) with: 24 | ``` 25 | docker run -v /yourpath:/var/www/html/data -p 80:80 ssilence/markdownnotes:latest 26 | ``` 27 | MarkdownNotes notes and files will be saved in your given path. Just replace ``/yourpath`` with target data dir on your system. 28 | 29 | ## screenshots 30 | 31 | ![MarkdownNotes Screenshot](https://github.com/SSilence/markdownnotes/raw/master/screenshot_2.jpg "MarkdownNotes Screenshot") 32 | 33 | ![MarkdownNotes Screenshot](https://github.com/SSilence/markdownnotes/raw/master/screenshot_3.jpg "MarkdownNotes Screenshot") 34 | 35 | ## credits 36 | 37 | Copyright (c) 2025 Tobias Zeising, tobias.zeising@aditu.de 38 | https://www.aditu.de 39 | Licensed under the GPLv3 license 40 | 41 | Special thanks to the great programmers of this libraries which will be used: 42 | 43 | * Angular: https://angular.io/ 44 | * Clarity: https://vmware.github.io/clarity/ 45 | * ShowdownJS: https://github.com/showdownjs/showdown 46 | * SimpleMDE: https://simplemde.com/ 47 | * highlightjs: https://highlightjs.org/ 48 | * ngx-clipboard: https://github.com/maxisam/ngx-clipboard 49 | * ngx-file-drop: https://github.com/georgipeltekov/ngx-file-drop 50 | 51 | Icon Source (design by Freepik.com): https://creativenerds.co.uk/freebies/80-free-wildlife-icons-the-best-ever-animal-icon-set/ 52 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "app": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "app", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:application", 15 | "options": { 16 | "outputPath": { 17 | "base": "dist" 18 | }, 19 | "index": "src/index.html", 20 | "polyfills": [ 21 | "src/polyfills.ts" 22 | ], 23 | "tsConfig": "tsconfig.app.json", 24 | "assets": [ 25 | "src/favicon.ico", 26 | "src/assets", 27 | { "glob": "backend.php", "input": "./", "output": "./" }, 28 | { "glob": "backend_apikeys.php", "input": "./", "output": "./" }, 29 | { "glob": ".htaccess", "input": "./", "output": "./" }, 30 | { "glob": "**/*", "input": "./data", "output": "./data" } 31 | ], 32 | "styles": [ 33 | "src/styles.css", 34 | "node_modules/@cds/core/global.min.css", 35 | "node_modules/@cds/core/styles/theme.dark.min.css", 36 | "node_modules/@clr/ui/clr-ui.min.css", 37 | "node_modules/easymde/dist/easymde.min.css", 38 | "node_modules/highlight.js/styles/atom-one-light.css", 39 | "node_modules/@fortawesome/fontawesome-free/css/all.min.css" 40 | ], 41 | "scripts": [ 42 | "node_modules/jquery/dist/jquery.min.js", 43 | "node_modules/@webcomponents/custom-elements/custom-elements.min.js" 44 | ], 45 | "browser": "src/main.ts" 46 | }, 47 | "configurations": { 48 | "production": { 49 | "budgets": [ 50 | { 51 | "type": "initial", 52 | "maximumWarning": "2mb", 53 | "maximumError": "5mb" 54 | }, 55 | { 56 | "type": "anyComponentStyle", 57 | "maximumWarning": "2kb", 58 | "maximumError": "4kb" 59 | } 60 | ], 61 | "fileReplacements": [ 62 | { 63 | "replace": "src/environments/environment.ts", 64 | "with": "src/environments/environment.prod.ts" 65 | } 66 | ], 67 | "outputHashing": "all" 68 | }, 69 | "development": { 70 | "optimization": false, 71 | "extractLicenses": false, 72 | "sourceMap": true, 73 | "namedChunks": true 74 | } 75 | }, 76 | "defaultConfiguration": "production" 77 | }, 78 | "serve": { 79 | "builder": "@angular-devkit/build-angular:dev-server", 80 | "configurations": { 81 | "production": { 82 | "buildTarget": "app:build:production" 83 | }, 84 | "development": { 85 | "buildTarget": "app:build:development", 86 | "proxyConfig": "proxy.conf.json" 87 | } 88 | }, 89 | "defaultConfiguration": "development" 90 | }, 91 | "extract-i18n": { 92 | "builder": "@angular-devkit/build-angular:extract-i18n", 93 | "options": { 94 | "buildTarget": "app:build" 95 | } 96 | }, 97 | "test": { 98 | "builder": "@angular-devkit/build-angular:karma", 99 | "options": { 100 | "main": "src/test.ts", 101 | "polyfills": "src/polyfills.ts", 102 | "tsConfig": "tsconfig.spec.json", 103 | "karmaConfig": "karma.conf.js", 104 | "assets": [ 105 | "src/favicon.ico", 106 | "src/assets" 107 | ], 108 | "styles": [ 109 | "src/styles.css" 110 | ], 111 | "scripts": [] 112 | } 113 | } 114 | } 115 | } 116 | }, 117 | "cli": { 118 | "analytics": false 119 | } 120 | } -------------------------------------------------------------------------------- /animals-71.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/animals-71.png -------------------------------------------------------------------------------- /backend.php: -------------------------------------------------------------------------------- 1 | 0 && $scriptName !== '/') { 43 | $path = substr($path, $len); 44 | } 45 | } 46 | if (!in_array($_SERVER['REQUEST_METHOD'], (array) $httpMethods)) { 47 | return; 48 | } 49 | $matches = null; 50 | $regex = '/' . str_replace('/', '\/', $route) . '/'; 51 | if (!preg_match_all($regex, $path, $matches)) { 52 | return; 53 | } 54 | if (empty($matches)) { 55 | $callback(); 56 | } else { 57 | $params = array(); 58 | foreach ($matches as $k => $v) { 59 | if (!is_numeric($k) && !isset($v[1])) { 60 | $params[$k] = $v[0]; 61 | } 62 | } 63 | $callback($params); 64 | } 65 | if ($exit) { 66 | exit; 67 | } 68 | } 69 | 70 | function parseString($data, $key) { 71 | if (isset($data) && isset($data[$key])) { 72 | return trim(htmlspecialchars($data[$key])); 73 | } else { 74 | return ''; 75 | } 76 | } 77 | 78 | function gzip($json) { 79 | if(strpos($_SERVER['HTTP_ACCEPT_ENCODING'],'gzip')!==FALSE) { 80 | header('Content-Type: application/json'); 81 | header('Content-Encoding: gzip'); 82 | die(gzencode($json)); 83 | } else { 84 | header('Content-Type: application/json'); 85 | die($json); 86 | } 87 | } 88 | 89 | function json($data) { 90 | header('Content-Type: application/json'); 91 | gzip(json_encode($data)); 92 | } 93 | 94 | function success() { 95 | die(0); 96 | } 97 | 98 | function error($status, $msg) { 99 | header("HTTP/1.0 $status Not Found"); 100 | die($msg); 101 | } 102 | 103 | function body() { 104 | return json_decode(file_get_contents('php://input'), true); 105 | } 106 | 107 | function sanitizeFilename($id) { 108 | return preg_replace("/[^A-Za-z0-9_\-\.]+/", "", strtolower(str_replace(" ", "_", basename($id)))); 109 | } 110 | 111 | function toFilename($id) { 112 | return CONFIG_DATA_PATH . sanitizeFilename($id) . ".md"; 113 | } 114 | 115 | function readPageByFilename($filename) { 116 | $content = @file_get_contents($filename); 117 | if ($content === false) { 118 | return false; 119 | } 120 | $parts = preg_split("/".CONFIG_META_SEPARATOR."/", $content); 121 | $meta = array(); 122 | foreach (preg_split("/\n/", $parts[0]) as $element) { 123 | $eParts = preg_split("/\: /", $element); 124 | @$meta[$eParts[0]] = $eParts[1]; 125 | } 126 | 127 | return array( 128 | 'id' => basename($filename, ".md"), 129 | 'title' => $meta['title'], 130 | 'icon' => $meta['icon'], 131 | 'language' => isset($meta['language']) ? $meta['language'] : '', 132 | 'disabled' => isset($meta['disabled']) ? $meta['disabled'] == 1 : false, 133 | 'expanded' => isset($meta['expanded']) ? $meta['expanded'] == 1 : false, 134 | 'content' => trim($parts[1]), 135 | 'updated' => filemtime($filename) 136 | ); 137 | } 138 | 139 | function readPageById($id) { 140 | return readPageByFilename(toFilename($id)); 141 | } 142 | 143 | function writePage($id, $title, $icon, $language, $disabled, $expanded, $content) { 144 | file_put_contents(toFilename($id), "title: $title\nicon: $icon\nlanguage: $language\ndisabled: $disabled\nexpanded: $expanded\n" . CONFIG_META_SEPARATOR . "\n$content"); 145 | } 146 | 147 | function text2SpeechAuth() { 148 | $context = stream_context_create([ 149 | 'http' => [ 150 | 'header' => "Ocp-Apim-Subscription-Key: " . CONFIG_AZURE_TEXT_TO_SPEECH_API_KEY . "\r\nContent-Length: 0", 151 | 'method' => 'POST' 152 | ], 153 | ]); 154 | $result = file_get_contents('https://eastus.api.cognitive.microsoft.com/sts/v1.0/issueToken', false, $context); 155 | if ($result === false) { 156 | error(500, "autherror"); 157 | } 158 | return $result; 159 | } 160 | 161 | function text2Speech($text, $lang) { 162 | $voice = "en-US-ChristopherNeural"; 163 | if ($lang == "fr-FR") { 164 | $voice = "fr-FR-HenriNeural"; 165 | } else if ($lang == "de-DE") { 166 | $voice = "de-DE-ChristophNeural"; 167 | } else if ($lang == "hr-HR") { 168 | $voice = "hr-HR-GabrijelaNeural"; 169 | } 170 | 171 | $data = "$text"; 172 | $context = stream_context_create([ 173 | 'http' => [ 174 | 'header' => "Content-type: application/ssml+xml\r\nUser-Agent: MarkdownNotes\r\nAuthorization: Bearer " . text2SpeechAuth() . "\r\nX-Microsoft-OutputFormat: audio-16khz-64kbitrate-mono-mp3\r\nContent-Length: " . strlen($data) . "\r\n", 175 | 'method' => 'POST', 176 | 'content' => $data, 177 | ], 178 | ]); 179 | $result = file_get_contents('https://eastus.tts.speech.microsoft.com/cognitiveservices/v1', false, $context); 180 | if ($result === false) { 181 | error(500, "text2speech error"); 182 | } 183 | return $result; 184 | } 185 | 186 | function chatgptStory($words) { 187 | $data = json_encode(array( 188 | "model" => "gpt-3.5-turbo", 189 | "messages" => array( 190 | array( 191 | "role" => "user", 192 | "content" => "Please write an easy, short, and entertaining story with the following words: " . $words 193 | ) 194 | ) 195 | )); 196 | 197 | $context = stream_context_create([ 198 | 'http' => [ 199 | 'header' => "Authorization: Bearer " . CONFIG_CHATGPT_API_KEY . "\r\nContent-Type: application/json", 200 | 'method' => 'POST', 201 | 'content' => $data 202 | ], 203 | ]); 204 | $result = file_get_contents('https://api.openai.com/v1/chat/completions', false, $context); 205 | if ($result === false) { 206 | error(500, "error"); 207 | } 208 | $responseArray = json_decode($result, true); 209 | if ($responseArray !== null && isset($responseArray['choices'][0]['message']['content'])) { 210 | $content = $responseArray['choices'][0]['message']['content']; 211 | return $content; 212 | } else { 213 | throw 'can not parse json response'; 214 | } 215 | } 216 | 217 | // time 218 | router('GET', '/$', function() { 219 | die("".time()); 220 | }); 221 | 222 | // add/edit page 223 | router('POST', '/page$', function() { 224 | $data = body(); 225 | $id = parseString($data, 'id'); 226 | $title = parseString($data, 'title'); 227 | $icon = parseString($data, 'icon'); 228 | $language = parseString($data, 'language'); 229 | $disabled = $data['disabled'] === true; 230 | $expanded = $data['expanded'] === true; 231 | $content = isset($data['content']) ? $data['content'] : readPageById($id)["content"]; 232 | 233 | if (strlen($id) == 0) { 234 | error(404, "invalid id"); 235 | } 236 | 237 | writePage($id, $title, $icon, $language, $disabled, $expanded, $content); 238 | json(readPageById($id)); 239 | }); 240 | 241 | // delete page 242 | router('DELETE', '/page/(?.+)$', function($params) { 243 | @unlink(toFilename($params['id'])); 244 | }); 245 | 246 | // get all pages 247 | router('GET', '/page$', function() { 248 | $pages = array(); 249 | foreach (scandir(CONFIG_DATA_PATH) as $file) { 250 | if (endsWith($file, ".md")) { 251 | $page = readPageByFilename(CONFIG_DATA_PATH . $file); 252 | unset($page['content']); 253 | $pages[] = $page; 254 | } 255 | } 256 | json($pages); 257 | }); 258 | 259 | // get one page 260 | router('GET', '/page/(?.+)$', function($params) { 261 | $page = readPageById($params['id']); 262 | if ($page === false) { 263 | error(404, "invalid id"); 264 | } 265 | json($page); 266 | }); 267 | 268 | // rename page 269 | router('POST', '/page/rename$', function() { 270 | 271 | $data = body(); 272 | if (!is_array($data)) { 273 | error(400, "no valid array given"); 274 | } 275 | 276 | // validate 277 | $parsed = array(); 278 | foreach ($data as $entry) { 279 | $oldFile = toFilename(parseString($entry, 'oldid')); 280 | $newFile = toFilename(parseString($entry, 'newid')); 281 | 282 | if (!file_exists($oldFile)) { 283 | error(404, "invalid id"); 284 | } 285 | if (file_exists($newFile)) { 286 | error(400, "new id already exists"); 287 | } 288 | $parsed[] = array( 289 | "from" => $oldFile, 290 | "to" => $newFile 291 | ); 292 | } 293 | 294 | foreach ($parsed as $entry) { 295 | rename($entry["from"], $entry["to"]); 296 | } 297 | 298 | success(); 299 | }); 300 | 301 | // file upload 302 | router('POST', '/file$', function() { 303 | move_uploaded_file($_FILES['file']['tmp_name'], CONFIG_FILES_PATH . sanitizeFilename($_FILES['file']['name'])); 304 | }); 305 | 306 | // file delete 307 | router('DELETE', '/file/(?.+)$', function($params) { 308 | @unlink(CONFIG_FILES_PATH . sanitizeFilename($params["filename"])); 309 | }); 310 | 311 | // get all files 312 | router('GET', '/file$', function() { 313 | $files = array(); 314 | foreach (scandir(CONFIG_FILES_PATH) as $file) { 315 | if (is_file(CONFIG_FILES_PATH . $file) && $file != '.' && $file != '..') { 316 | $files[] = array( 317 | "name" => $file, 318 | "size" => filesize(CONFIG_FILES_PATH . $file), 319 | "date" => filemtime(CONFIG_FILES_PATH . $file) * 1000 320 | ); 321 | } 322 | } 323 | json($files); 324 | }); 325 | 326 | // search 327 | router('GET', '/search$', function() { 328 | $q = htmlspecialchars($_GET["q"]); 329 | 330 | $pages = array(); 331 | foreach (scandir(CONFIG_DATA_PATH) as $file) { 332 | if (endsWith($file, ".md")) { 333 | $page = readPageByFilename(CONFIG_DATA_PATH . $file); 334 | if (stripos($page['content'], $q) !== false || stripos($page['title'], $q) !== false) { 335 | $pages[] = $page; 336 | } 337 | } 338 | } 339 | 340 | json($pages); 341 | }); 342 | 343 | // text2speech 344 | router('GET', '/text2speech$', function() { 345 | $text = htmlspecialchars($_GET["text"]); 346 | $lang = strtolower(isset($_GET["language"]) ? htmlspecialchars($_GET["language"]) : "en"); 347 | if ($lang == "en") { 348 | $lang = "en-US"; 349 | } else if ($lang == "de") { 350 | $lang = "de-DE"; 351 | } else if ($lang == "fr") { 352 | $lang = "fr-FR"; 353 | } else if ($lang == "hr") { 354 | $lang = "hr-HR"; 355 | } else { 356 | $lang = "en-US"; 357 | } 358 | 359 | $path = CONFIG_AUDIO_PATH . $lang . "/"; 360 | if (!file_exists($path)) { 361 | mkdir($path, 0777); 362 | } 363 | 364 | $filename = $path . sanitizeFilename($text) . ".mp3"; 365 | if (file_exists($filename)) { 366 | header('Content-Type: audio/mpeg'); 367 | die(file_get_contents($filename)); 368 | } 369 | $mp3 = text2Speech($text, $lang); 370 | file_put_contents($filename, $mp3); 371 | header('Content-Type: audio/mpeg'); 372 | echo $mp3; 373 | }); 374 | 375 | // chatgptStory 376 | router('POST', '/story$', function() { 377 | $words = file_get_contents('php://input'); 378 | die(chatgptStory($words)); 379 | }); 380 | 381 | header("HTTP/1.0 404 Not Found"); 382 | echo '404 Not Found'; -------------------------------------------------------------------------------- /data/files/bookmarks.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | Bookmarks 7 |

Bookmarks

8 |

9 |

Lesezeichenleiste

10 |

11 |

lesestunden ~ Bücher Blog über die schönsten Bücher aus der Welt der Literatur 12 |
Homepage - Tobias Zeising 13 |

github

14 |

15 |

SSilence (Tobias Zeising) · GitHub 16 |
SSilence/markdownnotes · GitHub 17 |
GitHub - SSilence/selfoss: selfoss: The multipurpose rss reader, live stream, mashup, aggregation web application 18 |

19 |

BuchschrankFinder – Apps bei Google Play 20 |

21 |

22 | -------------------------------------------------------------------------------- /data/pages/bookmarks.md: -------------------------------------------------------------------------------- 1 | title: bookmarks 2 | icon: world 3 | language: 4 | expanded: 5 | ------------------------------------ 6 | [bookmarks=data/files/bookmarks.html] -------------------------------------------------------------------------------- /data/pages/developmentnotes.md: -------------------------------------------------------------------------------- 1 | title: development notes 2 | icon: note 3 | language: 4 | disabled: 5 | expanded: 1 6 | ------------------------------------ 7 | -------------------------------------------------------------------------------- /data/pages/developmentnotes___javascriptsnippets.md: -------------------------------------------------------------------------------- 1 | title: JavaScript Snippets 2 | icon: scroll 3 | language: 4 | expanded: 1 5 | ------------------------------------ 6 | [toc] 7 | 8 | # random string 9 | 10 | ``` 11 | var id = Math.random().toString(36).substring(7); 12 | ``` 13 | 14 | # autoreplace links in text 15 | 16 | ``` 17 | var textWithUrls = text.replace(/(https?:\/\/[^\s]+)/g, function(url) { 18 | return '' + url + ''; 19 | }); 20 | ``` 21 | 22 | # jQueryfy Bookmarklet 23 | 24 | ``` 25 | javascript:var s=document.createElement('script'); 26 | s.setAttribute('src','http://code.jquery.com/jquery.js'); 27 | document.getElementsByTagName('body')[0].appendChild(s); 28 | ``` -------------------------------------------------------------------------------- /data/pages/developmentnotes___markdownsyntax.md: -------------------------------------------------------------------------------- 1 | title: markdown syntax 2 | icon: file 3 | language: 4 | expanded: 5 | ------------------------------------ 6 | --- 7 | __Advertisement :)__ 8 | 9 | - __[lesestunden.de](https://www.lesestunden.de)__ - blog about books 10 | - __[selfoss](https://selfoss.aditu.de)__ - RSS Reader 11 | 12 | You will like those projects! 13 | 14 | --- 15 | 16 | # h1 Heading 8-) 17 | ## h2 Heading 18 | ### h3 Heading 19 | #### h4 Heading 20 | ##### h5 Heading 21 | ###### h6 Heading 22 | 23 | 24 | ## Horizontal Rules 25 | 26 | ___ 27 | 28 | --- 29 | 30 | *** 31 | 32 | 33 | ## Emphasis 34 | 35 | **This is bold text** 36 | 37 | __This is bold text__ 38 | 39 | *This is italic text* 40 | 41 | _This is italic text_ 42 | 43 | ~~Strikethrough~~ 44 | 45 | 46 | ## Blockquotes 47 | 48 | 49 | > Blockquotes can also be nested... 50 | >> ...by using additional greater-than signs right next to each other... 51 | > > > ...or with spaces between arrows. 52 | 53 | 54 | ## Lists 55 | 56 | Unordered 57 | 58 | + Create a list by starting a line with `+`, `-`, or `*` 59 | + Sub-lists are made by indenting 2 spaces: 60 | - Marker character change forces new list start: 61 | * Ac tristique libero volutpat at 62 | + Facilisis in pretium nisl aliquet 63 | - Nulla volutpat aliquam velit 64 | + Very easy! 65 | 66 | Ordered 67 | 68 | 1. Lorem ipsum dolor sit amet 69 | 2. Consectetur adipiscing elit 70 | 3. Integer molestie lorem at massa 71 | 72 | 73 | 1. You can use sequential numbers... 74 | 1. ...or keep all the numbers as `1.` 75 | 76 | Start numbering with offset: 77 | 78 | 57. foo 79 | 1. bar 80 | 81 | 82 | ## Code 83 | 84 | Inline `code` 85 | 86 | Indented code 87 | 88 | // Some comments 89 | line 1 of code 90 | line 2 of code 91 | line 3 of code 92 | 93 | 94 | Block code "fences" 95 | 96 | ``` 97 | Sample text here... 98 | ``` 99 | 100 | Syntax highlighting 101 | 102 | ``` js 103 | var foo = function (bar) { 104 | return bar++; 105 | }; 106 | 107 | console.log(foo(5)); 108 | ``` 109 | 110 | ## Tables 111 | 112 | | Option | Description | 113 | | ------ | ----------- | 114 | | data | path to data files to supply the data that will be passed into templates. | 115 | | engine | engine to be used for processing templates. Handlebars is the default. | 116 | | ext | extension to be used for dest files. | 117 | 118 | Right aligned columns 119 | 120 | | Option | Description | 121 | | ------:| -----------:| 122 | | data | path to data files to supply the data that will be passed into templates. | 123 | | engine | engine to be used for processing templates. Handlebars is the default. | 124 | | ext | extension to be used for dest files. | 125 | 126 | 127 | ## Links 128 | 129 | [link text](http:/www.aditu.de) 130 | 131 | [link with title](http://www.aditu.de "title text!") 132 | 133 | Autoconverted link https://github.com/ssilence/ 134 | 135 | 136 | ## Images 137 | 138 | ![Minion](https://octodex.github.com/images/minion.png) 139 | ![Stormtroopocat](https://octodex.github.com/images/stormtroopocat.jpg "The Stormtroopocat") 140 | 141 | Like links, Images also have a footnote style syntax 142 | 143 | ![Alt text][id] 144 | 145 | With a reference later in the document defining the URL location: 146 | 147 | [id]: https://octodex.github.com/images/dojocat.jpg "The Dojocat" -------------------------------------------------------------------------------- /data/pages/quicknotes.md: -------------------------------------------------------------------------------- 1 | title: quick notes 2 | icon: details 3 | language: 4 | disabled: 5 | expanded: 1 6 | ------------------------------------ 7 | * buy milk 8 | * write a simple note tool 9 | * update wordpress 10 | * make a nice example screenshot for markdownnotes 11 | 12 | Install MarkdownNotes 13 | 1. Download latest stable release ZIP file from https://github.com/SSilence/markdownnotes/releases and unzip 14 | 2. Upload all files of this folder (IMPORTANT: also upload the invisible .htaccess files) 15 | 3. Make the directories data/files and data/pages writeable 16 | 4. If you are using MarkdownNotes on a subpath (e.g. http://example.com/markdownnotes) then add in .htaccess the line ``RewriteBase /markdownnotes/`` and add in index.html ```` 17 | 5. MarkdownNotes saves all data in files in the data dir. No database is necessary. -------------------------------------------------------------------------------- /docker-dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.3.22RC1-apache 2 | COPY php.ini /usr/local/etc/php/ 3 | RUN apt-get update 4 | RUN a2enmod rewrite -------------------------------------------------------------------------------- /docker-dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | php: 5 | container_name: php-markdown 6 | build: ./ 7 | ports: 8 | - 8888:80 9 | volumes: 10 | - ./../:/var/www/html -------------------------------------------------------------------------------- /docker-dev/php.ini: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/docker-dev/php.ini -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdownnotes", 3 | "version": "1.7.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "^19.2.14", 15 | "@angular/common": "^19.2.14", 16 | "@angular/compiler": "^19.2.14", 17 | "@angular/core": "^19.2.14", 18 | "@angular/forms": "^19.2.14", 19 | "@angular/platform-browser": "^19.2.14", 20 | "@angular/platform-browser-dynamic": "^19.2.14", 21 | "@angular/router": "^19.2.14", 22 | "@cds/angular": "^6.15.1", 23 | "@cds/core": "^6.15.1", 24 | "@clr/angular": "^17.10.0", 25 | "@clr/ui": "^17.10.0", 26 | "@fortawesome/fontawesome-free": "^6.2.1", 27 | "@webcomponents/custom-elements": "^1.5.1", 28 | "aes-js": "^3.1.2", 29 | "crypto-js": "^4.2.0", 30 | "easymde": "^2.20.0", 31 | "highlight.js": "^11.11.1", 32 | "jquery": "^3.7.1", 33 | "jssha": "^3.3.1", 34 | "luxon": "^3.6.1", 35 | "ng-circle-progress": "^1.7.1", 36 | "ngx-clipboard": "^16.0.0", 37 | "ngx-file-drop": "^16.0.0", 38 | "rxjs": "~7.8.1", 39 | "showdown": "^2.1.0", 40 | "tslib": "^2.3.0", 41 | "zone.js": "~0.15.1" 42 | }, 43 | "devDependencies": { 44 | "@angular-devkit/build-angular": "^19.2.14", 45 | "@angular/cli": "~19.2.14", 46 | "@angular/compiler-cli": "^19.2.14", 47 | "typescript": "~5.8.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://127.0.0.1:8888", 4 | "secure": false 5 | }, 6 | "/files": { 7 | "target": "http://127.0.0.1:8888", 8 | "secure": false 9 | } 10 | } -------------------------------------------------------------------------------- /screenshot_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/screenshot_1.jpg -------------------------------------------------------------------------------- /screenshot_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/screenshot_2.jpg -------------------------------------------------------------------------------- /screenshot_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/screenshot_3.jpg -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | width:2em; 3 | height:2em; 4 | margin-right:1em; 5 | } 6 | 7 | .sidenav { 8 | padding-top:1em; 9 | overflow:auto; 10 | min-width: 12rem; 11 | width: 18%; 12 | } 13 | 14 | .nav-link { 15 | cursor: pointer; 16 | } 17 | 18 | #search_input { 19 | padding-left:0.5em; 20 | } -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 |

2 |
3 | 9 |
10 | 11 | 12 | 13 | 14 |
15 | 20 |
21 | 22 |
23 | @if (showNavigation) { 24 | 33 | } 34 |
35 | 36 |
37 |
38 |
-------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router, ActivatedRoute, NavigationEnd, RouterModule } from '@angular/router'; 3 | import { filter, first } from 'rxjs/operators'; 4 | import { ClarityModule } from '@clr/angular'; 5 | 6 | import { PageTreeComponent } from './components/page-tree/page-tree.component'; 7 | import { Page } from './models/page'; 8 | import { BackendService } from './services/backend.service'; 9 | 10 | @Component({ 11 | selector: 'app-root', 12 | imports: [ClarityModule, RouterModule, PageTreeComponent], 13 | templateUrl: './app.component.html', 14 | styleUrls: ['./app.component.css'] 15 | }) 16 | export class AppComponent implements OnInit { 17 | pages: Page[] = []; 18 | active: string | null = null; 19 | loading = true; 20 | q = ''; 21 | showNavigation: boolean = true; 22 | 23 | constructor(private backendService: BackendService, 24 | private route: ActivatedRoute, 25 | private router: Router) {} 26 | 27 | ngOnInit() { 28 | this.router.events.pipe( 29 | filter(event => event instanceof NavigationEnd) 30 | ).subscribe(() => { 31 | let r = this.route; 32 | while (r.firstChild) { 33 | r = r.firstChild; 34 | } 35 | r.params.pipe( 36 | first(), 37 | filter(params => params['id']) 38 | ).subscribe(params => { 39 | this.active = params['id'] ? params['id'] : null 40 | }); 41 | 42 | this.showNavigation = this.router.url == "/" || this.router.url.startsWith("/page") || this.router.url.startsWith("/search"); 43 | }); 44 | this.backendService.pagesChanged.subscribe(pages => { 45 | this.pages = this.backendService.filterSystemPages(pages).map(p => ({...p})); 46 | }); 47 | this.backendService.getAllPages().subscribe(() => this.loading = false); 48 | } 49 | 50 | add() { 51 | this.router.navigate(['/page', 'add']); 52 | } 53 | 54 | search(event: any) { 55 | if (event.value.length>=3) { 56 | this.router.navigate(['/search'], { 57 | queryParams: {q: event.value} 58 | }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/app.router.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | import { FilesComponent } from './components/files/files.component'; 3 | import { HomeComponent } from './components/home/home.component'; 4 | import { PageEditComponent } from './components/page-edit/page-edit.component'; 5 | import { PageShowComponent } from './components/page-show/page-show.component'; 6 | import { PasswordsComponent } from './components/passwords/passwords.component'; 7 | import { SearchComponent } from './components/search/search.component'; 8 | import { VocabularyComponent } from './components/vocabulary/vocabulary.component'; 9 | import { VocabularyListComponent } from './components/vocabulary-list/vocabulary-list.component'; 10 | 11 | export const routes: Routes = [ 12 | { 13 | path: '', 14 | component: HomeComponent 15 | }, 16 | { 17 | path: 'page/add', 18 | component: PageEditComponent 19 | }, 20 | { 21 | path: 'page/edit/:id', 22 | component: PageEditComponent 23 | }, 24 | { 25 | path: 'page/:id', 26 | component: PageShowComponent 27 | }, 28 | { 29 | path: 'filelist', 30 | component: FilesComponent 31 | }, 32 | { 33 | path: 'passwords', 34 | component: PasswordsComponent 35 | }, 36 | { 37 | path: 'search', 38 | component: SearchComponent 39 | }, 40 | { 41 | path: 'vocabulary', 42 | component: VocabularyComponent 43 | }, 44 | { 45 | path: 'vocabulary/vocabular/:id', 46 | component: VocabularyListComponent 47 | } 48 | ]; 49 | -------------------------------------------------------------------------------- /src/app/components/alert-error/alert-error.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/src/app/components/alert-error/alert-error.component.css -------------------------------------------------------------------------------- /src/app/components/alert-error/alert-error.component.html: -------------------------------------------------------------------------------- 1 | @if (error) { 2 | 12 | } -------------------------------------------------------------------------------- /src/app/components/alert-error/alert-error.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Component, Input } from '@angular/core'; 3 | import { CdsModule } from '@cds/angular'; 4 | 5 | @Component({ 6 | selector: 'app-alert-error', 7 | imports: [CdsModule], 8 | templateUrl: './alert-error.component.html', 9 | styleUrls: ['./alert-error.component.css'] 10 | }) 11 | export class AlertErrorComponent { 12 | 13 | @Input() error: any = null; 14 | 15 | getErrorMessage(): string { 16 | if (this.error && this.error.error) { 17 | if (typeof this.error.error === "string") { 18 | return this.error.error; 19 | } else { 20 | JSON.stringify(this.error.error); 21 | } 22 | } 23 | 24 | if (typeof this.error === "string") { 25 | return this.error; 26 | } 27 | 28 | return JSON.stringify(this.error); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/app/components/alert-sticky/alert-sticky.component.css: -------------------------------------------------------------------------------- 1 | .sticky { 2 | position:fixed; 3 | right:0.5rem; 4 | top:0.1rem; 5 | width:15rem; 6 | } -------------------------------------------------------------------------------- /src/app/components/alert-sticky/alert-sticky.component.html: -------------------------------------------------------------------------------- 1 | @if (message) { 2 | 12 | } -------------------------------------------------------------------------------- /src/app/components/alert-sticky/alert-sticky.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Component, Input } from '@angular/core'; 3 | import { CdsModule } from '@cds/angular'; 4 | 5 | @Component({ 6 | selector: 'app-alert-sticky', 7 | imports: [CdsModule], 8 | templateUrl: './alert-sticky.component.html', 9 | styleUrls: ['./alert-sticky.component.css'] 10 | }) 11 | export class AlertStickyComponent { 12 | 13 | @Input() message: any = null; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/app/components/dtos/file-dto.ts: -------------------------------------------------------------------------------- 1 | export class FileDto { 2 | name: string = ""; 3 | size: number = 0; 4 | date: number = 0; 5 | 6 | delete: boolean = false; 7 | loading: boolean = false; 8 | } 9 | -------------------------------------------------------------------------------- /src/app/components/dtos/page-dto.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "src/app/models/page"; 2 | 3 | export class PageDto { 4 | id: string | null = null; 5 | title: string = ""; 6 | icon: string = ""; 7 | language: string = ""; 8 | disabled: boolean = false; 9 | expanded: boolean = false; 10 | content: string = ""; 11 | updated: number | null = null; 12 | 13 | constructor(page: Page | null = null) { 14 | if (page != null) { 15 | this.id = page.id; 16 | this.title = page.title; 17 | this.icon = page.icon; 18 | this.language = page.language; 19 | this.disabled = page.disabled; 20 | this.expanded = page.expanded; 21 | this.content = page.content; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/components/dtos/to-rename-dto.ts: -------------------------------------------------------------------------------- 1 | export class ToRenameDto { 2 | oldid: string; 3 | newid: string; 4 | 5 | constructor(oldid: string, newid: string) { 6 | this.oldid = oldid; 7 | this.newid = newid; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/app/components/files/files.component.css: -------------------------------------------------------------------------------- 1 | .progress { 2 | margin-top:1.5em; 3 | } 4 | 5 | cds-icon { 6 | cursor: pointer; 7 | } 8 | 9 | h1 { 10 | margin-top:0; 11 | } 12 | 13 | th { 14 | cursor: pointer; 15 | } 16 | 17 | td { 18 | width:25%; 19 | } 20 | 21 | button { 22 | margin:0; 23 | } -------------------------------------------------------------------------------- /src/app/components/files/files.component.html: -------------------------------------------------------------------------------- 1 |

Files

2 | @if (!files$) { 3 | Loading... 4 | } 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | @if (uploading) { 14 |
15 | } 16 | 17 | @if (files$) { 18 | 19 | 20 | 21 | 28 | 35 | 43 | 44 | 45 | 46 | 47 | @for (file of files$ | async; track file) { 48 | 49 | 50 | 51 | 52 | 68 | 69 | } 70 | 71 |
Filename 22 | @if (sortField=='name' && sortAsc) { 23 | 24 | } @if (sortField=='name' && !sortAsc) { 25 | 26 | } 27 | Size 29 | @if (sortField=='size' && sortAsc) { 30 | 31 | } @if (sortField=='size' && !sortAsc) { 32 | 33 | } 34 | Date 36 | @if (sortField=='date' && sortAsc) { 37 | 38 | } 39 | @if (sortField=='date' && !sortAsc) { 40 | 41 | } 42 |
{{file.name}}{{file.size | fileSize}}{{file.date | date:'medium'}} 53 | 54 |   55 | @if (!file.loading && !file.delete) { 56 | 57 | } 58 | @if (!file.loading && file.delete) { 59 | 60 | } 61 | @if (!file.loading && file.delete) { 62 | 63 | } 64 |   65 | @if (file.loading) { 66 | 67 | }
72 | } -------------------------------------------------------------------------------- /src/app/components/files/files.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | import { NgxFileDropEntry, FileSystemFileEntry, NgxFileDropModule } from 'ngx-file-drop'; 4 | import { tap, map } from 'rxjs/operators'; 5 | import { AlertErrorComponent } from '../alert-error/alert-error.component'; 6 | import { CommonModule } from '@angular/common'; 7 | import { CdsModule } from '@cds/angular'; 8 | import { ClipboardModule } from 'ngx-clipboard'; 9 | import { FileDto } from '../dtos/file-dto'; 10 | import { BackendService } from 'src/app/services/backend.service'; 11 | import { FileSizePipe } from 'src/app/pipes/file-size.pipe'; 12 | 13 | @Component({ 14 | selector: 'app-files', 15 | imports: [CdsModule, NgxFileDropModule, AlertErrorComponent, CommonModule, FileSizePipe, ClipboardModule], 16 | templateUrl: './files.component.html', 17 | styleUrls: ['./files.component.css'] 18 | }) 19 | export class FilesComponent implements OnInit { 20 | 21 | files$: Observable | null = null; 22 | error: any = null; 23 | uploading = false; 24 | sortField = "date"; 25 | sortAsc = false; 26 | 27 | constructor(private backendService: BackendService) { } 28 | 29 | ngOnInit() { 30 | this.update(); 31 | } 32 | 33 | public update() { 34 | this.files$ = this.backendService.getAllFiles().pipe( 35 | map(files => files.sort((a, b) => { 36 | const reverse = this.sortAsc ? 1 : -1; 37 | if (this.sortField == "date") { 38 | return reverse * (a.date - b.date); 39 | } else if (this.sortField == "size") { 40 | return reverse * (a.size - b.size); 41 | } else { 42 | return reverse * a.name.localeCompare(b.name); 43 | } 44 | }) 45 | ) 46 | ); 47 | } 48 | 49 | public sort(field: string) { 50 | if (this.sortField == field) { 51 | this.sortAsc = !this.sortAsc; 52 | } else { 53 | this.sortField = field; 54 | this.sortAsc = true; 55 | } 56 | this.update(); 57 | } 58 | 59 | public dropped(files: NgxFileDropEntry[]) { 60 | for (const droppedFile of files) { 61 | if (!droppedFile.fileEntry.isFile) { 62 | continue; 63 | } 64 | 65 | this.uploading = true; 66 | const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; 67 | fileEntry.file((file: File) => { 68 | this.backendService.saveFile(droppedFile.relativePath, file).pipe( 69 | tap(() => this.update()) 70 | ).subscribe({ 71 | next: () => { this.uploading = false; }, 72 | error: (error) => { this.error = error; this.uploading = false; } 73 | }); 74 | }); 75 | } 76 | } 77 | 78 | public delete(fileToDelete: FileDto) { 79 | (fileToDelete as any).loading = true; 80 | this.backendService.deleteFile(fileToDelete.name).pipe( 81 | tap(() => this.update()) 82 | ).subscribe({ 83 | next: () => { }, 84 | error: (error) => { this.error = error; (fileToDelete as any).loading = false; } 85 | }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/app/components/home/home.component.css: -------------------------------------------------------------------------------- 1 | div, h3 { 2 | text-align: center; 3 | color:#ccc; 4 | } 5 | 6 | div { 7 | margin-top:1.5em; 8 | } -------------------------------------------------------------------------------- /src/app/components/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |

select page from navigation or add a new page

4 |
-------------------------------------------------------------------------------- /src/app/components/home/home.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Component } from '@angular/core'; 3 | import { CdsModule } from '@cds/angular'; 4 | 5 | @Component({ 6 | selector: 'app-home', 7 | imports: [CdsModule], 8 | templateUrl: './home.component.html', 9 | styleUrls: ['./home.component.css'] 10 | }) 11 | export class HomeComponent { 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/app/components/markdown-editor/markdown-editor.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/src/app/components/markdown-editor/markdown-editor.component.css -------------------------------------------------------------------------------- /src/app/components/markdown-editor/markdown-editor.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /src/app/components/markdown-editor/markdown-editor.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Component, AfterViewInit, Output, EventEmitter, Input, ViewChildren } from '@angular/core'; 3 | import EasyMDE from 'easymde/dist/easymde.min.js'; 4 | import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'; 5 | 6 | @Component({ 7 | selector: 'markdown-editor', 8 | imports: [], 9 | templateUrl: './markdown-editor.component.html', 10 | styleUrls: ['./markdown-editor.component.css'] 11 | }) 12 | export class MarkdownEditorComponent implements AfterViewInit { 13 | 14 | @ViewChildren('markdown') markdown: any; 15 | 16 | private editor: any = null; 17 | _content = ''; 18 | 19 | @Output() contentChange = new EventEmitter(); 20 | 21 | constructor(private markdownPipe: MarkdownPipe) { } 22 | 23 | @Input() 24 | get content() { 25 | return this._content; 26 | } 27 | 28 | set content(val) { 29 | this._content = val; 30 | this.contentChange.emit(val); 31 | } 32 | 33 | ngAfterViewInit() { 34 | this.editor = new EasyMDE({ 35 | element: this.markdown.first.nativeElement, 36 | showIcons: ['code', 'table'], 37 | hideIcons: ['side-by-side', 'fullscreen'], 38 | toolbar: ["bold", "italic", "heading", "|", "code", "quote", "unordered-list", "ordered-list", "|", "link", "image", "table", "|", "preview", "help"], 39 | autoDownloadFontAwesome: false, 40 | spellChecker: false, 41 | status: false, 42 | previewRender: (plainText: any) => this.markdownPipe.transform(plainText) 43 | }); 44 | this.editor.value(this._content); 45 | this.editor.codemirror.on('change', () => this.content = this.editor.value()); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/app/components/page-edit/page-edit.component.css: -------------------------------------------------------------------------------- 1 | .top { 2 | display:flex; 3 | } 4 | 5 | .dropdown-menu { 6 | top:auto; 7 | left:-9.3em; 8 | } 9 | 10 | input { 11 | margin-top:0.5em; 12 | margin-bottom:1em; 13 | margin-right:1.4em; 14 | font-size:1.5em; 15 | flex-grow: 1; 16 | } 17 | 18 | .selectIcon { 19 | cursor: pointer; 20 | margin:0.2em; 21 | } 22 | 23 | .parent { 24 | margin-top:1em; 25 | margin-bottom:1em; 26 | border-radius: .125rem; 27 | border: 1px solid #d7d7d7; 28 | background-color: #fff; 29 | padding:1em; 30 | display:inline-block; 31 | } 32 | 33 | .parent span { 34 | display:block; 35 | float:left; 36 | margin-right:10px; 37 | } 38 | 39 | .clr-form { 40 | padding-left:0; 41 | } 42 | 43 | .success { 44 | color:#2f8400; 45 | } 46 | 47 | .edit { 48 | position: relative; 49 | } 50 | -------------------------------------------------------------------------------- /src/app/components/page-edit/page-edit.component.html: -------------------------------------------------------------------------------- 1 | @if (loading) { 2 | Loading... 3 | } 4 | 5 | @if (success) { 6 | 7 | } 8 | @if (page) { 9 |
10 |
11 |
12 | 13 | 14 |
15 | 16 |
17 | 20 | 33 |
34 |
35 |
36 | 37 |
38 | Parent Page: 39 | 45 |
46 |
47 | 48 | 49 | @if (page.id) { 50 | 51 | } 52 | @if (!page.id) { 53 | 54 | } 55 | @if (page.id) { 56 | 57 | } 58 | @if (success) { 59 |
page successfully saved
60 | } 61 |
62 | } 63 | 64 | 65 | @if (showDeleteConfirmation) { 66 | 79 | } 80 | 81 | @if (selectIconDialog) { 82 | 96 | } 97 | 98 | @if (showDeleteConfirmation || selectIconDialog) { 99 | 100 | } -------------------------------------------------------------------------------- /src/app/components/page-edit/page-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, HostListener, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, Router, RouterModule } from '@angular/router'; 3 | import { switchMap, map, tap } from 'rxjs/operators'; 4 | import { of, timer } from 'rxjs'; 5 | import { AlertErrorComponent } from '../alert-error/alert-error.component'; 6 | import { CommonModule } from '@angular/common'; 7 | import { CdsModule } from '@cds/angular'; 8 | import { FormsModule } from '@angular/forms'; 9 | import { MarkdownEditorComponent } from '../markdown-editor/markdown-editor.component'; 10 | import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'; 11 | import { Page } from 'src/app/models/page'; 12 | import { BackendService } from 'src/app/services/backend.service'; 13 | import { IconService } from 'src/app/services/icon.service'; 14 | import { AlertStickyComponent } from '../alert-sticky/alert-sticky.component'; 15 | 16 | @Component({ 17 | selector: 'app-page-edit', 18 | imports: [ 19 | AlertErrorComponent, 20 | CommonModule, 21 | CdsModule, 22 | RouterModule, 23 | FormsModule, 24 | MarkdownEditorComponent, 25 | AlertStickyComponent 26 | ], 27 | templateUrl: './page-edit.component.html', 28 | styleUrls: ['./page-edit.component.css'] 29 | }) 30 | export class PageEditComponent implements OnInit { 31 | 32 | page: Page | null = null; 33 | error: any = null; 34 | loading = false; 35 | showDeleteConfirmation = false; 36 | selectIconDialog = false; 37 | success = false; 38 | showMenue = false; 39 | 40 | constructor(private route: ActivatedRoute, 41 | private router: Router, 42 | private backendService: BackendService, 43 | public iconService: IconService) { } 44 | 45 | ngOnInit() { 46 | this.route.params.pipe( 47 | map(params => params['id']), 48 | tap(_ => { this.page = this.error = null; this.loading = true; } ), 49 | switchMap(id => id ? this.backendService.getPage(id) : of(this.createNewPage())), 50 | tap(_ => this.loading = false) 51 | ) 52 | .subscribe({ 53 | next: page => this.page = page, 54 | error: error => { this.error = error; this.loading = false; } 55 | }); 56 | } 57 | 58 | 59 | @HostListener('document:keydown.control.s', ['$event']) 60 | onCtrlSKey(event: KeyboardEvent): void { 61 | this.save(); 62 | event.preventDefault(); 63 | } 64 | 65 | createNewPage() { 66 | const page = new Page(); 67 | page.icon = 'file'; 68 | page.children = []; 69 | page.parent = null; 70 | page.content = ''; 71 | return page; 72 | } 73 | 74 | save(show = false) { 75 | this.error = null; 76 | this.backendService.savePage(this.page!).subscribe({ 77 | next: page => { 78 | if (show) { 79 | this.router.navigate(['/page', page.id]); 80 | } else { 81 | this.router.navigate(['/page', 'edit', page.id]); 82 | this.success = true; 83 | timer(3000).subscribe(() => this.success = false); 84 | } 85 | }, 86 | error: error => { this.error = error } 87 | }); 88 | } 89 | 90 | delete() { 91 | this.error = null; 92 | this.backendService.deletePage(this.page!).subscribe({ 93 | next: () => this.router.navigate(['/']), 94 | error: error => { this.error = error; this.showDeleteConfirmation = false; this.loading = false; } 95 | }); 96 | } 97 | 98 | flattenPages(): Page[] { 99 | return this.backendService.getAllPagesFlatten().filter(p => p.id != this.page!.id); 100 | } 101 | 102 | flattenPageTitle(page: Page): string { 103 | const spaces = (page.id!.split(BackendService.ID_SEPARATOR).length - 1) * 4; 104 | if (spaces > 0) { 105 | return " ".repeat(spaces) + "→ " + page.title; 106 | } 107 | return page.title!; 108 | } 109 | 110 | } 111 | -------------------------------------------------------------------------------- /src/app/components/page-show/page-show.component.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | margin-top:0; 3 | margin-bottom:0.5em; 4 | margin-left:1.2em; 5 | } 6 | 7 | button { 8 | float:right; 9 | } 10 | 11 | .page { 12 | position: relative; 13 | } 14 | 15 | cds-icon { 16 | position: absolute; 17 | top:0.6em; 18 | } -------------------------------------------------------------------------------- /src/app/components/page-show/page-show.component.html: -------------------------------------------------------------------------------- 1 | @if (loading) { 2 | Loading... 3 | } 4 | 5 | @if (page) { 6 |
7 | 8 | 9 |

{{page.title}}

10 | @if (page.content) { 11 |
12 | } 13 |
14 | } -------------------------------------------------------------------------------- /src/app/components/page-show/page-show.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { ActivatedRoute, RouterModule } from '@angular/router'; 3 | import { switchMap, map, tap, filter } from 'rxjs/operators'; 4 | import { AlertErrorComponent } from '../alert-error/alert-error.component'; 5 | import { CommonModule } from '@angular/common'; 6 | import { CdsModule } from '@cds/angular'; 7 | import { BookmakrsPipe } from 'src/app/pipes/bookmarks.pipe'; 8 | import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'; 9 | import { Page } from 'src/app/models/page'; 10 | import { BackendService } from 'src/app/services/backend.service'; 11 | 12 | @Component({ 13 | selector: 'app-page-show', 14 | imports: [AlertErrorComponent, CommonModule, RouterModule, BookmakrsPipe, MarkdownPipe, CdsModule], 15 | templateUrl: './page-show.component.html', 16 | styleUrls: ['./page-show.component.css'] 17 | }) 18 | export class PageShowComponent implements OnInit { 19 | page: Page | null = null; 20 | 21 | error: string | null = null; 22 | loading = false; 23 | 24 | constructor(private route: ActivatedRoute, 25 | private backendService: BackendService) { } 26 | 27 | ngOnInit() { 28 | this.route.params.pipe( 29 | map(params => params['id']), 30 | filter(id => id), 31 | tap(_ => {this.page = null; this.loading = true; }), 32 | switchMap(id => this.backendService.getPage(id)), 33 | tap(_ => this.loading = false) 34 | ) 35 | .subscribe({ 36 | next: page => this.page = page, 37 | error: error => { this.error = error; this.loading = false; } 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/components/page-tree/page-tree.component.css: -------------------------------------------------------------------------------- 1 | .example-tree-invisible { 2 | display: none; 3 | } 4 | 5 | .example-tree ul, 6 | .example-tree li { 7 | margin-top: 0; 8 | margin-bottom: 0; 9 | list-style-type: none; 10 | } -------------------------------------------------------------------------------- /src/app/components/page-tree/page-tree.component.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /src/app/components/page-tree/page-tree.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { ClarityModule } from '@clr/angular'; 3 | 4 | import { RouterModule } from '@angular/router'; 5 | import { CdsModule } from '@cds/angular'; 6 | import { Page } from 'src/app/models/page'; 7 | import { BackendService } from 'src/app/services/backend.service'; 8 | 9 | @Component({ 10 | selector: 'app-page-tree', 11 | imports: [ClarityModule, RouterModule, CdsModule], 12 | templateUrl: './page-tree.component.html', 13 | styleUrls: ['./page-tree.component.css'] 14 | }) 15 | export class PageTreeComponent { 16 | 17 | private _pages: Page[] = []; 18 | 19 | @Input() 20 | active: string | null = null; 21 | 22 | constructor(private backendService: BackendService) {} 23 | 24 | @Input() 25 | get pages() { 26 | return this._pages; 27 | } 28 | 29 | set pages(val) { 30 | this._pages = val; 31 | } 32 | 33 | getChildren = (page: Page) => page && page.children ? page.children : []; 34 | 35 | toggle(page: Page) { 36 | page.expanded = !page.expanded; 37 | this.backendService.savePage(page).subscribe(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/components/passwords/passwords.component.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | margin-top:0; 3 | } 4 | 5 | .table th { 6 | vertical-align: middle; 7 | } 8 | 9 | .search { 10 | font-weight: normal; 11 | } 12 | 13 | th .add { 14 | margin-top:0; 15 | } 16 | 17 | .unlock { 18 | text-align: center; 19 | padding:50px; 20 | } 21 | 22 | input { 23 | font-size: 1.1em; 24 | border-radius: 0.1em; 25 | margin-right:0.3em; 26 | padding: 0.3em; 27 | vertical-align: middle; 28 | border: 1px solid #aaa; 29 | } 30 | 31 | .unlock .btn { 32 | margin:0; 33 | } 34 | 35 | input:focus{ 36 | outline: none; 37 | } 38 | 39 | .savepassword .password { 40 | display:block; 41 | margin-top: 1em; 42 | width: 60%; 43 | } 44 | 45 | .add { 46 | text-align: left; 47 | } 48 | 49 | table button { 50 | margin-top:0.2em; 51 | margin-bottom: 0; 52 | } 53 | 54 | th, td { 55 | width:20%; 56 | font-size:1.15em; 57 | } 58 | 59 | td.last { 60 | width:40%; 61 | } 62 | 63 | td span { 64 | display: inline-block; 65 | margin-top:1em; 66 | } 67 | 68 | .action { 69 | margin-top:1.5em; 70 | } 71 | 72 | .modal-body { 73 | overflow-y: hidden; 74 | } 75 | 76 | cds-icon { 77 | cursor: pointer; 78 | } 79 | 80 | td clr-password-container { 81 | margin-top:5px; 82 | } 83 | 84 | .showhide { 85 | margin-left:0.7em; 86 | } 87 | 88 | .hidden { 89 | display: none; 90 | } -------------------------------------------------------------------------------- /src/app/components/passwords/passwords.component.html: -------------------------------------------------------------------------------- 1 |

Passwords

2 | @if (!page && !error) { 3 | Loading... 4 | } 5 | 6 | 7 | 8 | @if (hasEntries()) { 9 | 10 | 11 | 12 | 13 | 14 | 20 | 21 | 22 | } 23 | 24 | @for (entry of getEntries(); track entry; let i = $index) { 25 | 26 | 33 | 40 | 55 | 65 | 66 | } 67 | @if (!entries && page) { 68 | 69 | 73 | 74 | } 75 | @if (entries) { 76 | 77 | 82 | 83 | } 84 | 85 |
ServiceUsernamePassword 15 | 16 | 19 |
27 | @if (!entry.edit) { 28 | {{entry.service}} 29 | } @else { 30 | 31 | } 32 | 34 | @if (!entry.edit) { 35 | {{entry.username}} 36 | } @else { 37 | 38 | } 39 | 41 | @if (!entry.edit) { 42 | {{entry.passwordShow ? decryptPassword(entry.password) : '*********'}} 43 | } @else { 44 | 45 | 46 | 47 | } 48 | @if (!entry.passwordShow && !entry.edit) { 49 | 50 | } 51 | @if (entry.passwordShow && !entry.edit) { 52 | 53 | } 54 | 56 | @if (entry.edit) { 57 | 58 | 59 | 60 | } @else { 61 | 62 | 63 | } 64 |
70 | 71 | 72 |
78 | 81 |
86 | 87 | @if (entries && page) { 88 |
89 | 90 | 91 | 92 | 93 | 94 | @if (success || successImport || successExport) { 95 | 113 | } 114 |
115 | } 116 | 117 | @if (entryToDelete!==null) { 118 | 131 | } 132 | 133 | @if (askPassword) { 134 | 155 | } 156 | 157 | @if (export) { 158 | 178 | } 179 | 180 | @if (entryToDelete!==null || askPassword || export) { 181 | 182 | } -------------------------------------------------------------------------------- /src/app/components/passwords/passwords.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy, ViewChildren, AfterViewInit } from '@angular/core'; 2 | import { ClipboardService } from 'ngx-clipboard' 3 | import { timer, Subject, Subscription } from 'rxjs'; 4 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; 5 | import { AlertErrorComponent } from '../alert-error/alert-error.component'; 6 | import { ClarityModule } from '@clr/angular'; 7 | import { CommonModule } from '@angular/common'; 8 | import { RouterModule } from '@angular/router'; 9 | import { FormsModule } from '@angular/forms'; 10 | import { Page } from 'src/app/models/page'; 11 | import { BackendService } from 'src/app/services/backend.service'; 12 | import { AesService } from 'src/app/services/aes.service'; 13 | import { PasswordEntry } from 'src/app/models/password-entry'; 14 | 15 | @Component({ 16 | selector: 'app-passwords', 17 | imports: [AlertErrorComponent, ClarityModule, CommonModule, RouterModule, FormsModule], 18 | templateUrl: './passwords.component.html', 19 | styleUrls: ['./passwords.component.css'] 20 | }) 21 | export class PasswordsComponent implements OnInit, OnDestroy, AfterViewInit { 22 | 23 | @ViewChildren('unlockPasswordInput') unlockPasswordInput: any; 24 | @ViewChildren('password') password: any; 25 | @ViewChildren('exportPassword') exportPassword: any; 26 | @ViewChildren('search') search: any; 27 | @ViewChildren('service') service: any; 28 | 29 | page: Page | null = null; 30 | 31 | error: any = null; 32 | errorPassword: any = null; 33 | errorExport: any = null; 34 | 35 | success = false; 36 | successImport = false; 37 | successExport = false; 38 | 39 | export = false; 40 | entries: PasswordEntry[] | null = null; 41 | entryToDelete: number | null = null; 42 | askPassword = false; 43 | 44 | hash: string = "none"; 45 | q: string = ""; 46 | qChanged: Subject = new Subject(); 47 | private qChanged$ = new Subscription(); 48 | 49 | constructor(private backendService: BackendService, 50 | private aesService: AesService, 51 | private _clipboardService: ClipboardService) { } 52 | 53 | ngOnInit() { 54 | this.backendService.getPasswordPage().subscribe({ 55 | next: page => this.page = page, 56 | error: error => { 57 | if (error && error.status === 404) { 58 | this.page = new Page(); 59 | this.entries = []; 60 | } else { 61 | this.error = error; 62 | } 63 | } 64 | }); 65 | 66 | this.qChanged$ = this.qChanged.pipe( 67 | debounceTime(700), 68 | distinctUntilChanged() 69 | ).subscribe(q => this.q = q); 70 | } 71 | 72 | ngAfterViewInit() { 73 | setTimeout(() => { 74 | if (this.entries == null) { 75 | this.unlockPasswordInput.first.nativeElement.focus(); 76 | } 77 | }, 400); 78 | } 79 | 80 | ngOnDestroy() { 81 | this.entries = []; 82 | this.hash = "none"; 83 | this.page = null; 84 | this.qChanged$.unsubscribe(); 85 | } 86 | 87 | getEntries() { 88 | return this.q != null && this.q.length > 0 && this.entries ? this.entries.filter(entry => entry.service && entry.service.includes(this.q)) : this.entries; 89 | } 90 | 91 | hasEntries() { 92 | return this.entries != null && this.entries.length > 0; 93 | } 94 | 95 | add() { 96 | this.q = ""; 97 | const entry = new PasswordEntry(); 98 | entry.username = this.entries!.map(e => e.username).reduce((a: any,b: any,i: any,arr: any) => (arr.filter((v: any)=>v===a).length>=arr.filter((v: any)=>v===b).length?a:b), null); 99 | this.entries!.push(entry.withEditTrue()); 100 | setTimeout(() => { 101 | this.service.last.nativeElement.focus(); 102 | }, 400); 103 | } 104 | 105 | delete(index: number) { 106 | this.entries!.splice(index, 1); 107 | } 108 | 109 | random(index: number) { 110 | this.getEntries()![index].password = this.encryptPassword(Array(20) 111 | .fill("123456789ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz") 112 | .map(x => x[Math.floor(Math.random() * x.length)]) 113 | .join('')); 114 | this.entries![index].passwordShow = true; 115 | } 116 | 117 | clipboard(index: number) { 118 | this._clipboardService.copyFromContent(this.decryptPassword(this.getEntries()![index].password)!); 119 | } 120 | 121 | unlock(password: string) { 122 | this.error = null; 123 | try { 124 | let decrypted = this.aesService.decrypt(this.page!.content!, password); 125 | this.entries = JSON.parse(decrypted).sort((p1: PasswordEntry, p2: PasswordEntry) => { 126 | if (!p1.service) { 127 | return -1; 128 | } else if (!p2.service) { 129 | return 1; 130 | } else { 131 | return p1.service.localeCompare(p2.service); 132 | } 133 | }); 134 | this.hash = this.aesService.sha512(password); 135 | setTimeout(() => this.search.first.nativeElement.focus(), 0); 136 | } catch(e) { 137 | this.error = "decrypt error"; 138 | this.unlockPasswordInput.first.nativeElement.focus(); 139 | } 140 | } 141 | 142 | decryptPassword(password: string): string | null { 143 | if (!password) { 144 | return null; 145 | } 146 | return this.aesService.decrypt(password, this.hash); 147 | } 148 | 149 | encryptPassword(password: string): string { 150 | return this.aesService.encrypt(password, this.hash); 151 | } 152 | 153 | encryptPasswordFromUi(target: any): string { 154 | return this.encryptPassword(target.value); 155 | } 156 | 157 | reencryptPassword(entry: PasswordEntry, newpassword: string): PasswordEntry { 158 | let decryptedPassword = this.decryptPassword(entry.password); 159 | if (decryptedPassword) { 160 | entry.password = this.aesService.encrypt(decryptedPassword, newpassword); 161 | } 162 | return entry; 163 | } 164 | 165 | showAskPassword() { 166 | this.askPassword = true 167 | setTimeout(() => { 168 | this.password.first.nativeElement.focus(); 169 | }, 200); 170 | } 171 | 172 | save(password: string, password2: string) { 173 | this.errorPassword = null; 174 | if (!password) { 175 | this.errorPassword = "please enter a password"; 176 | return; 177 | } 178 | if (password !== password2) { 179 | this.errorPassword = "passwords are not equals"; 180 | return; 181 | } 182 | let hash = this.aesService.sha512(password); 183 | let toSave = this.entries! 184 | .map(e => PasswordEntry.fromOther(e)) 185 | .map(e => this.reencryptPassword(e, hash)); 186 | let json = JSON.stringify(toSave); 187 | let content = this.aesService.encrypt(json, password); 188 | this.page!.content = content; 189 | this.backendService.savePasswordPage(this.page!).subscribe({ 190 | next: () => { 191 | this.success = true; 192 | this.askPassword = false; 193 | timer(3000).subscribe(() => this.success = false); 194 | }, 195 | error: error => { 196 | this.askPassword = false; 197 | this.error = error; 198 | } 199 | }); 200 | } 201 | 202 | import(fileInput: any) { 203 | this.error = null; 204 | this.successImport = false; 205 | if (fileInput.target.files.length === 0) { 206 | return; 207 | } 208 | const file = fileInput.target.files[0]; 209 | 210 | const reader = new FileReader(); 211 | reader.readAsText(file, 'UTF-8'); 212 | reader.onload = (evt: any) => { 213 | fileInput.target.value = ''; 214 | const parsed = JSON.parse(evt.target.result); 215 | this.entries = parsed.map((e: any) => PasswordEntry.fromData(e.service, e.username, this.encryptPassword(e.password))); 216 | this.successImport = true; 217 | timer(3000).subscribe(() => this.successImport = false); 218 | } 219 | } 220 | 221 | showExport() { 222 | this.export = true; 223 | this.errorExport = null; 224 | setTimeout(() => { 225 | this.exportPassword.first.nativeElement.focus(); 226 | }, 200); 227 | } 228 | 229 | exportToClipboard(password: string) { 230 | this.errorExport = null; 231 | if (this.aesService.sha512(password) != this.hash) { 232 | this.errorExport = 'invalid password'; 233 | return; 234 | } 235 | this.successExport = false; 236 | const passwords = this.entries!.map(e => ({service: e.service, username: e.username, password: this.decryptPassword(e.password)})); 237 | const json = JSON.stringify(passwords, null, 4); 238 | this._clipboardService.copyFromContent(json); 239 | this.export = false; 240 | this.successExport = true; 241 | timer(3000).subscribe(() => this.successExport = false); 242 | } 243 | 244 | public static saveFile(name: string, type: string, data: any) { 245 | const blob = new Blob([data], {type: type}); 246 | const a = document.createElement('a'); 247 | a.setAttribute('style', 'display:none'); 248 | const url = window.URL.createObjectURL(blob); 249 | a.setAttribute('href', url); 250 | a.setAttribute('download', name); 251 | document.body.appendChild(a); 252 | a.click(); 253 | window.URL.revokeObjectURL(url); 254 | a.remove(); 255 | } 256 | } -------------------------------------------------------------------------------- /src/app/components/search/search.component.css: -------------------------------------------------------------------------------- 1 | .q span { 2 | font-weight: bold; 3 | } 4 | 5 | .result { 6 | margin-top:1.5em; 7 | } 8 | 9 | .result a { 10 | font-size:1.4em; 11 | text-decoration: none; 12 | } 13 | 14 | .result span { 15 | padding-left:0.4em; 16 | } 17 | 18 | pre { 19 | padding:1em; 20 | background:#eeeeee; 21 | } -------------------------------------------------------------------------------- /src/app/components/search/search.component.html: -------------------------------------------------------------------------------- 1 | @if (pages.length>0) { 2 |
3 | searchresults for "{{q}}" 4 | @for (page of pages; track page) { 5 |
6 | 7 |

 8 |       
9 | } 10 |
11 | } -------------------------------------------------------------------------------- /src/app/components/search/search.component.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Component, OnDestroy, OnInit } from '@angular/core'; 3 | import { ActivatedRoute, RouterModule } from '@angular/router'; 4 | import { CdsModule } from '@cds/angular'; 5 | import { Subscription } from 'rxjs'; 6 | import { Page } from 'src/app/models/page'; 7 | import { HighlightPipe } from 'src/app/pipes/highlight.pipe'; 8 | import { Nl2BrPipe } from 'src/app/pipes/nl2br.pipe'; 9 | import { SearchResultPipe } from 'src/app/pipes/searchresult.pipe'; 10 | import { BackendService } from 'src/app/services/backend.service'; 11 | 12 | @Component({ 13 | selector: 'app-search', 14 | imports: [CdsModule, RouterModule, Nl2BrPipe, HighlightPipe, SearchResultPipe], 15 | templateUrl: './search.component.html', 16 | styleUrls: ['./search.component.css'] 17 | }) 18 | export class SearchComponent implements OnInit, OnDestroy { 19 | 20 | q: string = ''; 21 | pages: Page[] = []; 22 | 23 | private search$ = new Subscription(); 24 | 25 | constructor(private route: ActivatedRoute, 26 | private backendService: BackendService) {} 27 | 28 | ngOnInit() { 29 | this.search$ = this.route.queryParams.subscribe(params => { 30 | if (params['q']) { 31 | this.q = params['q']; 32 | this.search(); 33 | } 34 | }); 35 | } 36 | 37 | ngOnDestroy(): void { 38 | this.search$.unsubscribe(); 39 | } 40 | 41 | search() { 42 | this.backendService.search(this.q).subscribe(pages => { 43 | this.pages = pages; 44 | }); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/app/components/vocabulary-edit/vocabulary-edit.component.css: -------------------------------------------------------------------------------- 1 | .clr-form-control { 2 | margin-top: 0; 3 | margin-bottom: 1rem; 4 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-edit/vocabulary-edit.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/app/components/vocabulary-edit/vocabulary-edit.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from "@angular/core"; 2 | import { Page } from "src/app/models/page"; 3 | import { ClarityModule } from "@clr/angular"; 4 | 5 | import { FormsModule } from "@angular/forms"; 6 | 7 | @Component({ 8 | selector: 'app-vocabulary-edit', 9 | imports: [ClarityModule, FormsModule], 10 | templateUrl: './vocabulary-edit.component.html', 11 | styleUrls: ['./vocabulary-edit.component.css'] 12 | }) 13 | export class VocabularyEditComponent { 14 | 15 | @Input() 16 | page: Page | null = null; 17 | 18 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-exercise-result/vocabulary-exercise-result.component.css: -------------------------------------------------------------------------------- 1 | .modal-body { 2 | text-align: center; 3 | } 4 | 5 | .correct { 6 | color:green; 7 | font-size:1.3rem; 8 | font-weight: bold; 9 | } 10 | 11 | .wrong { 12 | color:red; 13 | font-size:1.3rem; 14 | font-weight: bold; 15 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-exercise-result/vocabulary-exercise-result.component.html: -------------------------------------------------------------------------------- 1 | @if (result!==null) { 2 | 25 | } 26 | @if (result!==null) { 27 | 28 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-exercise-result/vocabulary-exercise-result.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, Output, EventEmitter, ModuleWithProviders } from "@angular/core"; 2 | import { ClarityModule } from "@clr/angular"; 3 | 4 | import { FormsModule } from "@angular/forms"; 5 | import { VocabularyExerciseResult } from "src/app/models/vocabulary-exercise-result"; 6 | 7 | import { NgCircleProgressModule } from "ng-circle-progress"; 8 | 9 | @Component({ 10 | selector: 'app-vocabulary-exercise-result', 11 | imports: [ClarityModule, FormsModule, NgCircleProgressModule], 12 | providers: [ 13 | (NgCircleProgressModule.forRoot({ 14 | radius: 100, 15 | outerStrokeWidth: 16, 16 | innerStrokeWidth: 8, 17 | outerStrokeColor: '#78C000', 18 | innerStrokeColor: '#C7E596', 19 | animationDuration: 300, 20 | }) as ModuleWithProviders).providers!, 21 | ], 22 | templateUrl: './vocabulary-exercise-result.component.html', 23 | styleUrls: ['./vocabulary-exercise-result.component.css'] 24 | }) 25 | export class VocabularyExerciseResultComponent { 26 | 27 | @Input() 28 | result: VocabularyExerciseResult | null = null; 29 | 30 | @Output() finished = new EventEmitter(); 31 | 32 | close() { 33 | this.finished.emit(true) 34 | } 35 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-exercise/vocabulary-exercise.component.css: -------------------------------------------------------------------------------- 1 | .action { 2 | width:100%; 3 | float: left; 4 | text-align: center; 5 | } 6 | 7 | .word { 8 | width:100%; 9 | text-align: center; 10 | font-size: 1.2rem; 11 | font-weight: bold; 12 | padding:5rem; 13 | } 14 | 15 | audio { 16 | display: none; 17 | } 18 | 19 | .modal-header { 20 | display: flex; 21 | align-items: center; 22 | justify-content: center; 23 | } 24 | 25 | .modal-header button { 26 | margin-left:0.5rem; 27 | } 28 | 29 | .phase { 30 | padding-right:1rem; 31 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-exercise/vocabulary-exercise.component.html: -------------------------------------------------------------------------------- 1 | @if (current) { 2 | 67 | } 68 | @if (current) { 69 | 70 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-exercise/vocabulary-exercise.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, ViewChildren, Output, EventEmitter, ViewChild } from "@angular/core"; 2 | import { ClarityModule } from "@clr/angular"; 3 | 4 | import { FormsModule } from "@angular/forms"; 5 | import { VocabularyEntry } from "src/app/models/vocabulary-entry"; 6 | import { Page } from "src/app/models/page"; 7 | import { DateTime } from "luxon"; 8 | import { BackendService } from "src/app/services/backend.service"; 9 | import { VocabularyCard } from "src/app/models/vocabulary-card"; 10 | import { AlertErrorComponent } from "../alert-error/alert-error.component"; 11 | import { VocabularyExerciseResult } from "src/app/models/vocabulary-exercise-result"; 12 | 13 | @Component({ 14 | selector: 'app-vocabulary-exercise', 15 | imports: [ClarityModule, FormsModule, AlertErrorComponent], 16 | templateUrl: './vocabulary-exercise.component.html', 17 | styleUrls: ['./vocabulary-exercise.component.css'] 18 | }) 19 | export class VocabularyExerciseComponent { 20 | 21 | @Input() 22 | page: Page | null = null; 23 | 24 | @Input() 25 | introduce: boolean = false; 26 | private introduceCards: VocabularyCard[] = []; 27 | introduceMode: boolean = false; 28 | 29 | _cards: VocabularyCard[] = []; 30 | current: VocabularyCard | undefined | null = null; 31 | result: boolean = false; 32 | 33 | private history: VocabularyCard[] = []; 34 | private backup: VocabularyCard[] = []; 35 | 36 | progressAll: number = 0; 37 | progressFinished: number = 0; 38 | progressValue: number = 0; 39 | progressLabel: string = ""; 40 | progressCorrect: number = 0 41 | progressWrong: number = 0 42 | 43 | @Input() 44 | train: boolean = false; 45 | 46 | @Output() finished = new EventEmitter(); 47 | 48 | error: any = null; 49 | 50 | private _audio: any | null = null; 51 | 52 | @ViewChild('audio') set audio(element: any) { 53 | if (element) { 54 | this._audio = element.nativeElement; 55 | if (this.introduceMode && this.current) { 56 | this.play(this.current!.vocabulary.english); 57 | } 58 | } 59 | } 60 | 61 | constructor(private backendService: BackendService) {} 62 | 63 | @Input() 64 | set cards(value: VocabularyCard[]) { 65 | this.progressAll = 0 66 | this.progressFinished = 0 67 | this.progressCorrect = 0 68 | this.progressWrong = 0 69 | this.history = []; 70 | this.backup = []; 71 | if (this.introduce) { 72 | this.introduceCards = value.filter(v => v.g2e); 73 | } 74 | const g2e = this.shuffle(value.filter(v => v.g2e)); 75 | const e2g = this.shuffle(value.filter(v => !(v.g2e))); 76 | this._cards = g2e.concat(e2g); 77 | this.progressAll = this._cards.length; 78 | this.next(); 79 | } 80 | 81 | answer() { 82 | if (this.current!.g2e) { 83 | this.play(this.current!.vocabulary.english); 84 | } 85 | this.result = true 86 | } 87 | 88 | correct() { 89 | this.progressFinished++; 90 | this.save(true); 91 | } 92 | 93 | wrong() { 94 | this.save(false); 95 | } 96 | 97 | hasBack(): boolean { 98 | return this.history.length > 0; 99 | } 100 | 101 | back() { 102 | this._cards.unshift(this.current!); 103 | const current = this.history.pop(); 104 | const backup = this.backup.pop(); 105 | 106 | const wasCorrectg2e = current!.g2e && current!.vocabulary.g2ePhase > backup!.vocabulary.g2ePhase; 107 | const wasCorrecte2g = current!.g2e == false && current!.vocabulary.e2gPhase > backup!.vocabulary.e2gPhase; 108 | if (wasCorrectg2e || wasCorrecte2g) { 109 | this.progressFinished--; 110 | this.progressCorrect--; 111 | } else { 112 | this.progressWrong--; 113 | } 114 | this.progressUpdate(); 115 | 116 | current!.vocabulary.e2gPhase = backup!.vocabulary.e2gPhase; 117 | current!.vocabulary.e2gNext = backup!.vocabulary.e2gNext; 118 | current!.vocabulary.g2ePhase = backup!.vocabulary.g2ePhase; 119 | current!.vocabulary.g2eNext = backup!.vocabulary.g2eNext; 120 | this.current = current; 121 | } 122 | 123 | private save(correct: boolean) { 124 | const isAlreadyInHistory = this.history.find(entry => entry.g2e == this.current!.g2e && VocabularyEntry.equals(entry.vocabulary, this.current!.vocabulary)); 125 | if (!isAlreadyInHistory && correct) { 126 | this.progressCorrect++; 127 | } 128 | if (!isAlreadyInHistory && !correct) { 129 | this.progressWrong++ 130 | } 131 | 132 | const vocabularyCopy = new VocabularyEntry(this.current!.vocabulary ? this.current!.vocabulary : null); 133 | this.backup.push(new VocabularyCard(vocabularyCopy, this.current!.g2e)); 134 | 135 | if (!this.introduce && !this.train && !isAlreadyInHistory) { 136 | if (this.current!.g2e) { 137 | const newPhase = this.current!.vocabulary.g2ePhase + (correct ? 1 : -2); 138 | this.current!.vocabulary.g2ePhase = newPhase <= 0 ? 1 : newPhase; 139 | this.current!.vocabulary.g2eNext = correct ? this.phaseToNext(newPhase) : this.phaseToNext(2); 140 | } else { 141 | const newPhase = this.current!.vocabulary.e2gPhase + (correct ? 1 : -2); 142 | this.current!.vocabulary.e2gPhase = newPhase <= 0 ? 1 : newPhase; 143 | this.current!.vocabulary.e2gNext = correct ? this.phaseToNext(newPhase) : this.phaseToNext(2); 144 | } 145 | } 146 | 147 | this.history.push(this.current!); 148 | 149 | if (!correct) { 150 | this._cards.push(this.current!); 151 | } 152 | 153 | if (!this.introduce && !this.train) { 154 | this.write(this.current!!); 155 | } 156 | 157 | this.next(); 158 | } 159 | 160 | private write(vocabularyCard: VocabularyCard) { 161 | const vocabulary = vocabularyCard.vocabulary; 162 | const all = VocabularyEntry.parseVocabulary(this.page!!.content); 163 | const existing = all.find(v => VocabularyEntry.equals(v, vocabulary)); 164 | existing!.g2ePhase = vocabulary.g2ePhase; 165 | existing!.g2eNext = vocabulary.g2eNext; 166 | existing!.e2gPhase = vocabulary.e2gPhase; 167 | existing!.e2gNext = vocabulary.e2gNext; 168 | this.page!.content = JSON.stringify(all, null, 4); 169 | this.backendService.savePage(this.page!!).subscribe({ 170 | next: () => {}, 171 | error: error => { this.error = error; } 172 | }); 173 | } 174 | 175 | private progressUpdate() { 176 | this.progressValue = Math.round((this.progressFinished / (this.progressAll)) * 100); 177 | this.progressLabel = `${this.progressFinished}/${this.progressAll}`; 178 | } 179 | 180 | next() { 181 | if (this.introduceCards.length > 0) { 182 | this.current = this.introduceCards.shift(); 183 | this.introduceMode = true; 184 | this.play(this.current!.vocabulary.english); 185 | this.introduced(this.current!); 186 | } else { 187 | this.introduceMode = false; 188 | this.current = this._cards.shift(); 189 | 190 | if (!this.current && this.history.length > 0) { 191 | const percent = Math.round((this.progressCorrect / (this.progressAll)) * 100); 192 | this.finished.emit(new VocabularyExerciseResult(percent, this.progressCorrect, this.progressWrong)); 193 | return; 194 | } 195 | 196 | if (this.current!.g2e === false) { 197 | this.play(this.current!.vocabulary.english); 198 | } 199 | 200 | this.result = false; 201 | } 202 | 203 | this.progressUpdate(); 204 | } 205 | 206 | private introduced(current: VocabularyCard) { 207 | const g2eCard = this._cards.find(c => c.g2e === true && VocabularyEntry.equals(c.vocabulary, current.vocabulary)); 208 | g2eCard!.vocabulary.g2eNext = this.phaseToNext(1); 209 | g2eCard!.vocabulary.g2ePhase = 1; 210 | this.write(g2eCard!); 211 | 212 | const e2gCard = this._cards.find(c => c.g2e === false && VocabularyEntry.equals(c.vocabulary, current.vocabulary)); 213 | e2gCard!.vocabulary.e2gNext = this.phaseToNext(1); 214 | e2gCard!.vocabulary.e2gPhase = 1; 215 | this.write(e2gCard!); 216 | } 217 | 218 | play(word: string) { 219 | try { 220 | const base = document.querySelector('base')!!.getAttribute('href'); 221 | this._audio.src= `${base}api/api/text2speech?text=${word}&language=${this.page?.language}`; 222 | this._audio.play(); 223 | } catch(ex) { 224 | // ignore error on init 225 | } 226 | } 227 | 228 | private phaseToNext(phase: number): Date { 229 | if(phase == 1) { 230 | return DateTime.now().plus({days: 1}).startOf('day').toJSDate(); 231 | } else if(phase == 2) { 232 | return DateTime.now().plus({days: 3}).startOf('day').toJSDate(); 233 | } else if(phase == 3) { 234 | return DateTime.now().plus({days: 9}).startOf('day').toJSDate(); 235 | } else if(phase == 4) { 236 | return DateTime.now().plus({days: 29}).startOf('day').toJSDate(); 237 | } else if(phase == 5) { 238 | return DateTime.now().plus({days: 90}).startOf('day').toJSDate(); 239 | } else { 240 | return DateTime().plus({years: 100}).startOf('day').toJSDate(); 241 | } 242 | } 243 | 244 | private shuffle(a: any[]): any[] { 245 | var j, x, i; 246 | for (i = a.length - 1; i > 0; i--) { 247 | j = Math.floor(Math.random() * (i + 1)); 248 | x = a[i]; 249 | a[i] = a[j]; 250 | a[j] = x; 251 | } 252 | return a; 253 | } 254 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-list/vocabulary-list.component.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | margin-top:0; 3 | margin-bottom:0.5em; 4 | margin-left:0; 5 | } 6 | 7 | .top { 8 | display:flex; 9 | } 10 | 11 | .top h1 { 12 | flex: 1; 13 | } 14 | 15 | input { 16 | font-size: 1.1em; 17 | border-radius: 0.1em; 18 | margin-right:0.3em; 19 | padding: 0.3em; 20 | vertical-align: middle; 21 | border: 1px solid #ddd; 22 | width: 100%; 23 | } 24 | 25 | .table th { 26 | vertical-align: middle; 27 | } 28 | 29 | .search { 30 | font-weight: normal; 31 | } 32 | 33 | .add { 34 | margin-top:0; 35 | text-align: left; 36 | } 37 | 38 | .success { 39 | color:green; 40 | padding-right: 10px; 41 | } 42 | 43 | .add td { 44 | border-bottom:3px green solid !important; 45 | } 46 | 47 | input { 48 | border:0; 49 | margin:0; 50 | padding:0.7rem; 51 | } 52 | 53 | td { 54 | vertical-align: middle; 55 | padding:0; 56 | } 57 | 58 | th span { 59 | padding-left:0.5rem; 60 | } 61 | 62 | h1 { 63 | margin-bottom:0; 64 | } 65 | 66 | h4 { 67 | margin-bottom: 0.5rem; 68 | } 69 | 70 | .search { 71 | border-bottom:1px solid black; 72 | } 73 | 74 | .centered { 75 | display: flex; 76 | justify-content: center; 77 | align-items: center; 78 | } 79 | 80 | .pagination { 81 | margin-top:0.8rem; 82 | } 83 | 84 | .pagination td { 85 | background:white; 86 | border:1px solid #eee; 87 | padding:0.4rem 0.8rem 0.4rem 0.8rem; 88 | cursor: pointer; 89 | } 90 | 91 | .pagination td.selected { 92 | background:#eee; 93 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary-list/vocabulary-list.component.html: -------------------------------------------------------------------------------- 1 | @if (loading) { 2 | Loading... 3 | } 4 | 5 | 6 | @if (page) { 7 |
8 | @if (train.length > 0) { 9 | 10 | } 11 | @if (successSave) { 12 | 13 | } 14 |
15 |

{{page.title}}

16 |
17 | @if (successSave) { 18 | successfully saved 19 | } 20 | 21 | 22 | 23 |
24 |
25 |
26 |
27 | 28 | 29 | 30 |

Phase

31 | @for (i of [0,1,2,3,4,5,6]; track i) { 32 | 33 | 34 | 35 | 36 | } 37 |

Section

38 | @for (sec of sections; track sec) { 39 | 40 | 41 | 42 | 43 | } 44 |
45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | @if (toAdd) { 58 | 59 | 60 | 61 | 62 | 63 | 67 | 68 | } 69 | @for (entry of selected; track entry) { 70 | 71 | 72 | 73 | 74 | 78 | 90 | 91 | } 92 | 93 |
GermanEnglishSectionPhase
64 | 65 | 66 |
75 | {{entry.g2ePhase}} →
76 | {{entry.e2gPhase}} ← 77 |
79 | 80 | 83 | 86 | 89 |
94 |
95 | 96 | 97 | @for (page of pages; track page) { 98 | 99 | } 100 | 101 |
{{page}}
102 |
103 |
104 |
105 |
106 | } 107 | 108 | -------------------------------------------------------------------------------- /src/app/components/vocabulary-list/vocabulary-list.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostListener, OnInit, ViewChild, ViewChildren } from "@angular/core"; 2 | import { Page } from "src/app/models/page"; 3 | import { BackendService } from "src/app/services/backend.service"; 4 | import { AlertErrorComponent } from "../alert-error/alert-error.component"; 5 | import { ClarityModule } from "@clr/angular"; 6 | import { CommonModule } from "@angular/common"; 7 | import { FormsModule } from "@angular/forms"; 8 | import { ActivatedRoute, RouterModule } from "@angular/router"; 9 | import { switchMap, map, tap } from 'rxjs/operators'; 10 | import { VocabularyEntry } from "src/app/models/vocabulary-entry"; 11 | import { timer } from "rxjs"; 12 | import { VocabularyCard } from "src/app/models/vocabulary-card"; 13 | import { VocabularyExerciseComponent } from "../vocabulary-exercise/vocabulary-exercise.component"; 14 | import { VocabularyExerciseResult } from "src/app/models/vocabulary-exercise-result"; 15 | import { VocabularyExerciseResultComponent } from "../vocabulary-exercise-result/vocabulary-exercise-result.component"; 16 | import { AlertStickyComponent } from "../alert-sticky/alert-sticky.component"; 17 | 18 | @Component({ 19 | selector: 'app-vocabulary-list', 20 | imports: [ 21 | AlertErrorComponent, 22 | ClarityModule, 23 | CommonModule, 24 | FormsModule, 25 | RouterModule, 26 | VocabularyExerciseComponent, 27 | VocabularyExerciseResultComponent, 28 | AlertStickyComponent 29 | ], 30 | templateUrl: './vocabulary-list.component.html', 31 | styleUrls: ['./vocabulary-list.component.css'] 32 | }) 33 | export class VocabularyListComponent implements OnInit { 34 | 35 | page: Page | null = null; 36 | error: any = null; 37 | loading: boolean = false; 38 | vocabulary: VocabularyEntry[] = []; 39 | selected: VocabularyEntry[] = []; 40 | selectedCount = 0; 41 | 42 | q: string = ""; 43 | phase: number[] = []; 44 | section: string[] = []; 45 | currentPage: number = 1; 46 | itemsPerPage = 100; 47 | 48 | toAdd: VocabularyEntry | null = null; 49 | 50 | successSave: boolean = false; 51 | 52 | train: VocabularyCard[] = []; 53 | 54 | exerciseResult: VocabularyExerciseResult | null = null; 55 | 56 | get sections(): string[] { 57 | return structuredClone(this.vocabulary) 58 | .map(v => v.section) 59 | .filter((value, index, array) => array.indexOf(value) === index) 60 | .reverse(); 61 | } 62 | 63 | get pages(): number[] { 64 | const length = this.selectedCount / this.itemsPerPage; 65 | return Array.from({length: length}, (_, i) => i + 1); 66 | } 67 | 68 | @ViewChildren('addGermanInput') addGermanInput: any; 69 | 70 | constructor(private backendService: BackendService, 71 | private route: ActivatedRoute) {} 72 | 73 | ngOnInit(): void { 74 | this.route.params.pipe( 75 | map(params => params['id']), 76 | tap(_ => {this.page = null; this.loading = true; }), 77 | switchMap(id => this.backendService.getPage(id)), 78 | tap(_ => this.loading = false) 79 | ).subscribe({ 80 | next: page => { 81 | this.page = page; 82 | this.vocabulary = VocabularyEntry.parseVocabulary(this.page!!.content).reverse(); 83 | this.refresh(); 84 | }, 85 | error: error => { this.error = error; this.loading = false; } 86 | }); 87 | } 88 | 89 | @HostListener('document:keydown.control.s', ['$event']) 90 | onCtrlSKey(event: KeyboardEvent): void { 91 | this.save(); 92 | event.preventDefault(); 93 | } 94 | 95 | add() { 96 | this.clearFilters(); 97 | this.toAdd = new VocabularyEntry(); 98 | if (this.vocabulary.length>0) { 99 | this.toAdd.section = this.vocabulary[0].section; 100 | } 101 | setTimeout(() => this.addGermanInput.last.nativeElement.focus(), 400); 102 | } 103 | 104 | onAddKeypress(event: KeyboardEvent): void { 105 | if (event.key === 'Enter') { 106 | this.addSave(); 107 | } 108 | } 109 | 110 | addSave() { 111 | this.vocabulary.unshift(this.toAdd!); 112 | this.add(); 113 | this.refresh(); 114 | this.save(); 115 | } 116 | 117 | 118 | clearFilters() { 119 | this.q = ""; 120 | this.phase = []; 121 | this.section = []; 122 | this.currentPage = 1; 123 | this.refresh(); 124 | } 125 | 126 | filterSearch() { 127 | this.toAdd = null; 128 | this.currentPage = 1; 129 | this.refresh(); 130 | } 131 | 132 | filterPhase(phase: number) { 133 | this.toAdd = null; 134 | if(this.phase.includes(phase)) { 135 | this.phase = this.phase.filter(item => item != phase); 136 | } else { 137 | this.phase.push(phase); 138 | } 139 | this.currentPage = 1; 140 | this.refresh(); 141 | } 142 | 143 | filterSection(section: string) { 144 | this.toAdd = null; 145 | if(this.section.includes(section)) { 146 | this.section = this.section.filter(item => item != section); 147 | } else { 148 | this.section.push(section); 149 | } 150 | this.currentPage = 1; 151 | this.refresh(); 152 | } 153 | 154 | selectPage(page: number) { 155 | this.toAdd = null; 156 | this.currentPage = page; 157 | this.refresh(); 158 | } 159 | 160 | refresh() { 161 | const startIndex = (this.currentPage-1)*this.itemsPerPage; 162 | const endIndex = startIndex + this.itemsPerPage; 163 | this.selected = this.vocabulary.filter(item => { 164 | const qMatch = this.q.length > 3 ? item.german.toLowerCase().includes(this.q.toLowerCase()) || item.english.toLowerCase().includes(this.q.toLowerCase()) : true; 165 | const phaseMatch = this.phase.length > 0 ? this.phase.includes(item.e2gPhase) || this.phase.includes(item.g2ePhase) : true; 166 | const sectionMatch = this.section.length > 0 ? this.section.includes(item.section) : true; 167 | return qMatch && phaseMatch && sectionMatch; 168 | }) 169 | this.selectedCount = this.selected.length; 170 | this.selected = this.selected.slice(startIndex, endIndex); 171 | } 172 | 173 | 174 | exercise() { 175 | const train: VocabularyCard[] = []; 176 | this.selected.forEach(vocabulary => { 177 | train.push(new VocabularyCard(vocabulary, false)); 178 | train.push(new VocabularyCard(vocabulary, true)); 179 | }); 180 | if (train.length == 0) { 181 | return; 182 | } 183 | this.train = train; 184 | } 185 | 186 | save() { 187 | this.page!!.content = JSON.stringify(structuredClone(this.vocabulary).reverse(), null, 4); 188 | this.backendService.savePage(this.page!!).subscribe({ 189 | next: () => { 190 | this.successSave = true; 191 | timer(3000).subscribe(() => this.successSave = false); 192 | }, 193 | error: error => { this.error = error; } 194 | }); 195 | } 196 | 197 | delete(entry: VocabularyEntry) { 198 | const index = this.vocabulary.indexOf(entry); 199 | this.vocabulary.splice(index, 1); 200 | this.refresh(); 201 | } 202 | 203 | reset(entry: VocabularyEntry) { 204 | entry.e2gNext = null; 205 | entry.e2gPhase = 0; 206 | entry.g2eNext = null; 207 | entry.g2ePhase = 0; 208 | } 209 | 210 | playUrl(word: String): string { 211 | const base = document.querySelector('base')!!.getAttribute('href'); 212 | return `${base}api/api/text2speech?text=${word}&language=${this.page?.language}`; 213 | } 214 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary/vocabulary.component.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | margin-top:0; 3 | } 4 | 5 | h2 { 6 | margin:0; 7 | margin-bottom: 0.4rem; 8 | font-size:1.35rem; 9 | } 10 | 11 | .vocabularies { 12 | display:flex; 13 | flex-wrap: wrap; 14 | } 15 | 16 | .vocabulary { 17 | border: 1px solid #cbcbcb; 18 | background: #dddddd; 19 | padding:1rem; 20 | margin-right: 1rem; 21 | margin-top: 1rem; 22 | width:24rem; 23 | } 24 | 25 | .vocabulary .content { 26 | display: flex; 27 | } 28 | 29 | .vocabulary img { 30 | max-height: 11rem; 31 | margin-right: 1rem; 32 | max-width: 8rem; 33 | object-fit: cover; 34 | 35 | } 36 | 37 | .vocabulary span { 38 | margin-top: 0.5rem; 39 | font-weight: bold; 40 | } 41 | 42 | .vocabulary ul, 43 | .vocabulary li { 44 | list-style: none; 45 | } 46 | 47 | .vocabulary table { 48 | margin-top: 0.2rem; 49 | border-collapse: collapse; 50 | border-style: hidden; 51 | } 52 | 53 | .vocabulary th, 54 | .vocabulary td { 55 | width:1.8rem; 56 | text-align: center; 57 | border: 1px solid #d0d0d0; 58 | font-size:0.6rem; 59 | } 60 | 61 | .vocabulary .action { 62 | margin-top: 1rem; 63 | } 64 | 65 | .danger { 66 | color:#ff0000; 67 | } 68 | 69 | .add { 70 | clear:both; 71 | } 72 | 73 | .add button { 74 | margin-top:1rem; 75 | } 76 | 77 | .stats-table th, 78 | .stats-table td { 79 | border:0; 80 | width:1.3rem; 81 | margin-right:0.2rem; 82 | display:inline-block; 83 | } 84 | 85 | .stats-bg { 86 | position: relative; 87 | height:4rem; 88 | vertical-align: bottom; 89 | width:1rem; 90 | } 91 | 92 | .stats-label { 93 | z-index: 90; 94 | position:absolute; 95 | bottom:0; 96 | width: 100%; 97 | text-align: center; 98 | color: #173700; 99 | font-size: 0.4rem; 100 | } 101 | 102 | .stats-bar { 103 | z-index: 80; 104 | background-color: #3c8500; 105 | position:absolute; 106 | bottom:0; 107 | width: 100%; 108 | border-radius: 0.2rem; 109 | } 110 | 111 | clr-range-container, 112 | clr-range-container input { 113 | width: 25rem; 114 | } 115 | 116 | .vocabulary.disabled h2, 117 | .vocabulary.disabled li, 118 | .vocabulary.disabled .stats-label{ 119 | color: #bbbbbb; 120 | } 121 | 122 | .vocabulary.disabled .stats-bar { 123 | background-color: #cacaca; 124 | } 125 | 126 | .vocabulary.disabled img, 127 | .vocabulary.disabled button { 128 | opacity: 0.5; 129 | } -------------------------------------------------------------------------------- /src/app/components/vocabulary/vocabulary.component.html: -------------------------------------------------------------------------------- 1 |

Vocabulary

2 | @if (!page && !error) { 3 | Loading... 4 | } 5 | 6 | 7 |
8 | @for (page of pages; track page) { 9 |
10 | @if (!page.edit) { 11 |
12 | @if (page.icon) { 13 | 14 | } 15 |
16 |

{{page.title}}

17 |
    18 | @if (page.vocabularyCount) { 19 |
  • {{page.vocabularyCount}} vocabularies
  • 20 | } 21 | @if (page.updated) { 22 |
  • {{page.updated | date:'mediumDate'}} last updated
  • 23 | } 24 | @if (page.phases) { 25 |
  • {{finished(page)}}% finished
  • 26 | 27 |
  • 28 | 29 | 30 | 31 | @for (stats of page.phases; track stats) { 32 | 36 | } 37 | 38 | 39 | 40 | 41 | @for (stats of page.phases; track stats) { 42 | 43 | } 44 | 45 | 46 |
    33 |
    34 |
    {{stats.count}}
    35 |
    {{stats.phase}}
    47 |
  • 48 | } 49 |
50 |
51 |
52 | } 53 | @if (!page.edit && !page.phases) { 54 |
55 | 56 |
57 | } 58 | @if (!page.edit && page.phases) { 59 |
60 | @if (page.newVocabularyCount && page.newVocabularyCount > 0 && !page.disabled) { 61 | 62 | } 63 | @if (page.exerciseVocabularyCount && page.exerciseVocabularyCount > 0 && !page.disabled) { 64 | 65 | } 66 | 67 | 68 | 69 |
70 | } 71 | @if (page.exercise) { 72 | 73 | } 74 | @if (page.start) { 75 | 76 | } 77 | @if (page.edit) { 78 |
79 | 80 | 81 | 82 |
83 | } 84 |
85 | } 86 |
87 | 88 |
89 | 92 |
93 | 94 | @if (entryToDelete!==null) { 95 | 108 | } 109 | 110 | @if (start) { 111 | 130 | } 131 | 132 | -------------------------------------------------------------------------------- /src/app/components/vocabulary/vocabulary.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core"; 2 | import { switchMap } from "rxjs"; 3 | import { Page } from "src/app/models/page"; 4 | import { BackendService } from "src/app/services/backend.service"; 5 | import { AlertErrorComponent } from "../alert-error/alert-error.component"; 6 | import { ClarityModule } from "@clr/angular"; 7 | import { CommonModule } from "@angular/common"; 8 | import { FormsModule } from "@angular/forms"; 9 | import { VocabularyEditComponent } from "../vocabulary-edit/vocabulary-edit.component"; 10 | import { RouterModule } from "@angular/router"; 11 | import { VocabularyEntry } from "src/app/models/vocabulary-entry"; 12 | import { VocabularyExerciseComponent } from "../vocabulary-exercise/vocabulary-exercise.component"; 13 | import { VocabularyCard } from "src/app/models/vocabulary-card"; 14 | import { DateTime } from "luxon"; 15 | import { VocabularyExerciseResult } from "src/app/models/vocabulary-exercise-result"; 16 | import { VocabularyExerciseResultComponent } from "../vocabulary-exercise-result/vocabulary-exercise-result.component"; 17 | 18 | @Component({ 19 | selector: 'app-vocabulary', 20 | imports: [ 21 | AlertErrorComponent, 22 | ClarityModule, 23 | CommonModule, 24 | FormsModule, 25 | VocabularyEditComponent, 26 | RouterModule, 27 | VocabularyExerciseComponent, 28 | VocabularyExerciseResultComponent 29 | ], 30 | templateUrl: './vocabulary.component.html', 31 | styleUrls: ['./vocabulary.component.css'] 32 | }) 33 | export class VocabularyComponent implements OnInit { 34 | 35 | page: Page | null = null; 36 | pages: PageEditable[] = []; 37 | error: any = null; 38 | entryToDelete: Page | null = null; 39 | start: PageEditable | null = null; 40 | startAmount: number = 10; 41 | 42 | exerciseResult: VocabularyExerciseResult | null = null; 43 | 44 | constructor(private backendService: BackendService) {} 45 | 46 | ngOnInit(): void { 47 | this.backendService.getVocabularyPage().subscribe({ 48 | next: page => this.page = page, 49 | error: error => { 50 | if (error && error.status === 404) { 51 | this.createVocabularyParentPage() 52 | } else { 53 | this.error = error; 54 | } 55 | } 56 | }); 57 | 58 | this.backendService.getAllVocabularyPages().subscribe({ 59 | next: pages => { 60 | const sortedPages = pages.map(page => this.toPageEditable(page, false)) 61 | .sort((a,b) => (b.updated ? b.updated.getTime() : 0) - (a.updated ? a.updated.getTime() : 0)); 62 | 63 | this.pages = sortedPages.filter(p => !p.disabled).concat(sortedPages.filter(p => p.disabled)) 64 | this.pages.forEach(page => this.loadFullPage(page)); 65 | }, 66 | error: error => this.error = error 67 | }); 68 | } 69 | 70 | delete(page: Page) { 71 | if (page.id == null) { 72 | this.pages = this.pages.filter(page => page != page) 73 | } else { 74 | this.backendService.deletePage(page).subscribe({ 75 | next: () => this.pages = this.pages.filter(p => p.id != page.id), 76 | error: error => this.error = error 77 | }); 78 | } 79 | } 80 | 81 | add() { 82 | const page = new Page(); 83 | page.parent = this.page; 84 | this.pages.push(this.toPageEditable(page, true)); 85 | } 86 | 87 | save(page: PageEditable) { 88 | this.backendService.savePage(page).subscribe({ 89 | next: saved => this.pages[this.pages.indexOf(page)] = this.toPageEditable(saved, false), 90 | error: error => this.error = error 91 | }); 92 | } 93 | 94 | showNewDialog(page: PageEditable) { 95 | this.startAmount = page.newVocabularyCount && page.newVocabularyCount < 10 ? page.newVocabularyCount : 10; 96 | this.start = page; 97 | } 98 | 99 | startNew() { 100 | if (this.start) { 101 | const vocabulary = VocabularyEntry.parseVocabulary(this.start.content); 102 | this.start.startVocabulary = this.getNewVocabularyCards(vocabulary, this.startAmount); 103 | this.start.start = true; 104 | this.start = null; 105 | } 106 | } 107 | 108 | startExercise (page: PageEditable) { 109 | const vocabulary = VocabularyEntry.parseVocabulary(page.content); 110 | page.exerciseVocabulary = this.getExerciseVocabularyCards(vocabulary); 111 | page.exercise = true; 112 | } 113 | 114 | loadFullPage(page: PageEditable) { 115 | this.backendService.getPage(page.id!!).subscribe({ 116 | next: loadedPage => { 117 | page.content = loadedPage.content; 118 | const vocabulary = VocabularyEntry.parseVocabulary(loadedPage.content); 119 | page.vocabularyCount = vocabulary.length; 120 | page.phases = []; 121 | for(let i=0; i <= 6; i++) { 122 | const count = this.countPhases(vocabulary, i); 123 | page.phases.push({ 124 | phase: i, 125 | count: count, 126 | percent: Math.round((count/((vocabulary.length)*2))*100) 127 | }); 128 | } 129 | page.newVocabularyCount = this.countNew(vocabulary); 130 | page.exerciseVocabularyCount = this.getExerciseVocabularyCards(vocabulary).length; 131 | }, 132 | error: error => this.error = error 133 | }) 134 | } 135 | 136 | finished(page: PageEditable): number { 137 | return page.phases && page.phases[6] && !isNaN(page.phases[6].percent) ? page.phases[6].percent : 0; 138 | } 139 | 140 | private countPhases(vocabulary: VocabularyEntry[], phase: number): number { 141 | return vocabulary.filter(v => v.e2gPhase == phase).length + vocabulary.filter(v => v.g2ePhase == phase).length; 142 | } 143 | 144 | private countNew(vocabulary: VocabularyEntry[]): number { 145 | return vocabulary.filter(v => v.e2gPhase == 0) 146 | .concat(vocabulary.filter(v => v.g2ePhase == 0)) 147 | .filter((value, index, array) => array.indexOf(value) === index) 148 | .length; 149 | } 150 | 151 | private getNewVocabularyCards(vocabulary: VocabularyEntry[], amount: number): VocabularyCard[] { 152 | const e2g = vocabulary 153 | .filter(v => v.e2gPhase == 0) 154 | .map(v => new VocabularyCard(v, false)) 155 | .slice(0, amount); 156 | const g2e = vocabulary 157 | .filter(v => v.g2ePhase == 0) 158 | .map(v => new VocabularyCard(v, true)) 159 | .slice(0, amount); 160 | return e2g.concat(g2e); 161 | } 162 | 163 | private getExerciseVocabularyCards(vocabulary: VocabularyEntry[]): VocabularyCard[] { 164 | const e2g = vocabulary 165 | .filter(v => v.e2gPhase > 0 && v.e2gNext && DateTime.now() > DateTime.fromISO(v.e2gNext)) 166 | .map(v => new VocabularyCard(v, false)); 167 | const g2e = vocabulary 168 | .filter(v => v.g2ePhase > 0 && v.g2eNext && DateTime.now() > DateTime.fromISO(v.g2eNext)) 169 | .map(v => new VocabularyCard(v, true)); 170 | return e2g.concat(g2e); 171 | } 172 | 173 | private createVocabularyParentPage() { 174 | this.backendService.createVocabularyParentPage() 175 | .pipe( 176 | switchMap(() => this.backendService.getVocabularyPage()) 177 | ).subscribe({ 178 | next: page => this.page = page, 179 | error: error => this.error = error 180 | }); 181 | } 182 | 183 | private toPageEditable(page: Page, edit: boolean): PageEditable { 184 | return { 185 | ...page, 186 | edit: edit, 187 | vocabularyCount: null, 188 | phases: null, 189 | newVocabularyCount: null, 190 | exerciseVocabularyCount: null, 191 | exercise: false, 192 | exerciseVocabulary: [], 193 | start: false, 194 | startVocabulary: [] 195 | } 196 | } 197 | 198 | } 199 | 200 | interface PageEditable extends Page { 201 | edit: boolean; 202 | vocabularyCount: number | null; 203 | phases: PhaseStats[] | null; 204 | newVocabularyCount: number | null; 205 | exerciseVocabularyCount: number | null; 206 | exercise: boolean; 207 | exerciseVocabulary: VocabularyCard[]; 208 | start: boolean; 209 | startVocabulary: VocabularyCard[]; 210 | } 211 | 212 | interface PhaseStats { 213 | phase: number; 214 | count: number; 215 | percent: number; 216 | } -------------------------------------------------------------------------------- /src/app/models/page.ts: -------------------------------------------------------------------------------- 1 | import { PageDto } from "../components/dtos/page-dto"; 2 | 3 | export class Page { 4 | id: string | null = null; 5 | title: string = ""; 6 | icon: string = ""; 7 | content: string = ""; 8 | language: string = ""; 9 | disabled: boolean = false; 10 | expanded: boolean = false; 11 | updated: Date | null = null; 12 | 13 | parent: Page | null = null; 14 | children: Page[] = []; 15 | 16 | constructor(pageDto: PageDto | null = null) { 17 | if (pageDto) { 18 | this.id = pageDto.id; 19 | this.title = pageDto.title; 20 | this.icon = pageDto.icon; 21 | this.content = pageDto.content; 22 | this.language = pageDto.language; 23 | this.disabled = pageDto.disabled; 24 | this.expanded = pageDto.expanded; 25 | this.parent = null; 26 | this.updated = new Date((pageDto.updated ??= 0) * 1000); 27 | this.children = []; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/models/password-entry.ts: -------------------------------------------------------------------------------- 1 | export class PasswordEntry { 2 | service: string = ""; 3 | username: string = ""; 4 | password: string = ""; 5 | 6 | passwordShow: boolean = false; 7 | edit: boolean = false; 8 | prev: PasswordEntry | null = null; 9 | 10 | static fromOther(other: PasswordEntry): PasswordEntry { 11 | const passwordEntry = new PasswordEntry(); 12 | passwordEntry.service = other.service; 13 | passwordEntry.username = other.username; 14 | passwordEntry.password = other.password; 15 | return passwordEntry; 16 | } 17 | 18 | static fromData(service: string, username: string, password: string): PasswordEntry { 19 | const passwordEntry = new PasswordEntry(); 20 | passwordEntry.service = service; 21 | passwordEntry.username = username; 22 | passwordEntry.password = password; 23 | return passwordEntry; 24 | } 25 | 26 | withEditTrue(): PasswordEntry { 27 | this.edit = true; 28 | return this; 29 | } 30 | } -------------------------------------------------------------------------------- /src/app/models/vocabulary-card.ts: -------------------------------------------------------------------------------- 1 | import { VocabularyEntry } from "./vocabulary-entry"; 2 | 3 | export class VocabularyCard { 4 | constructor( 5 | public vocabulary: VocabularyEntry, 6 | public g2e: boolean 7 | ) {} 8 | } -------------------------------------------------------------------------------- /src/app/models/vocabulary-entry.ts: -------------------------------------------------------------------------------- 1 | export class VocabularyEntry { 2 | german: string = ""; 3 | english: string = ""; 4 | section: string = ""; 5 | e2gPhase: number = 0; 6 | e2gNext: Date | null = null; 7 | g2ePhase: number = 0; 8 | g2eNext: Date | null = null; 9 | 10 | constructor(other: VocabularyEntry | null = null) { 11 | if (other) { 12 | this.german = other.german; 13 | this.english = other.english; 14 | this.section = other.section; 15 | this.e2gPhase = other.e2gPhase; 16 | this.e2gNext = other.e2gNext; 17 | this.g2ePhase = other.g2ePhase; 18 | this.g2eNext = other.g2eNext; 19 | } 20 | } 21 | 22 | static equals(one: VocabularyEntry, other: VocabularyEntry): boolean { 23 | return other.german == one.german && other.english == one.english && other.section == one.section; 24 | } 25 | 26 | static parseVocabulary(json: string): VocabularyEntry[] { 27 | try { 28 | return JSON.parse(json); 29 | } catch(error) { 30 | console.error(error); 31 | return []; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /src/app/models/vocabulary-exercise-result.ts: -------------------------------------------------------------------------------- 1 | export class VocabularyExerciseResult { 2 | constructor( 3 | public percent: number, 4 | public correct: number, 5 | public wrong: number 6 | ) { } 7 | } -------------------------------------------------------------------------------- /src/app/pipes/bookmarks.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | import jquery from 'jquery'; 4 | import { Observable, of } from 'rxjs'; 5 | import { BackendService } from './../services/backend.service'; 6 | import { map, switchMap, catchError } from 'rxjs/operators'; 7 | import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; 8 | 9 | @Pipe({ 10 | name: 'bookmarks', 11 | standalone: true 12 | }) 13 | export class BookmakrsPipe implements PipeTransform { 14 | 15 | constructor(private backendService: BackendService, 16 | private sanitizer: DomSanitizer) { 17 | 18 | } 19 | 20 | transform(value: string, args?: any): Observable { 21 | if (!value) { 22 | return of(value); 23 | } 24 | 25 | let regex = /(?:\[bookmarks=)([^\]]+)]/g; 26 | let result; 27 | 28 | let observable = null; 29 | 30 | do { 31 | result = regex.exec(value); 32 | 33 | if (!result) { 34 | break; 35 | } 36 | 37 | let file = result[1]; 38 | 39 | let observableFile = this.backendService.getFileAsString(file).pipe( 40 | map(content => this.bookmarksToHtml(content)), 41 | catchError(() => of('bookmarks file not found')), 42 | map(content => { value = value.replace(`[bookmarks=${file}]`, content); return value } ) 43 | ); 44 | 45 | if (observable == null) { 46 | observable = observableFile; 47 | } else { 48 | observable = observable.pipe( 49 | switchMap(() => observableFile) 50 | ); 51 | } 52 | } while (result); 53 | 54 | observable = observable ? observable : of(value); 55 | return observable.pipe(map((content: string) => this.sanitizer.bypassSecurityTrustHtml(content))); 56 | } 57 | 58 | private bookmarksToHtml(bookmarkshtml: string): string { 59 | bookmarkshtml = bookmarkshtml.replace(new RegExp("

", 'g'), ""); 60 | let categories = { 61 | category: '', 62 | entries: [] 63 | } as any; 64 | 65 | for (let a of jquery(bookmarkshtml).find('a')) { 66 | let e = jquery(a); 67 | let headings = []; 68 | let parent = e.parent().parent().prev(); 69 | while(parent.length > 0 && jquery(parent).prop("tagName") == 'H3') { 70 | let h = jquery(parent).text(); 71 | if (h.length > 0) { 72 | headings.push(h); 73 | } 74 | parent = jquery(parent).parent().parent().prev(); 75 | } 76 | 77 | let c = categories; 78 | for (let heading of headings.reverse()) { 79 | let folder: any = c.entries.find((item: any) => item.category == heading); 80 | if (!folder) { 81 | folder = { category: heading, entries: [] }; 82 | c.entries.push(folder) 83 | } 84 | c = folder; 85 | } 86 | 87 | c.entries.push({ 88 | title: e.text(), 89 | icon: e.attr('icon'), 90 | href: e.attr('href') 91 | }); 92 | } 93 | 94 | return '

' + this.categoryToHtml(categories) + '
'; 95 | } 96 | 97 | private categoryToHtml(category: any, level = 1) { 98 | let caption = `${category.category}`; 99 | return (category.category ? caption : '') + '
    ' + 100 | category.entries.map((e: any) => { 101 | if (e.href) { 102 | return `
  • ${e.title}
  • `; 103 | } else if (e.category) { 104 | return `
  • ${this.categoryToHtml(e, level+1)}
  • `; 105 | } else { 106 | return ""; 107 | } 108 | }).join("\n") + '
'; 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /src/app/pipes/file-size.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'fileSize', 5 | standalone: true 6 | }) 7 | export class FileSizePipe implements PipeTransform { 8 | 9 | private units = [ 10 | 'bytes', 11 | 'KB', 12 | 'MB', 13 | 'GB', 14 | 'TB', 15 | 'PB' 16 | ]; 17 | 18 | transform(bytes: number = 0, precision: number = 2 ): string { 19 | if ( isNaN( parseFloat( String(bytes) )) || ! isFinite( bytes ) ) { 20 | return '?' 21 | } 22 | 23 | let unit = 0; 24 | 25 | while ( bytes >= 1024 ) { 26 | bytes /= 1024; 27 | unit ++; 28 | } 29 | 30 | return bytes.toFixed( + precision ) + ' ' + this.units[ unit ]; 31 | } 32 | 33 | } -------------------------------------------------------------------------------- /src/app/pipes/highlight.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from '@angular/core'; 2 | import { DomSanitizer } from '@angular/platform-browser'; 3 | 4 | @Pipe({ 5 | name: 'highlight', 6 | standalone: true 7 | }) 8 | export class HighlightPipe implements PipeTransform { 9 | constructor(private sanitizer: DomSanitizer){} 10 | 11 | transform(value: any, args: any): any { 12 | if (!args) { 13 | return value; 14 | } 15 | // Match in a case insensitive maneer 16 | const re = new RegExp(args, 'gi'); 17 | const match = value.match(re); 18 | 19 | // If there's no match, just return the original value. 20 | if (!match) { 21 | return value; 22 | } 23 | 24 | const replacedValue = value.replace(re, "" + match[0] + "") 25 | return this.sanitizer.bypassSecurityTrustHtml(replacedValue) 26 | } 27 | } -------------------------------------------------------------------------------- /src/app/pipes/markdown.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | import * as showdown from 'showdown/dist/showdown.min.js'; 4 | import highlightjs from 'highlight.js'; 5 | 6 | @Pipe({ 7 | name: 'markdown', 8 | standalone: true 9 | }) 10 | export class MarkdownPipe implements PipeTransform { 11 | 12 | converter: any = null; 13 | 14 | transform(value: string, args?: any): any { 15 | if (!value) { 16 | return value; 17 | } 18 | 19 | if (!this.converter) { 20 | 21 | showdown.extension('highlightjs', function () { 22 | function htmlunencode(text: any) { 23 | return (text 24 | .replace(/&/g, '&') 25 | .replace(/</g, '<') 26 | .replace(/>/g, '>')); 27 | } 28 | // use new shodown's regexp engine to conditionally parse codeblocks 29 | const left = '
]*>';
 30 |                 const right = '
'; 31 | const flags = 'g'; 32 | function replacement(wholeMatch: any, match: any, left: any, right: any) { 33 | // unescape match to prevent double escaping 34 | match = htmlunencode(match); 35 | return left + highlightjs.highlightAuto(match).value + right; 36 | } 37 | return [ 38 | { 39 | type: 'output', 40 | filter: function (text: any, converter: any, options: any) { 41 | return showdown.helper.replaceRecursiveRegExp(text, replacement, left, right, flags); 42 | } 43 | } 44 | ]; 45 | }); 46 | 47 | // taken from https://github.com/JanLoebel/showdown-toc/blob/master/src/showdown-toc.js 48 | showdown.extension('toc', function () { 49 | function getHeaderEntries(sourceHtml: any) { 50 | // Generate dummy element 51 | var source = document.createElement('div'); 52 | source.innerHTML = sourceHtml; 53 | 54 | // Find headers 55 | var headers = source.querySelectorAll('h1, h2, h3, h4, h5, h6'); 56 | var headerList: any[] = []; 57 | for (var i = 0; i < headers.length; i++) { 58 | var el = headers[i]; 59 | headerList.push(new TocEntry(el.tagName, el.textContent, el.id)); 60 | } 61 | 62 | return headerList; 63 | } 64 | 65 | class TocEntry { 66 | public tagName: any; 67 | public text: any; 68 | public anchor: any; 69 | public children: any[] = []; 70 | 71 | constructor(tagName: any, text: any, anchor: any) { 72 | this.tagName = tagName; 73 | this.text = text; 74 | this.anchor = anchor; 75 | this.children = []; 76 | } 77 | 78 | childrenToString() { 79 | if (this.children.length === 0) { 80 | return ""; 81 | } 82 | var result = "
    \n"; 83 | for (var i = 0; i < this.children.length; i++) { 84 | result += this.children[i].toString(); 85 | } 86 | result += "
\n"; 87 | return result; 88 | }; 89 | 90 | toString() { 91 | var result = "
  • "; 92 | if (this.text) { 93 | result += "" + this.text + ""; 94 | } 95 | result += this.childrenToString(); 96 | result += "
  • \n"; 97 | return result; 98 | }; 99 | } 100 | 101 | 102 | 103 | function sortHeader(tocEntries: any, level: any) { 104 | level = level || 1; 105 | var tagName = "H" + level, 106 | result: any = [], 107 | currentTocEntry; 108 | 109 | function push(tocEntry: any) { 110 | if (tocEntry !== undefined) { 111 | if (tocEntry.children.length > 0) { 112 | tocEntry.children = sortHeader(tocEntry.children, level + 1); 113 | } 114 | result.push(tocEntry); 115 | } 116 | } 117 | 118 | for (var i = 0; i < tocEntries.length; i++) { 119 | var tocEntry = tocEntries[i]; 120 | if (tocEntry.tagName.toUpperCase() !== tagName) { 121 | if (currentTocEntry === undefined) { 122 | currentTocEntry = new TocEntry(undefined, undefined, undefined); 123 | } 124 | currentTocEntry.children.push(tocEntry); 125 | } else { 126 | push(currentTocEntry); 127 | currentTocEntry = tocEntry; 128 | } 129 | } 130 | 131 | push(currentTocEntry); 132 | return result; 133 | } 134 | 135 | return { 136 | type: 'output', 137 | filter: (sourceHtml: any) => { 138 | var headerList = getHeaderEntries(sourceHtml); 139 | 140 | // No header found 141 | if (headerList.length === 0) { 142 | return sourceHtml; 143 | } 144 | 145 | // Sort header 146 | headerList = sortHeader(headerList, undefined); 147 | 148 | // Build result and replace all [toc] 149 | var result = '

    table of contents

    \n
      \n' + headerList.join("") + '
    \n
    \n'; 150 | return sourceHtml.replace(/\[toc\]/gi, result); 151 | } 152 | }; 153 | }); 154 | 155 | 156 | this.converter = new showdown.Converter({ 157 | simpleLineBreaks: true, 158 | tables: true, 159 | strikethrough: true, 160 | simplifiedAutoLink: true, 161 | extensions: ['highlightjs', 'toc'] 162 | }); 163 | } 164 | 165 | return this.converter.makeHtml(value); 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/app/pipes/nl2br.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'nl2br', 5 | standalone: true 6 | }) 7 | export class Nl2BrPipe implements PipeTransform { 8 | 9 | transform(value: string): string { 10 | if (typeof value !== 'string') { 11 | return value; 12 | } 13 | return value.replace(/(?:\r\n|\r|\n)/g, '
    '); 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/app/pipes/searchresult.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from '@angular/core'; 2 | 3 | const EXTRACT_SIZE = 2; 4 | 5 | @Pipe({ 6 | name: 'searchResult', 7 | standalone: true 8 | }) 9 | export class SearchResultPipe implements PipeTransform { 10 | 11 | transform(value: string, q: string): string { 12 | if (typeof value !== 'string') { 13 | return value; 14 | } 15 | const lines = value.split(/(?:\r\n|\r|\n)/g); 16 | let persist = []; 17 | for (let i=0;i0 && n a.indexOf(v) === i) // unique 28 | .sort((a, b) => a - b); 29 | 30 | const extracts: number[][] = []; 31 | let current: number[] = []; 32 | for (let i=0;i 0 && persist[0] != 0 ? "...\n" : "") + extracts 42 | .filter(c => c.length>0) 43 | .map(c => c.map(i => lines[i]).join("\n")) 44 | .join("\n...\n") 45 | .trim() + (persist.length > 0 && persist[persist.length-1] < lines.length-1 ? "\n..." : ""); 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /src/app/services/aes.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import * as aesjs from 'aes-js/index.js'; 3 | import jsSHA from 'jssha'; 4 | import * as CryptoJS from 'crypto-js/crypto-js.js'; 5 | 6 | @Injectable() 7 | export class AesService { 8 | 9 | static readonly SALT: string = 'alijlkjfuhewqlfijhgiwqkb'; 10 | 11 | encrypt(text: string, password: string): string { 12 | return this.cryptojsEncrypt(this.aesjsEncrypt(text, password), password); 13 | } 14 | 15 | decrypt(secret: string, password: string): string { 16 | return this.aesjsDecrypt(this.cryptojsDecrypt(secret, password), password); 17 | } 18 | 19 | cryptojsEncrypt(text: string, password: string): string { 20 | if (!text) { 21 | return ""; 22 | } 23 | return CryptoJS.AES.encrypt(text, password).toString(); 24 | } 25 | 26 | cryptojsDecrypt(secret: string, password: string): string { 27 | if (!secret) { 28 | return ""; 29 | } 30 | var bytes = CryptoJS.AES.decrypt(secret, password); 31 | return bytes.toString(CryptoJS.enc.Utf8); 32 | } 33 | 34 | aesjsEncrypt(text: string, password: string): string { 35 | let key = this.key(password); 36 | let textBytes = aesjs.utils.utf8.toBytes(text); 37 | let aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(5)); 38 | let encryptedBytes = aesCtr.encrypt(textBytes); 39 | return aesjs.utils.hex.fromBytes(encryptedBytes); 40 | } 41 | 42 | aesjsDecrypt(secret: string, password: string): string { 43 | let key = this.key(password); 44 | let encryptedBytes = aesjs.utils.hex.toBytes(secret); 45 | let aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(5)); 46 | let decryptedBytes = aesCtr.decrypt(encryptedBytes); 47 | return aesjs.utils.utf8.fromBytes(decryptedBytes); 48 | } 49 | 50 | key(password: string): Uint8Array { 51 | let sha512 = new jsSHA("SHA-512", "TEXT"); 52 | sha512.update(password + AesService.SALT); 53 | return new Uint8Array(sha512.getHash("ARRAYBUFFER")).slice(0,32); 54 | } 55 | 56 | sha512(text: string): string { 57 | let sha512 = new jsSHA("SHA-512", "TEXT"); 58 | sha512.update(text + AesService.SALT); 59 | return sha512.getHash("HEX"); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/app/services/backend.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient, HttpParams } from '@angular/common/http'; 3 | import { Observable, BehaviorSubject, throwError, of } from 'rxjs'; 4 | import { Page } from './../models/page'; 5 | import { tap, switchMap, map } from 'rxjs/operators'; 6 | import { PageDto } from '../components/dtos/page-dto'; 7 | import { ToRenameDto } from '../components/dtos/to-rename-dto'; 8 | import { FileDto } from '../components/dtos/file-dto'; 9 | 10 | @Injectable() 11 | export class BackendService { 12 | 13 | static readonly PASSWORD_PAGE_ID: string = 'password_page_storage'; 14 | static readonly VOCABULARY_PAGE_ID: string = 'vocabulary_page_storage'; 15 | 16 | static readonly ID_SEPARATOR: string = '___'; 17 | static readonly BASE_URL: string = 'api/'; 18 | 19 | pages: Page[] = []; 20 | 21 | pagesChanged = new BehaviorSubject([]); 22 | 23 | constructor(private http: HttpClient) { 24 | } 25 | 26 | getAllPages(): Observable { 27 | return this.http.get(BackendService.BASE_URL + 'page') 28 | .pipe( 29 | map(pagedtos => pagedtos.sort((p1, p2) => p1.id!.localeCompare(p2.id!)).map(pagedto => new Page(pagedto))), 30 | map(pages => this.convertToNestedTree(pages)), 31 | tap(pages => this.pages = pages), 32 | tap(pages => this.pagesChanged.next(pages)) 33 | ); 34 | } 35 | 36 | getAllPagesFlatten(): Page[] { 37 | return this.flattenPages(this.filterSystemPages(this.pages)); 38 | } 39 | 40 | filterSystemPages(pages: Page[]): Page[] { 41 | return pages.filter(page => page.id !== BackendService.VOCABULARY_PAGE_ID && page.id !== BackendService.PASSWORD_PAGE_ID); 42 | } 43 | 44 | getPage(id: string): Observable { 45 | const observable = this.pages.length == 0 ? this.getAllPages() : of([]); 46 | return observable.pipe( 47 | switchMap(() => this.http.get(BackendService.BASE_URL + 'page/' + id)), 48 | map(pagedto => new Page(pagedto)), 49 | map(page => { 50 | const p = this.findPage(this.pages, page); 51 | p!.content = page.content; 52 | return p!; 53 | }) 54 | ); 55 | } 56 | 57 | savePage(page: Page): Observable { 58 | const newid = this.getPageId(page); 59 | if (page.id != null && newid != page.id) { 60 | return this.renamePage(this.renamePageAndChildrenPages(page)).pipe( 61 | switchMap(() => this.http.post(BackendService.BASE_URL + 'page', new PageDto(page))), 62 | switchMap(() => this.getAllPages()), 63 | map(() => page) 64 | ); 65 | } else { 66 | page.id = newid; 67 | return this.http.post(BackendService.BASE_URL + 'page', new PageDto(page)).pipe( 68 | switchMap(() => this.getAllPages()), 69 | map(() => page) 70 | ); 71 | } 72 | } 73 | 74 | deletePage(page: Page): Observable { 75 | if (page.children && page.children.length > 0) { 76 | return throwError(() => 'page has child pages, move or delete them'); 77 | } 78 | return this.http.delete(BackendService.BASE_URL + 'page/' + page.id) 79 | .pipe( 80 | switchMap(() => this.getAllPages()) 81 | ); 82 | } 83 | 84 | renamePage(toRenameDtos: ToRenameDto[]): Observable { 85 | return this.http.post(BackendService.BASE_URL + 'page/rename', toRenameDtos); 86 | } 87 | 88 | search(q: string): Observable { 89 | const params = new HttpParams() 90 | .set('q', q ? q : ''); 91 | return this.http.get(BackendService.BASE_URL + 'search', { params: params }) 92 | .pipe( 93 | map(pagedtos => pagedtos.filter(dto => dto.id !== BackendService.PASSWORD_PAGE_ID )), 94 | map(pagedtos => pagedtos.sort((p1, p2) => p1.id!.localeCompare(p2.id!)).map(pagedto => new Page(pagedto))) 95 | ); 96 | } 97 | 98 | 99 | 100 | 101 | getAllFiles(): Observable { 102 | return this.http.get(BackendService.BASE_URL + 'file'); 103 | } 104 | 105 | deleteFile(id: string): Observable { 106 | return this.http.delete(BackendService.BASE_URL + 'file/' + id); 107 | } 108 | 109 | saveFile(path: string, file: File): Observable { 110 | const formData = new FormData(); 111 | formData.append('file', file, path); 112 | return this.http.post(BackendService.BASE_URL + 'file', formData); 113 | } 114 | 115 | getFileAsString(file: string): Observable { 116 | return this.http.get(file, {responseType: 'text' as 'json'}); 117 | } 118 | 119 | 120 | 121 | 122 | getPasswordPage(): Observable { 123 | return this.http.get(BackendService.BASE_URL + 'page/' + BackendService.PASSWORD_PAGE_ID).pipe( 124 | map(pagedto => new Page(pagedto)) 125 | ); 126 | } 127 | 128 | savePasswordPage(page: Page): Observable { 129 | page.id = BackendService.PASSWORD_PAGE_ID 130 | page.title = BackendService.PASSWORD_PAGE_ID; 131 | return this.http.post(BackendService.BASE_URL + 'page', new PageDto(page)); 132 | } 133 | 134 | getVocabularyPage(): Observable { 135 | return this.http.get(BackendService.BASE_URL + 'page/' + BackendService.VOCABULARY_PAGE_ID).pipe( 136 | map(pagedto => new Page(pagedto)) 137 | ); 138 | } 139 | 140 | getAllVocabularyPages(): Observable { 141 | return this.http.get(BackendService.BASE_URL + 'page') 142 | .pipe( 143 | map(pagedtos => pagedtos.map(pagedto => new Page(pagedto))), 144 | map(pages => this.convertToNestedTree(pages)), 145 | map(pages => { 146 | const vocabularyPages = pages.filter(page => page.id === BackendService.VOCABULARY_PAGE_ID) 147 | return vocabularyPages.length > 0 ? vocabularyPages[0].children : []; 148 | }) 149 | ); 150 | } 151 | 152 | createVocabularyParentPage(): Observable { 153 | const page = new Page(); 154 | page.id = BackendService.VOCABULARY_PAGE_ID; 155 | page.title = BackendService.VOCABULARY_PAGE_ID; 156 | return this.http.post(BackendService.BASE_URL + 'page', new PageDto(page)); 157 | } 158 | 159 | 160 | 161 | 162 | private findPage(pages: Page[], page: Page): Page | null { 163 | if (!pages) { 164 | return null; 165 | } 166 | let found = pages.find(p => p.id == page.id); 167 | if (found) { 168 | return found; 169 | } 170 | for (let p of pages) { 171 | let found = this.findPage(p.children, page); 172 | if (found) { 173 | return found; 174 | } 175 | } 176 | return null; 177 | } 178 | 179 | private flattenPages(pages: Page[]): Page[] { 180 | let result: Page[] = []; 181 | for (let page of pages) { 182 | result.push(page); 183 | for (let child of page.children) { 184 | result.push(child); 185 | if (child.children && child.children.length > 0) { 186 | result = result.concat(this.flattenPages(child.children)); 187 | } 188 | } 189 | } 190 | return result; 191 | } 192 | 193 | private convertToNestedTree(pages: Page[]): Page[] { 194 | const root = []; 195 | for (const page of pages) { 196 | const ids = page.id!.split(BackendService.ID_SEPARATOR); 197 | if (ids.length == 1) { 198 | root.push(page); 199 | } else { 200 | const parentId = ids.slice(0, -1).join(BackendService.ID_SEPARATOR); 201 | const parent = pages.find(p => p.id == parentId); 202 | if (parent) { 203 | parent.children.push(page); 204 | page.parent = parent; 205 | } else { 206 | root.push(page); 207 | } 208 | } 209 | } 210 | return root; 211 | } 212 | 213 | private renamePageAndChildrenPages(page: Page): ToRenameDto[] { 214 | const oldid = page.id; 215 | page.id = this.getPageId(page); 216 | let toRenameDtos = [ new ToRenameDto(oldid!, page.id) ]; 217 | if (page.children) { 218 | for (const child of page.children) { 219 | toRenameDtos = toRenameDtos.concat(this.renamePageAndChildrenPages(child)); 220 | } 221 | } 222 | return toRenameDtos; 223 | } 224 | 225 | private getPageId(page: Page): string { 226 | const id = page.title!.toLowerCase().trim().replace(/[^a-z0-9_\.]/gi,''); 227 | if (page.parent != null) { 228 | return this.getPageId(page.parent) + BackendService.ID_SEPARATOR + id; 229 | } 230 | return id; 231 | } 232 | 233 | } 234 | -------------------------------------------------------------------------------- /src/app/services/icon.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ClarityIcons } from '@cds/core/icon'; 3 | 4 | @Injectable() 5 | export class IconService { 6 | icons = Object.keys(ClarityIcons.registry) 7 | } 8 | -------------------------------------------------------------------------------- /src/assets/bookmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/src/assets/bookmark.png -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/src/assets/logo.png -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SSilence/markdownnotes/f764448c44f4012514ad7c8750bc58c2d0436ee4/src/favicon.ico -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*'; -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MarkdownNotes 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode, importProvidersFrom } from '@angular/core'; 2 | import { environment } from './environments/environment'; 3 | 4 | import '@cds/core/icon/register.js'; 5 | import { ClarityIcons, unknownIcon, angleIcon, angleDoubleIcon, arrowIcon, barsIcon, bellIcon, calendarIcon, checkIcon, checkCircleIcon, cloudIcon, cogIcon, ellipsisHorizontalIcon, ellipsisVerticalIcon, errorStandardIcon, eventIcon, exclamationCircleIcon, exclamationTriangleIcon, eyeIcon, eyeHideIcon, filterGridIcon, filterGridCircleIcon, folderIcon, folderOpenIcon, helpInfoIcon, homeIcon, imageIcon, infoCircleIcon, infoStandardIcon, searchIcon, stepForward2Icon, successStandardIcon, timesIcon, unknownStatusIcon, userIcon, viewColumnsIcon, vmBugIcon, vmBugInverseIcon, warningStandardIcon, detailExpandIcon, detailCollapseIcon, accessibility1Icon, accessibility2Icon, announcementIcon, addTextIcon, alarmClockIcon, alarmOffIcon, asteriskIcon, banIcon, betaIcon, boltIcon, bookIcon, briefcaseIcon, bubbleExclamationIcon, bugIcon, bullseyeIcon, childArrowIcon, circleIcon, circleArrowIcon, clipboardIcon, clockIcon, cloneIcon, collapseCardIcon, colorPaletteIcon, colorPickerIcon, copyIcon, copyToClipboardIcon, crosshairsIcon, cursorArrowIcon, cursorHandIcon, cursorHandClickIcon, cursorHandGrabIcon, cursorHandOpenIcon, cursorMoveIcon, detailsIcon, dotCircleIcon, downloadIcon, dragHandleIcon, dragHandleCornerIcon, eraserIcon, expandCardIcon, fileIcon, fileGroupIcon, fileSettingsIcon, fileZipIcon, filterIcon, filter2Icon, filterOffIcon, firewallIcon, firstAidIcon, fishIcon, flameIcon, formIcon, fuelIcon, gridViewIcon, helpIcon, historyIcon, hourglassIcon, idBadgeIcon, keyIcon, landscapeIcon, launchpadIcon, libraryIcon, lightbulbIcon, listIcon, lockIcon, loginIcon, logoutIcon, minusIcon, minusCircleIcon, moonIcon, newIcon, noAccessIcon, noteIcon, objectsIcon, organizationIcon, paperclipIcon, pasteIcon, pencilIcon, pinIcon, pinboardIcon, plusIcon, plusCircleIcon, popOutIcon, portraitIcon, printerIcon, recycleIcon, redoIcon, refreshIcon, repeatIcon, resizeIcon, scissorsIcon, scrollIcon, shrinkIcon, sliderIcon, snowflakeIcon, sortByIcon, sunIcon, switchIcon, syncIcon, tableIcon, tagIcon, tagsIcon, targetIcon, thermometerIcon, timesCircleIcon, toolsIcon, trashIcon, treeIcon, treeViewIcon, twoWayArrowsIcon, undoIcon, unpinIcon, unlockIcon, uploadIcon, usersIcon, viewCardsIcon, viewListIcon, volumeIcon, wandIcon, windowCloseIcon, windowMaxIcon, windowMinIcon, windowRestoreIcon, worldIcon, wrenchIcon, zoomInIcon, zoomOutIcon, axisChartIcon, barChartIcon, bellCurveIcon, boxPlotIcon, bubbleChartIcon, cloudChartIcon, curveChartIcon, gridChartIcon, heatMapIcon, lineChartIcon, pieChartIcon, scatterPlotIcon, tickChartIcon, bankIcon, bitcoinIcon, calculatorIcon, coinBagIcon, creditCardIcon, dollarIcon, dollarBillIcon, eCheckIcon, employeeIcon, employeeGroupIcon, euroIcon, factoryIcon, pesoIcon, piggyBankIcon, poundIcon, rubleIcon, rupeeIcon, shoppingBagIcon, shoppingCartIcon, storeIcon, walletIcon, wonIcon, yenIcon, cameraIcon, fastForwardIcon, filmStripIcon, headphonesIcon, imageGalleryIcon, microphoneIcon, microphoneMuteIcon, musicNoteIcon, pauseIcon, playIcon, powerIcon, replayAllIcon, replayOneIcon, rewindIcon, shuffleIcon, stepForwardIcon, stopIcon, videoCameraIcon, videoGalleryIcon, volumeDownIcon, volumeMuteIcon, volumeUpIcon, arrowMiniIcon, calendarMiniIcon, checkCircleMiniIcon, checkMiniIcon, errorMiniIcon, eventMiniIcon, filterGridCircleMiniIcon, infoCircleMiniIcon, timesMiniIcon, warningMiniIcon, administratorIcon, animationIcon, applicationIcon, applicationsIcon, archiveIcon, assignUserIcon, atomIcon, backupIcon, backupRestoreIcon, barCodeIcon, batteryIcon, blockIcon, blocksGroupIcon, bluetoothIcon, bluetoothOffIcon, buildingIcon, bundleIcon, capacitorIcon, cdDvdIcon, certificateIcon, ciCdIcon, cloudNetworkIcon, cloudScaleIcon, cloudTrafficIcon, clusterIcon, codeIcon, computerIcon, connectIcon, containerIcon, containerVolumeIcon, controlLunIcon, cpuIcon, dashboardIcon, dataClusterIcon, deployIcon, devicesIcon, digitalSignatureIcon, disconnectIcon, displayIcon, downloadCloudIcon, exportIcon, fileShare2Icon, fileShareIcon, flaskIcon, floppyIcon, forkingIcon, hardDiskIcon, hardDriveDisksIcon, hardDriveIcon, helixIcon, hostGroupIcon, hostIcon, importIcon, inductorIcon, installIcon, internetOfThingsIcon, keyboardIcon, layersIcon, linkIcon, mediaChangerIcon, memoryIcon, mobileIcon, mouseIcon, namespaceIcon, networkGlobeIcon, networkSettingsIcon, networkSwitchIcon, nodeGroupIcon, nodeIcon, nodesIcon, noWifiIcon, nvmeIcon, phoneHandsetIcon, pluginIcon, podIcon, processOnVmIcon, qrCodeIcon, rackServerIcon, radarIcon, resistorIcon, resourcePoolIcon, routerIcon, rulerPencilIcon, scriptExecuteIcon, scriptScheduleIcon, shieldCheckIcon, shieldIcon, shieldXIcon, squidIcon, ssdIcon, storageAdapterIcon, storageIcon, tabletIcon, tapeDriveIcon, terminalIcon, thinClientIcon, unarchiveIcon, uninstallIcon, unlinkIcon, updateIcon, uploadCloudIcon, usbIcon, vmIcon, wifiIcon, xlsFileIcon, bookmarkIcon, chatBubbleIcon, contractIcon, crownIcon, envelopeIcon, flagIcon, halfStarIcon, happyFaceIcon, hashtagIcon, heartIcon, heartBrokenIcon, inboxIcon, neutralFaceIcon, pictureIcon, sadFaceIcon, shareIcon, starIcon, talkBubblesIcon, tasksIcon, thumbsDownIcon, thumbsUpIcon, alignBottomIcon, alignCenterIcon, alignLeftIcon, alignLeftTextIcon, alignMiddleIcon, alignRightIcon, alignRightTextIcon, alignTopIcon, blockQuoteIcon, boldIcon, bulletListIcon, centerTextIcon, checkboxListIcon, fontSizeIcon, highlighterIcon, indentIcon, italicIcon, justifyTextIcon, languageIcon, numberListIcon, outdentIcon, paintRollerIcon, strikethroughIcon, subscriptIcon, superscriptIcon, textIcon, textColorIcon, underlineIcon, airplaneIcon, bicycleIcon, boatIcon, campervanIcon, carIcon, caravanIcon, compassIcon, ferryIcon, mapIcon, mapMarkerIcon, onHolidayIcon, trailerIcon, truckIcon } from '@cds/core/icon'; 6 | import { bootstrapApplication } from '@angular/platform-browser'; 7 | import { AppComponent } from './app/app.component'; 8 | 9 | import { routes } from './app/app.router'; 10 | import { provideRouter } from '@angular/router'; 11 | import {withInterceptorsFromDi, provideHttpClient, HTTP_INTERCEPTORS} from '@angular/common/http'; 12 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 13 | import { BackendService } from './app/services/backend.service'; 14 | import { AesService } from './app/services/aes.service'; 15 | import { IconService } from './app/services/icon.service'; 16 | import { MarkdownPipe } from './app/pipes/markdown.pipe'; 17 | import { FileSizePipe } from './app/pipes/file-size.pipe'; 18 | import { BookmakrsPipe } from './app/pipes/bookmarks.pipe'; 19 | 20 | ClarityIcons.addIcons(unknownIcon, angleIcon, angleDoubleIcon, arrowIcon, barsIcon, bellIcon, calendarIcon, checkIcon, checkCircleIcon, cloudIcon, cogIcon, ellipsisHorizontalIcon, ellipsisVerticalIcon, errorStandardIcon, eventIcon, exclamationCircleIcon, exclamationTriangleIcon, eyeIcon, eyeHideIcon, filterGridIcon, filterGridCircleIcon, folderIcon, folderOpenIcon, helpInfoIcon, homeIcon, imageIcon, infoCircleIcon, infoStandardIcon, searchIcon, stepForward2Icon, successStandardIcon, timesIcon, unknownStatusIcon, userIcon, viewColumnsIcon, vmBugIcon, vmBugInverseIcon, warningStandardIcon, detailExpandIcon, detailCollapseIcon, accessibility1Icon, accessibility2Icon, announcementIcon, addTextIcon, alarmClockIcon, alarmOffIcon, asteriskIcon, banIcon, betaIcon, boltIcon, bookIcon, briefcaseIcon, bubbleExclamationIcon, bugIcon, bullseyeIcon, childArrowIcon, circleIcon, circleArrowIcon, clipboardIcon, clockIcon, cloneIcon, collapseCardIcon, colorPaletteIcon, colorPickerIcon, copyIcon, copyToClipboardIcon, crosshairsIcon, cursorArrowIcon, cursorHandIcon, cursorHandClickIcon, cursorHandGrabIcon, cursorHandOpenIcon, cursorMoveIcon, detailsIcon, dotCircleIcon, downloadIcon, dragHandleIcon, dragHandleCornerIcon, eraserIcon, expandCardIcon, fileIcon, fileGroupIcon, fileSettingsIcon, fileZipIcon, filterIcon, filter2Icon, filterOffIcon, firewallIcon, firstAidIcon, fishIcon, flameIcon, formIcon, fuelIcon, gridViewIcon, helpIcon, historyIcon, hourglassIcon, idBadgeIcon, keyIcon, landscapeIcon, launchpadIcon, libraryIcon, lightbulbIcon, listIcon, lockIcon, loginIcon, logoutIcon, minusIcon, minusCircleIcon, moonIcon, newIcon, noAccessIcon, noteIcon, objectsIcon, organizationIcon, paperclipIcon, pasteIcon, pencilIcon, pinIcon, pinboardIcon, plusIcon, plusCircleIcon, popOutIcon, portraitIcon, printerIcon, recycleIcon, redoIcon, refreshIcon, repeatIcon, resizeIcon, scissorsIcon, scrollIcon, shrinkIcon, sliderIcon, snowflakeIcon, sortByIcon, sunIcon, switchIcon, syncIcon, tableIcon, tagIcon, tagsIcon, targetIcon, thermometerIcon, timesCircleIcon, toolsIcon, trashIcon, treeIcon, treeViewIcon, twoWayArrowsIcon, undoIcon, unpinIcon, unlockIcon, uploadIcon, usersIcon, viewCardsIcon, viewListIcon, volumeIcon, wandIcon, windowCloseIcon, windowMaxIcon, windowMinIcon, windowRestoreIcon, worldIcon, wrenchIcon, zoomInIcon, zoomOutIcon, axisChartIcon, barChartIcon, bellCurveIcon, boxPlotIcon, bubbleChartIcon, cloudChartIcon, curveChartIcon, gridChartIcon, heatMapIcon, lineChartIcon, pieChartIcon, scatterPlotIcon, tickChartIcon, bankIcon, bitcoinIcon, calculatorIcon, coinBagIcon, creditCardIcon, dollarIcon, dollarBillIcon, eCheckIcon, employeeIcon, employeeGroupIcon, euroIcon, factoryIcon, pesoIcon, piggyBankIcon, poundIcon, rubleIcon, rupeeIcon, shoppingBagIcon, shoppingCartIcon, storeIcon, walletIcon, wonIcon, yenIcon, cameraIcon, fastForwardIcon, filmStripIcon, headphonesIcon, imageGalleryIcon, microphoneIcon, microphoneMuteIcon, musicNoteIcon, pauseIcon, playIcon, powerIcon, replayAllIcon, replayOneIcon, rewindIcon, shuffleIcon, stepForwardIcon, stopIcon, videoCameraIcon, videoGalleryIcon, volumeDownIcon, volumeMuteIcon, volumeUpIcon, arrowMiniIcon, calendarMiniIcon, checkCircleMiniIcon, checkMiniIcon, errorMiniIcon, eventMiniIcon, filterGridCircleMiniIcon, infoCircleMiniIcon, timesMiniIcon, warningMiniIcon, administratorIcon, animationIcon, applicationIcon, applicationsIcon, archiveIcon, assignUserIcon, atomIcon, backupIcon, backupRestoreIcon, barCodeIcon, batteryIcon, blockIcon, blocksGroupIcon, bluetoothIcon, bluetoothOffIcon, buildingIcon, bundleIcon, capacitorIcon, cdDvdIcon, certificateIcon, ciCdIcon, cloudNetworkIcon, cloudScaleIcon, cloudTrafficIcon, clusterIcon, codeIcon, computerIcon, connectIcon, containerIcon, containerVolumeIcon, controlLunIcon, cpuIcon, dashboardIcon, dataClusterIcon, deployIcon, devicesIcon, digitalSignatureIcon, disconnectIcon, displayIcon, downloadCloudIcon, exportIcon, fileShare2Icon, fileShareIcon, flaskIcon, floppyIcon, forkingIcon, hardDiskIcon, hardDriveDisksIcon, hardDriveIcon, helixIcon, hostGroupIcon, hostIcon, importIcon, inductorIcon, installIcon, internetOfThingsIcon, keyboardIcon, layersIcon, linkIcon, mediaChangerIcon, memoryIcon, mobileIcon, mouseIcon, namespaceIcon, networkGlobeIcon, networkSettingsIcon, networkSwitchIcon, nodeGroupIcon, nodeIcon, nodesIcon, noWifiIcon, nvmeIcon, phoneHandsetIcon, pluginIcon, podIcon, processOnVmIcon, qrCodeIcon, rackServerIcon, radarIcon, resistorIcon, resourcePoolIcon, routerIcon, rulerPencilIcon, scriptExecuteIcon, scriptScheduleIcon, shieldCheckIcon, shieldIcon, shieldXIcon, squidIcon, ssdIcon, storageAdapterIcon, storageIcon, tabletIcon, tapeDriveIcon, terminalIcon, thinClientIcon, unarchiveIcon, uninstallIcon, unlinkIcon, updateIcon, uploadCloudIcon, usbIcon, vmIcon, wifiIcon, xlsFileIcon, bookmarkIcon, chatBubbleIcon, contractIcon, crownIcon, envelopeIcon, flagIcon, halfStarIcon, happyFaceIcon, hashtagIcon, heartIcon, heartBrokenIcon, inboxIcon, neutralFaceIcon, pictureIcon, sadFaceIcon, shareIcon, starIcon, talkBubblesIcon, tasksIcon, thumbsDownIcon, thumbsUpIcon, alignBottomIcon, alignCenterIcon, alignLeftIcon, alignLeftTextIcon, alignMiddleIcon, alignRightIcon, alignRightTextIcon, alignTopIcon, blockQuoteIcon, boldIcon, bulletListIcon, centerTextIcon, checkboxListIcon, fontSizeIcon, highlighterIcon, indentIcon, italicIcon, justifyTextIcon, languageIcon, numberListIcon, outdentIcon, paintRollerIcon, strikethroughIcon, subscriptIcon, superscriptIcon, textIcon, textColorIcon, underlineIcon, airplaneIcon, bicycleIcon, boatIcon, campervanIcon, carIcon, caravanIcon, compassIcon, ferryIcon, mapIcon, mapMarkerIcon, onHolidayIcon, trailerIcon, truckIcon); 21 | 22 | if (environment.production) { 23 | enableProdMode(); 24 | } 25 | 26 | bootstrapApplication(AppComponent, { 27 | providers: [ 28 | {provide: BackendService, useClass: BackendService}, 29 | {provide: AesService, useClass: AesService}, 30 | {provide: IconService, useClass: IconService}, 31 | {provide: MarkdownPipe, useClass: MarkdownPipe}, 32 | {provide: FileSizePipe, useClass: FileSizePipe}, 33 | {provide: BookmakrsPipe, useClass: BookmakrsPipe}, 34 | provideRouter(routes), 35 | provideHttpClient(withInterceptorsFromDi()), 36 | importProvidersFrom(BrowserAnimationsModule) 37 | ] 38 | }); -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes recent versions of Safari, Chrome (including 12 | * Opera), Edge on the desktop, and iOS and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** 22 | * By default, zone.js will patch all possible macroTask and DomEvents 23 | * user can disable parts of macroTask/DomEvents patch by setting following flags 24 | * because those flags need to be set before `zone.js` being loaded, and webpack 25 | * will put import in the top of bundle, so user need to create a separate file 26 | * in this directory (for example: zone-flags.ts), and put the following flags 27 | * into that file, and then add the following code before importing zone.js. 28 | * import './zone-flags'; 29 | * 30 | * The flags allowed in zone-flags.ts are listed here. 31 | * 32 | * The following flags will work for all browsers. 33 | * 34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 37 | * 38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 40 | * 41 | * (window as any).__Zone_enable_cross_context_check = true; 42 | * 43 | */ 44 | 45 | /*************************************************************************************************** 46 | * Zone JS is required by default for Angular itself. 47 | */ 48 | import 'zone.js'; // Included with Angular CLI. 49 | 50 | 51 | /*************************************************************************************************** 52 | * APPLICATION IMPORTS 53 | */ 54 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | button:focus { 2 | outline:0; 3 | } 4 | 5 | .CodeMirror pre { 6 | font-family: monospace,monospace !important; 7 | } 8 | 9 | .markdown-formatted pre { 10 | background-color:#eee; 11 | padding:10px; 12 | overflow-x:auto; 13 | } 14 | 15 | .markdown-formatted th { 16 | background:#002538; 17 | color:#eeeeee; 18 | padding:10px; 19 | } 20 | 21 | .markdown-formatted td { 22 | padding:10px; 23 | } 24 | 25 | .markdown-formatted tr:nth-child(even) { 26 | background:#eee; 27 | } 28 | 29 | .markdown-formatted tr:nth-child(odd) { 30 | background:#f9f9f9; 31 | } 32 | 33 | .markdown-formatted h1:not([cds-text]) { 34 | font-size: 1.85em; 35 | margin-bottom:0.6rem; 36 | } 37 | 38 | .markdown-formatted h2:not([cds-text]) { 39 | font-size: 1.6em; 40 | margin-bottom:0.6rem; 41 | } 42 | 43 | .markdown-formatted h3:not([cds-text]) { 44 | font-size: 1.4em; 45 | margin-bottom:0.6rem; 46 | } 47 | 48 | .markdown-formatted h4:not([cds-text]) { 49 | font-size: 1.3em; 50 | margin-bottom:0.6rem; 51 | } 52 | 53 | .markdown-formatted h5:not([cds-text]) { 54 | font-size: 1.2em; 55 | margin-bottom:0.6rem; 56 | } 57 | 58 | .markdown-formatted h6:not([cds-text]) { 59 | font-size: 1.1em; 60 | margin-bottom:0.6rem; 61 | } 62 | 63 | .markdown-formatted blockquote { 64 | border-left:2px solid #678da0; 65 | padding-left:5px; 66 | } 67 | 68 | .markdown-formatted .toc { 69 | box-shadow: 0 0.125rem 0 0 #d7d7d7; 70 | border-radius: .125rem; 71 | border: 1px solid #d7d7d7; 72 | background-color: #fff; 73 | padding:15px; 74 | display:inline-block; 75 | } 76 | 77 | .markdown-formatted .toc h1 { 78 | margin-top:0; 79 | font-size:1.3em; 80 | line-height: 1.5em; 81 | margin-bottom:0.5em; 82 | } 83 | 84 | .markdown-formatted .bookmarks li { 85 | list-style: none; 86 | margin-bottom:5px; 87 | } 88 | 89 | .markdown-formatted .bookmarks h1, 90 | .markdown-formatted .bookmarks h2, 91 | .markdown-formatted .bookmarks h3, 92 | .markdown-formatted .bookmarks h4, 93 | .markdown-formatted .bookmarks h5, 94 | .markdown-formatted .bookmarks h6 { 95 | margin-top:10px; 96 | margin-bottom:5px; 97 | } 98 | 99 | .markdown-formatted .bookmarks img { 100 | width:16px; 101 | height:16px; 102 | } 103 | 104 | .markdown-formatted .bookmarks a { 105 | text-decoration: none; 106 | } 107 | 108 | .markdown-formatted .error { 109 | color:#ff0000; 110 | } 111 | 112 | .markdown-formatted ol, 113 | .markdown-formatted ul { 114 | list-style-position: outside; 115 | margin-left: 1.2em; 116 | } 117 | 118 | .markdown-formatted ul:not([cds-list]), 119 | .markdown-formatted ul { 120 | margin-left: 1.5em; 121 | } 122 | 123 | .markdown-formatted hr { 124 | margin:30px 0 30px 0; 125 | } 126 | 127 | .markdown-formatted li { 128 | margin-bottom:0.1rem; 129 | } 130 | 131 | .markdown-formatted li p:not([cds-text]) { 132 | line-height: 0; 133 | margin-top:0; 134 | display: inline; 135 | } 136 | 137 | .filedrop { 138 | cursor: pointer; 139 | margin: auto; 140 | height: 100px; 141 | border: 3px dashed #0782d0; 142 | border-radius: 5px; 143 | } 144 | 145 | .editor-toolbar { 146 | padding: 0px 9px 8px 9px !important; 147 | } 148 | 149 | .editor-toolbar button.table { 150 | width:auto; 151 | } -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | // First, initialize the Angular testing environment. 11 | getTestBed().initTestEnvironment( 12 | BrowserDynamicTestingModule, 13 | platformBrowserDynamicTesting(), 14 | ); 15 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "noImplicitOverride": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "experimentalDecorators": true, 17 | "moduleResolution": "node", 18 | "importHelpers": true, 19 | "target": "ES2022", 20 | "module": "es2020", 21 | "lib": [ 22 | "es2020", 23 | "dom" 24 | ], 25 | "useDefineForClassFields": false 26 | }, 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-redundant-jsdoc": true, 69 | "no-shadowed-variable": true, 70 | "no-string-literal": false, 71 | "no-string-throw": true, 72 | "no-switch-case-fall-through": true, 73 | "no-trailing-whitespace": true, 74 | "no-unnecessary-initializer": true, 75 | "no-unused-expression": true, 76 | "no-var-keyword": true, 77 | "object-literal-sort-keys": false, 78 | "one-line": [ 79 | true, 80 | "check-open-brace", 81 | "check-catch", 82 | "check-else", 83 | "check-whitespace" 84 | ], 85 | "prefer-const": true, 86 | "quotemark": [ 87 | true, 88 | "single" 89 | ], 90 | "radix": true, 91 | "semicolon": [ 92 | true, 93 | "always" 94 | ], 95 | "triple-equals": [ 96 | true, 97 | "allow-null-check" 98 | ], 99 | "typedef-whitespace": [ 100 | true, 101 | { 102 | "call-signature": "nospace", 103 | "index-signature": "nospace", 104 | "parameter": "nospace", 105 | "property-declaration": "nospace", 106 | "variable-declaration": "nospace" 107 | } 108 | ], 109 | "unified-signatures": true, 110 | "variable-name": false, 111 | "whitespace": [ 112 | true, 113 | "check-branch", 114 | "check-decl", 115 | "check-operator", 116 | "check-separator", 117 | "check-type" 118 | ], 119 | "no-output-on-prefix": true, 120 | "use-input-property-decorator": true, 121 | "use-output-property-decorator": true, 122 | "use-host-property-decorator": true, 123 | "no-input-rename": true, 124 | "no-output-rename": true, 125 | "use-life-cycle-interface": true, 126 | "use-pipe-transform-interface": true, 127 | "component-class-suffix": true, 128 | "directive-class-suffix": true 129 | } 130 | } 131 | --------------------------------------------------------------------------------