├── src ├── assets │ ├── .gitkeep │ ├── icons │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ └── icon-512x512.png │ └── img │ │ ├── graph-icon.png │ │ ├── tag-and-notes.jpg │ │ ├── drag-and-drop-tag.gif │ │ └── multiple-tags-per-note.jpg ├── typings-custom │ └── marked.d.ts ├── app │ ├── app.component.css │ ├── app.component.html │ ├── already-existing-note.directive.spec.ts │ ├── analytics.service.spec.ts │ ├── notification.service.spec.ts │ ├── tag-group │ │ └── tag-group.component.spec.ts │ ├── analytics.service.ts │ ├── app-routing.module.ts │ ├── filelist │ │ ├── filelist.component.html │ │ ├── filelist.component.ts │ │ └── filelist.component.spec.ts │ ├── constants.ts │ ├── utils.spec.ts │ ├── already-existing-note.directive.ts │ ├── app.component.ts │ ├── backends │ │ ├── google-drive-auth-confirmation.component.ts │ │ ├── firebase.service.ts │ │ ├── test-data.service.ts │ │ ├── test-data.ts │ │ └── in-memory-cache.service.ts │ ├── backreferences-dialog │ │ └── backreferences-dialog.component.ts │ ├── confirmation-dialog │ │ └── confirmation-dialog.component.ts │ ├── settings │ │ └── settings.component.ts │ ├── settings.service.ts │ ├── utils.ts │ ├── editor │ │ ├── editor.component.spec.ts │ │ ├── highlighted-programming-languages.ts │ │ └── editor.component.html │ ├── notification.service.ts │ ├── upload-existing-dialog │ │ └── upload-existing-dialog.component.spec.ts │ ├── attachments-dialog │ │ └── attachments-dialog.component.ts │ ├── flashcard.service.ts │ ├── subview-manager.service.ts │ ├── flashcard.service.spec.ts │ ├── study │ │ ├── study.component.spec.ts │ │ └── study.component.ts │ ├── edit-tag-parents-dialog │ │ └── edit-tag-parents-dialog.component.ts │ ├── app.module.ts │ ├── types.d.ts │ ├── zettelkasten │ │ ├── zettelkasten.component.ts │ │ └── zettelkasten.component.html │ ├── frontpage │ │ ├── frontpage.component.ts │ │ └── frontpage.component.html │ └── search-dialog │ │ └── search-dialog.component.ts ├── favicon.ico ├── favicon2.ico ├── main.ts ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── test.ts ├── index.html ├── manifest.webmanifest ├── themes.scss ├── styles.css ├── polyfills.ts └── codemirror-dark-styles.css ├── e2e ├── tsconfig.json ├── src │ ├── app.po.ts │ └── app.e2e-spec.ts └── protractor.conf.js ├── tsconfig.spec.json ├── tsconfig.app.json ├── browserslist ├── tsconfig.json ├── ngsw-config.json ├── .gitignore ├── LICENSE ├── karma.conf.js ├── package.json ├── README.md ├── tslint.json └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/typings-custom/marked.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'marked'; 2 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | 2 | .cm-header.cm-header2 { 3 | font-size: 12pt; 4 | } 5 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/favicon2.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/favicon2.ico -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/assets/img/graph-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/img/graph-icon.png -------------------------------------------------------------------------------- /src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/assets/img/tag-and-notes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/img/tag-and-notes.jpg -------------------------------------------------------------------------------- /src/assets/img/drag-and-drop-tag.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/img/drag-and-drop-tag.gif -------------------------------------------------------------------------------- /src/assets/img/multiple-tags-per-note.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsiki/connectednotes/HEAD/src/assets/img/multiple-tags-per-note.jpg -------------------------------------------------------------------------------- /e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/already-existing-note.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { AlreadyExistingNoteDirective } from './already-existing-note.directive'; 2 | 3 | describe('AlreadyExistingNoteDirective', () => { 4 | it('should create an instance', () => { 5 | const directive = new AlreadyExistingNoteDirective(null); 6 | expect(directive).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo(): Promise { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText(): Promise { 9 | return element(by.css('cn-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 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 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": ["gapi", "gapi.auth2", "gapi.client", "gapi.client.drive"], 6 | "typeRoots": [ 7 | "node_modules/@types" 8 | ] 9 | }, 10 | "files": [ 11 | "src/main.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | 12 | platformBrowserDynamic().bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /src/app/analytics.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { AnalyticsService } from './analytics.service'; 4 | 5 | describe('AnalyticsService', () => { 6 | let service: AnalyticsService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(AnalyticsService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /src/app/notification.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { NotificationService } from './notification.service'; 4 | 5 | describe('NotificationService', () => { 6 | let service: NotificationService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(NotificationService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | 2 | export const environment = { 3 | production: true, 4 | firebase: { 5 | apiKey: 'AIzaSyDv1ZQMKxS3Dfp00vWJ6j5sSTBHSGp42Xo', 6 | authDomain: 'zeka-cdbc1.firebaseapp.com', 7 | databaseURL: 'https://zeka-cdbc1.firebaseio.com', 8 | projectId: 'zeka-cdbc1', 9 | storageBucket: 'zeka-cdbc1.appspot.com', 10 | messagingSenderId: '77203604551', 11 | }, 12 | googleDrive: { 13 | gdriveClientKey: '54017624858-kubnhm2m7p7oolitvucjavcfnkuvcnju.apps.googleusercontent.com', 14 | gdriveApiKey: 'AIzaSyBb3UqJBV9uVmTpB7hbaEVmqZGeLzyV4NA', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/tag-group/tag-group.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TagGroupComponent } from './tag-group.component'; 4 | 5 | describe('TagGroupComponent', () => { 6 | let component: TagGroupComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ TagGroupComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(TagGroupComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "allowSyntheticDefaultImports": true, 10 | "experimentalDecorators": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "target": "es2015", 15 | "lib": [ 16 | "es2019", 17 | "es2020.string", 18 | "dom" 19 | ] 20 | }, 21 | "angularCompilerOptions": { 22 | "fullTemplateTypeCheck": true, 23 | "strictInjectionParameters": true 24 | }, 25 | "include": [ 26 | "./typings-custom/**/*.ts" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('zk app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/analytics.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {ANALYTICS_ENABLED_LOCAL_STORAGE_KEY} from './constants'; 3 | 4 | declare interface WindowWithAnalytics { 5 | gtag: (...args: any) => {}; 6 | } 7 | 8 | declare const window: WindowWithAnalytics; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class AnalyticsService { 14 | 15 | enabled = false; 16 | 17 | constructor() { 18 | this.enabled = localStorage.getItem(ANALYTICS_ENABLED_LOCAL_STORAGE_KEY) === 'true'; 19 | } 20 | 21 | recordEvent(eventName: string, eventParams?: {}) { 22 | try { 23 | if (this.enabled) { 24 | window.gtag('event', eventName, eventParams); 25 | } 26 | } catch (e) {} 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { Routes, RouterModule } from '@angular/router'; 3 | import {FrontpageComponent} from './frontpage/frontpage.component'; 4 | import {ZettelkastenComponent} from './zettelkasten/zettelkasten.component'; 5 | 6 | const routes: Routes = [ 7 | { path: '', component: FrontpageComponent }, 8 | { path: 'zks/:userid', component: ZettelkastenComponent }, 9 | { path: 'gd', component: ZettelkastenComponent }, 10 | { path: 'test', component: ZettelkastenComponent }, 11 | { path: 'demo', component: ZettelkastenComponent }, 12 | ]; 13 | 14 | @NgModule({ 15 | imports: [RouterModule.forRoot(routes)], 16 | exports: [RouterModule] 17 | }) 18 | export class AppRoutingModule { } 19 | -------------------------------------------------------------------------------- /ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/service-worker/config/schema.json", 3 | "index": "/index.html", 4 | "assetGroups": [ 5 | { 6 | "name": "app", 7 | "installMode": "prefetch", 8 | "resources": { 9 | "files": [ 10 | "/favicon.ico", 11 | "/index.html", 12 | "/manifest.webmanifest", 13 | "/*.css", 14 | "/*.js", 15 | "https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined" 16 | ] 17 | } 18 | }, { 19 | "name": "assets", 20 | "installMode": "lazy", 21 | "updateMode": "prefetch", 22 | "resources": { 23 | "files": [ 24 | "/assets/**", 25 | "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" 26 | ] 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /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/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context(path: string, deep?: boolean, filter?: RegExp): { 12 | keys(): string[]; 13 | (id: string): T; 14 | }; 15 | }; 16 | 17 | // First, initialize the Angular testing environment. 18 | getTestBed().initTestEnvironment( 19 | BrowserDynamicTestingModule, 20 | platformBrowserDynamicTesting() 21 | ); 22 | // Then we find all the tests. 23 | const context = require.context('./', true, /\.spec\.ts$/); 24 | // And load the modules. 25 | context.keys().map(context); 26 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Connected Notes 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.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 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | 48 | .firebase/ 49 | /.firebase 50 | /.firebase/ 51 | /firebase.json 52 | /.firebaserc 53 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 31 | } 32 | }; -------------------------------------------------------------------------------- /src/app/filelist/filelist.component.html: -------------------------------------------------------------------------------- 1 | 29 | 30 |
35 | 40 | 41 |
42 | -------------------------------------------------------------------------------- /src/app/constants.ts: -------------------------------------------------------------------------------- 1 | import {FlashcardLearningData} from './types'; 2 | 3 | export const JSON_MIMETYPE = 'application/json'; 4 | export const TEXT_MIMETYPE = 'text/plain'; 5 | 6 | export const DARK_THEME = 'darcula'; 7 | export const LIGHT_THEME = 'default'; 8 | 9 | export const ROOT_TAG_NAME = '(root)'; 10 | export const ALL_NOTES_TAG_NAME = 'all'; 11 | export const UNTAGGED_NOTES_TAG_NAME = 'untagged'; 12 | export const AUTOMATICALLY_GENERATED_TAG_NAMES = [ALL_NOTES_TAG_NAME, UNTAGGED_NOTES_TAG_NAME]; 13 | 14 | export const ANALYTICS_ENABLED_LOCAL_STORAGE_KEY = 'analyticsEnabled'; 15 | 16 | export const INITIAL_FLASHCARD_LEARNING_DATA: FlashcardLearningData = Object.freeze({ 17 | easinessFactor: 2.5, 18 | numRepetitions: 0, 19 | prevRepetitionEpochMillis: 0, 20 | prevRepetitionIntervalMillis: 0, 21 | }); 22 | 23 | // (^|\s) matches whitespace or start of line 24 | // ?![#] ensures that there's only one hashtag before the tag, ignoring stuff like ##asd 25 | // \S is non-whitespace 26 | // ig makes it case insensitive and global 27 | export const TAG_MATCH_REGEX = /(^|\s)(#((?![#])[\S])+)/ig; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-2021 Scott Chacon and others 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/zk'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import * as utils from './utils'; 4 | 5 | fdescribe('AnalyticsService', () => { 6 | 7 | it('should detect note references', () => { 8 | const existingTitles = new Set(['qweqwe', 'zxc']); 9 | const refs = utils.getAllNoteReferences('asdasd [[qweqwe]] qwdsadoqi dss [[zxc]]!', existingTitles); 10 | expect(refs).toEqual([{index: 9, noteReferenced: 'qweqwe'}, {index: 34, noteReferenced: 'zxc'}]); 11 | }); 12 | 13 | it('should detect only existing note references', () => { 14 | const existingTitles = new Set(['qweqwe']); 15 | const refs = utils.getAllNoteReferences('asdasd [[qweqwe]] qwdsadoqi dss [[zxc]]!', existingTitles); 16 | expect(refs).toEqual([{index: 9, noteReferenced: 'qweqwe'}]); 17 | }); 18 | 19 | it('should detect multiple note references', () => { 20 | const existingTitles = new Set(['qweqwe', 'zxc']); 21 | const refs = utils.getAllNoteReferences('asdasd [[qweqwe]] qwdsadoqi dss [[qweqwe]]!', existingTitles); 22 | expect(refs).toEqual([{index: 9, noteReferenced: 'qweqwe'}, {index: 34, noteReferenced: 'qweqwe'}]); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | // TODO: I guess we don't need the below anymore 6 | export const environment = { 7 | production: false, 8 | firebase: { 9 | apiKey: 'AIzaSyDv1ZQMKxS3Dfp00vWJ6j5sSTBHSGp42Xo', 10 | authDomain: 'zeka-cdbc1.firebaseapp.com', 11 | databaseURL: 'https://zeka-cdbc1.firebaseio.com', 12 | projectId: 'zeka-cdbc1', 13 | storageBucket: 'zeka-cdbc1.appspot.com', 14 | messagingSenderId: '77203604551', 15 | }, 16 | googleDrive: { 17 | gdriveClientKey: '54017624858-ojio5b0juobrn7gnjgsi556cq8un7spm.apps.googleusercontent.com', 18 | gdriveApiKey: 'AIzaSyATVo6Y6X8NOuPH6z1P5Daow2gFPgK25wI', 19 | }, 20 | }; 21 | 22 | /* 23 | * For easier debugging in development mode, you can import the following file 24 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 25 | * 26 | * This import should be commented out in production mode because it will have a negative impact 27 | * on performance if an error is thrown. 28 | */ 29 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 30 | -------------------------------------------------------------------------------- /src/app/already-existing-note.directive.ts: -------------------------------------------------------------------------------- 1 | import {Directive, Input} from '@angular/core'; 2 | import { 3 | AbstractControl, 4 | FormControl, 5 | FormGroupDirective, 6 | NG_VALIDATORS, 7 | NgForm, 8 | Validator, 9 | } from '@angular/forms'; 10 | import {StorageService} from './storage.service'; 11 | import {ErrorStateMatcher} from '@angular/material/core'; 12 | 13 | /** Error when invalid control is dirty. */ 14 | export class ValidateImmediatelyMatcher implements ErrorStateMatcher { 15 | isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { 16 | return control && control.invalid && control.value; 17 | } 18 | } 19 | 20 | @Directive({ 21 | selector: '[appAlreadyExistingNote]', 22 | providers: [{provide: NG_VALIDATORS, useExisting: AlreadyExistingNoteDirective, multi: true}] 23 | }) 24 | export class AlreadyExistingNoteDirective implements Validator { 25 | @Input() alreadyExistingTitle; 26 | 27 | constructor(private storage: StorageService) {} 28 | 29 | validate(control: AbstractControl): {[key: string]: any} | null { 30 | const noteExists = control.value ? !!this.storage.getNoteForTitleCaseInsensitive(control.value) : false; 31 | return noteExists && control.value !== this.alreadyExistingTitle 32 | ? {forbiddenName: {value: control.value}} 33 | : null; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, HostBinding} from '@angular/core'; 2 | import {SettingsService, Theme} from './settings.service'; 3 | 4 | interface ExpandedWindow { 5 | dataLayer: any[]; 6 | } 7 | 8 | declare const window: ExpandedWindow; 9 | 10 | @Component({ 11 | selector: 'cn-root', 12 | templateUrl: './app.component.html', 13 | styleUrls: ['./app.component.css'] 14 | }) 15 | export class AppComponent { 16 | title = 'Connected Notes'; 17 | 18 | @HostBinding('class.dark-theme') darkTheme = false; 19 | 20 | constructor(private readonly settingsService: SettingsService) { 21 | window.dataLayer = window.dataLayer || []; 22 | const gtag = (...args: any[]) => window.dataLayer.push(args); 23 | gtag('js', new Date()); 24 | 25 | gtag('config', 'G-HMG4J1GHEX'); 26 | gtag('consent', 'default', { 27 | 'ad_storage': 'denied', 28 | // Deny by default, update after consent 29 | 'analytics_storage': 'denied' 30 | }); 31 | 32 | this.settingsService.themeSetting.subscribe(newTheme => { 33 | if (newTheme === Theme.DARK) { 34 | document.body.classList.remove('light-theme'); 35 | document.body.classList.add('dark-theme'); 36 | } else if (newTheme === Theme.LIGHT) { 37 | document.body.classList.add('light-theme'); 38 | document.body.classList.remove('dark-theme'); 39 | } 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/backends/google-drive-auth-confirmation.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Inject} from '@angular/core'; 2 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; 3 | 4 | declare interface ExtendedWindow { 5 | gapi: any; 6 | } 7 | 8 | declare const window: ExtendedWindow; 9 | 10 | @Component({ 11 | selector: 'cn-google-drive-auth-confirmation-dialog', 12 | template: ` 13 |

Google Drive authorization has expired.

14 |

Click below to authorize Google Drive usage

15 | Connected Notes will only have access to the notes and flashcards it has created itself and 16 | won't be able to see any other files. 17 |
18 | 19 |
`, 20 | styles: [` 21 | #buttons { 22 | display: flex; 23 | justify-content: space-around; 24 | margin-top: 15px; 25 | } 26 | `] 27 | }) 28 | export class GoogleDriveAuthConfirmationComponent { 29 | 30 | expired: boolean; 31 | 32 | constructor( 33 | @Inject(MAT_DIALOG_DATA) public data: any, 34 | public dialogRef: MatDialogRef) { 35 | this.expired = data.expired; 36 | } 37 | 38 | async authorize() { 39 | await window.gapi.auth2.getAuthInstance().signIn(); 40 | this.close(); 41 | } 42 | 43 | close() { 44 | this.dialogRef.close(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/backreferences-dialog/backreferences-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Inject} from '@angular/core'; 2 | import {NoteObject} from '../types'; 3 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; 4 | import {StorageService} from '../storage.service'; 5 | import {SubviewManagerService} from '../subview-manager.service'; 6 | 7 | @Component({ 8 | selector: 'cn-backreferences-dialog', 9 | template: ` 10 | `, 16 | styles: [` 17 | .result-link { 18 | display: block; 19 | } 20 | `] 21 | }) 22 | export class BackreferencesDialogComponent { 23 | 24 | backrefs: NoteObject[]; 25 | noteId: string; 26 | 27 | constructor( 28 | @Inject(MAT_DIALOG_DATA) public data: any, 29 | public dialogRef: MatDialogRef, 30 | private readonly storage: StorageService, 31 | private readonly subviewManager: SubviewManagerService) { 32 | this.noteId = data.noteId; 33 | this.backrefs = this.storage.getBackreferences(this.noteId); 34 | } 35 | 36 | selectNote(e: MouseEvent, noteId: string) { 37 | if (e.metaKey || e.ctrlKey) { 38 | this.subviewManager.openNoteInNewWindow(noteId); 39 | } else { 40 | this.subviewManager.openViewInActiveWindow(noteId); 41 | } 42 | this.dialogRef.close(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/confirmation-dialog/confirmation-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Inject, OnInit} from '@angular/core'; 2 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; 3 | 4 | 5 | export interface ConfirmDialogData { 6 | title: string; 7 | message: string; 8 | confirmButtonText?: string; 9 | rejectButtonText?: string; 10 | } 11 | 12 | 13 | @Component({ 14 | selector: 'cn-confirmation-dialog', 15 | template: ` 16 |

{{title}}

17 | {{message}} 18 |
19 | 20 | 21 |
22 | `, 23 | styles: [` 24 | #button-container { 25 | display: flex; 26 | justify-content: flex-end; 27 | margin-top: 10px; 28 | } 29 | `] 30 | }) 31 | export class ConfirmationDialogComponent { 32 | 33 | title: string; 34 | message: string; 35 | confirmButtonText: string; 36 | rejectButtonText: string|null; 37 | 38 | constructor( 39 | @Inject(MAT_DIALOG_DATA) public data: any, 40 | public dialogRef: MatDialogRef) { 41 | this.title = data.title; 42 | this.message = data.message; 43 | this.confirmButtonText = data.confirmButtonText; 44 | this.rejectButtonText = data.rejectButtonText; 45 | } 46 | 47 | confirm() { 48 | this.dialogRef.close(true); 49 | } 50 | 51 | reject() { 52 | this.dialogRef.close(false); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Connected Notes", 3 | "short_name": "cn", 4 | "theme_color": "#1976d2", 5 | "background_color": "#fafafa", 6 | "display": "standalone", 7 | "scope": "./", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "assets/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | }, 22 | { 23 | "src": "assets/icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "maskable any" 27 | }, 28 | { 29 | "src": "assets/icons/icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "maskable any" 33 | }, 34 | { 35 | "src": "assets/icons/icon-152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "maskable any" 39 | }, 40 | { 41 | "src": "assets/icons/icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "maskable any" 45 | }, 46 | { 47 | "src": "assets/icons/icon-384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "maskable any" 51 | }, 52 | { 53 | "src": "assets/icons/icon-512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "maskable any" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/themes.scss: -------------------------------------------------------------------------------- 1 | @import '~@angular/material/theming'; 2 | // Plus imports for other components in your app. 3 | 4 | // Include the common styles for Angular Material. We include this here so that you only 5 | // have to load a single css file for Angular Material in your app. 6 | // Be sure that you only ever include this mixin once! 7 | @include mat-core(); 8 | 9 | // Define the palettes for your theme using the Material Design palettes available in palette.scss 10 | // (imported above). For each palette, you can optionally specify a default, lighter, and darker 11 | // hue. Available color palettes: https://material.io/design/color/ 12 | $cn-primary: mat-palette($mat-indigo); 13 | $cn-accent: mat-palette($mat-deep-orange, A200, A100, A400); 14 | 15 | // The warn palette is optional (defaults to red). 16 | $cn-warn: mat-palette($mat-red); 17 | 18 | // Create the theme object. A theme consists of configurations for individual 19 | // theming systems such as `color` or `typography`. 20 | $cn-theme: mat-light-theme(( 21 | color: ( 22 | primary: $cn-primary, 23 | accent: $cn-accent, 24 | warn: $cn-warn, 25 | ) 26 | )); 27 | 28 | // Include theme styles for core and each component used in your app. 29 | // Alternatively, you can import and @include the theme mixins for each component 30 | // that you are using. 31 | @include angular-material-theme($cn-theme); 32 | 33 | $dark-primary: mat-palette($mat-blue-grey); 34 | $dark-accent: mat-palette($mat-amber, A200, A100, A400); 35 | $dark-warn: mat-palette($mat-deep-orange); 36 | $dark-theme: mat-dark-theme(( 37 | color: ( 38 | primary: $dark-primary, 39 | accent: $dark-accent, 40 | warn: $dark-warn, 41 | ) 42 | )); 43 | 44 | .dark-theme { 45 | @include angular-material-color($dark-theme); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {MatDialog, MatDialogRef} from '@angular/material/dialog'; 3 | import {SettingsService, Theme} from '../settings.service'; 4 | import {BehaviorSubject} from 'rxjs'; 5 | import {UploadExistingDialogComponent} from '../upload-existing-dialog/upload-existing-dialog.component'; 6 | 7 | @Component({ 8 | selector: 'cn-settings', 9 | template: ` 10 |
11 | 12 |
13 | 14 | Theme 15 | 16 | Light 17 | Dark 18 | Follow Device Settings (default) 19 | 20 | 21 | 22 |

Ignored tags:

23 | 24 | 25 | {{ tag }} 26 | delete_outline 27 | 28 | 29 | `, 30 | styles: [` 31 | .delete-icon { 32 | cursor: pointer; 33 | } 34 | `] 35 | }) 36 | export class SettingsComponent { 37 | 38 | selectedTheme: string; 39 | ignoredTags: BehaviorSubject; 40 | 41 | constructor( 42 | private readonly settingsService: SettingsService, 43 | public dialog: MatDialog) { 44 | this.ignoredTags = settingsService.ignoredTags; 45 | } 46 | 47 | changeTheme(e) { 48 | this.settingsService.setTheme(Theme[e.value]); 49 | } 50 | 51 | openUploadDialog() { 52 | this.dialog.open(UploadExistingDialogComponent, { 53 | position: { top: '10px' }, 54 | maxHeight: '90vh' /* to enable scrolling on overflow */, 55 | }); 56 | } 57 | 58 | async removeIgnoredTag(tag: string) { 59 | await this.settingsService.removeIgnoredTag(tag); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/settings.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {StorageService} from './storage.service'; 3 | import {BehaviorSubject} from 'rxjs'; 4 | 5 | export enum Theme { 6 | LIGHT = 'light', 7 | DARK = 'dark', 8 | DEVICE = 'device', 9 | } 10 | 11 | const MILLIS_PER_DAY = 24 * 60 * 60 * 1000; 12 | 13 | @Injectable({ 14 | providedIn: 'root' 15 | }) 16 | export class SettingsService { 17 | 18 | themeSetting = new BehaviorSubject(Theme.DEVICE); 19 | ignoredTags = new BehaviorSubject(null); 20 | flashcardInitialDelayPeriod = new BehaviorSubject([MILLIS_PER_DAY, 6 * MILLIS_PER_DAY]); 21 | 22 | colorSchemeListener = e => this.themeSetting.next(e.matches ? Theme.DARK : Theme.LIGHT); 23 | 24 | constructor(private readonly storage: StorageService) { 25 | storage.storedSettings.asObservable().subscribe(newSettings => { 26 | if (newSettings?.theme) { 27 | if (newSettings.theme === Theme.DEVICE) { 28 | window.matchMedia('(prefers-color-scheme: dark)') 29 | .addEventListener('change', this.colorSchemeListener); 30 | const darkModePreferred = window.matchMedia('(prefers-color-scheme: dark)'); 31 | this.themeSetting.next(darkModePreferred.matches ? Theme.DARK : Theme.LIGHT); 32 | } else { 33 | window.matchMedia('(prefers-color-scheme: dark)') 34 | .removeEventListener('change', this.colorSchemeListener); 35 | this.themeSetting.next(newSettings.theme); 36 | } 37 | } 38 | if (newSettings?.ignoredTags) { 39 | this.ignoredTags.next(newSettings?.ignoredTags); 40 | } 41 | }); 42 | } 43 | 44 | async setTheme(value: Theme) { 45 | await this.storage.updateSettings('theme', value); 46 | } 47 | 48 | async addIgnoredTag(tag: string) { 49 | const cur = this.ignoredTags.value?.slice() || []; 50 | cur.push(tag); 51 | await this.storage.updateSettings('ignoredTags', cur); 52 | } 53 | 54 | async removeIgnoredTag(tag: string) { 55 | const newTags = this.ignoredTags.value?.slice().filter(existingTag => tag !== existingTag) || []; 56 | await this.storage.updateSettings('ignoredTags', newTags); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | export function sortAscByNumeric(arr: T[], propertyFn: (obj: T) => number) { 3 | arr.sort((a, b) => propertyFn(a) - propertyFn(b)); 4 | } 5 | 6 | export function sortAscByString(arr: T[], propertyFn: (obj: T) => string) { 7 | arr.sort((a, b) => propertyFn(a).localeCompare(propertyFn(b))); 8 | } 9 | 10 | export function sortDescByNumeric(arr: T[], propertyFn: (obj: T) => number) { 11 | arr.sort((a, b) => propertyFn(b) - propertyFn(a)); 12 | } 13 | 14 | export function sortDescByString(arr: T[], propertyFn: (obj: T) => string) { 15 | arr.sort((a, b) => propertyFn(b).localeCompare(propertyFn(a))); 16 | } 17 | 18 | export function shallowCopy(obj: any) { 19 | return Object.assign({}, obj); 20 | } 21 | 22 | export interface NoteReference { 23 | index: number; // Index where the actual note title starts 24 | noteReferenced: string; 25 | } 26 | 27 | export function getAllNoteReferences(s: string, existingTitles: Set): NoteReference[] { 28 | const ans = []; 29 | for (let idx = s.indexOf('[['); idx !== -1; idx = s.indexOf('[[', idx + 1)) { 30 | if (idx > 0 && s[idx - 1] === '\\') continue; 31 | // Just in case there's something like [[[[[title]] 32 | while (s.length > idx + 1 && s[idx] === '[' && s[idx + 1] === '[') { 33 | idx++; 34 | } 35 | const endIdx = s.indexOf(']]', idx); 36 | const ref = s.slice(idx + 1, endIdx); 37 | if (existingTitles.has(ref)) { 38 | ans.push({index: idx + 1, noteReferenced: s.slice(idx + 1, endIdx)}); 39 | } 40 | } 41 | return ans; 42 | } 43 | 44 | function getUniqueName(curName: string, existingNames: Set) { 45 | if (!existingNames.has(curName)) { 46 | return curName; 47 | } 48 | let i = 1; 49 | while (existingNames.has(curName + ` (${i})`)) { 50 | i++; 51 | } 52 | return curName + ` (${i})`; 53 | } 54 | 55 | export function makeNamesUnique(titles: string[], existingNames: Set): string[] { 56 | const ans = []; 57 | for (const title of titles) { 58 | const newTitle = getUniqueName(title, existingNames); 59 | ans.push(newTitle); 60 | existingNames.add(newTitle); 61 | } 62 | return ans; 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zk", 3 | "version": "0.0.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": "^10.1.6", 15 | "@angular/cdk": "^10.2.7", 16 | "@angular/common": "^10.1.6", 17 | "@angular/compiler": "^10.1.6", 18 | "@angular/core": "^10.1.6", 19 | "@angular/fire": "^6.1.2", 20 | "@angular/forms": "^10.1.6", 21 | "@angular/material": "^10.2.7", 22 | "@angular/platform-browser": "^10.1.6", 23 | "@angular/platform-browser-dynamic": "^10.1.6", 24 | "@angular/router": "^10.1.6", 25 | "@angular/service-worker": "^10.1.6", 26 | "@types/cytoscape": "^3.14.11", 27 | "@types/dropzone": "^5.7.3", 28 | "angular-split": "^3.0.3", 29 | "codemirror": "^5.58.3", 30 | "cytoscape": "^3.17.1", 31 | "cytoscape-fcose": "^1.2.3", 32 | "d3": "^5.16.0", 33 | "d3-force": "^2.1.1", 34 | "dropzone": "^5.7.2", 35 | "firebase": "^8.1.1", 36 | "marked": "^1.2.5", 37 | "material-design-icons": "^3.0.1", 38 | "rxjs": "~6.6.3", 39 | "tslib": "^1.14.1", 40 | "zone.js": "~0.10.2" 41 | }, 42 | "devDependencies": { 43 | "@angular-devkit/architect": "~0.900", 44 | "@angular-devkit/build-angular": "^0.1100.2", 45 | "@angular/cli": "^10.1.7", 46 | "@angular/compiler-cli": "^10.1.6", 47 | "@angular/language-service": "^10.1.6", 48 | "@ionic/angular-toolkit": "^2.3.3", 49 | "@types/codemirror": "0.0.91", 50 | "@types/d3": "^5.16.4", 51 | "@types/gapi": "0.0.39", 52 | "@types/gapi.auth2": "0.0.52", 53 | "@types/gapi.client.drive": "^3.0.13", 54 | "@types/jasmine": "^3.5.14", 55 | "@types/jasminewd2": "~2.0.3", 56 | "@types/node": "^12.19.7", 57 | "codelyzer": "^5.1.2", 58 | "fuzzy": "^0.1.3", 59 | "inquirer": "^6.2.2", 60 | "inquirer-autocomplete-prompt": "^1.3.0", 61 | "jasmine-core": "~3.5.0", 62 | "jasmine-spec-reporter": "~4.2.1", 63 | "karma": "^5.0.9", 64 | "karma-chrome-launcher": "~3.1.0", 65 | "karma-coverage-istanbul-reporter": "~2.1.0", 66 | "karma-jasmine": "~3.0.1", 67 | "karma-jasmine-html-reporter": "^1.5.4", 68 | "protractor": "~5.4.3", 69 | "ts-node": "~8.3.0", 70 | "tslint": "^6.1.3", 71 | "typescript": "^4.0.5" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | 2 | html, body { height: 100%; } 3 | body { margin: 0; font-family: "Roboto", sans-serif; } 4 | 5 | 6 | 7 | :root { 8 | --editor-font-weight: 400; 9 | --editor-text-font-size: 14px; 10 | --primary-text-color: #000; 11 | --low-contrast-text-color: #4d4d4d; 12 | --primary-background-color: #fff; 13 | --secondary-background-color: #fafafa; 14 | --tertiary-background-color: #e6e6e6; 15 | --warning-background-color: #ffed61; 16 | --icons-menu-background-color: #2e2e2e; 17 | --icons-menu-color: #d1d1d1; 18 | --navigation-border-color: #d1d1d1; 19 | --highlight-color: #ffed61; 20 | --low-contrast-highlight-color: #ffffb3; 21 | --selected-note-color: rgb(224, 224, 224); 22 | --top-bar-height: 50px; 23 | --gutter-color: #fafafa; 24 | --save-status-indicator-unsaved-changes-color: #eb0212; 25 | --save-status-indicator-saving-color: #d1d13d; 26 | --save-status-indicator-everything-saved-color: initial; 27 | --nested-tag-gutter-color: #c9c9c9; 28 | /* Text styles */ 29 | --h1-font-size: 22px; 30 | --h2-font-size: 18px; 31 | --h3-font-size: 15px; 32 | --h4-font-size: 14px; 33 | --h5-font-size: 14px; 34 | --h6-font-size: 14px; 35 | --h1-font-weight: 900; 36 | --h2-font-weight: 700; 37 | --h3-font-weight: 500; 38 | --h4-font-weight: 500; 39 | --h5-font-weight: 500; 40 | --h6-font-weight: 500; 41 | --header-text-color: var(--primary-text-color); 42 | --graph-highlighted-edge-color: #ba68c8; 43 | --graph-highlighted-node-color: #ba68c8; 44 | --md-header-hashtag-color: #bdbdbd; 45 | } 46 | 47 | .dark-theme { 48 | --primary-text-color: rgb(209, 209, 209); 49 | --low-contrast-text-color: #bfbfbf; 50 | --primary-background-color: #2e2e2e; 51 | --secondary-background-color: #1c1c1c; 52 | --highlight-color: #bd9400; 53 | --warning-background-color: #bd9400; 54 | --low-contrast-highlight-color: #666600; 55 | --selected-note-color: rgb(50, 50, 50); 56 | --gutter-color: #1c1c1c; 57 | --save-status-indicator-unsaved-changes-color: #800000; 58 | --save-status-indicator-saving-color: #808000; 59 | --save-status-indicator-everything-saved-color: initial; 60 | --header-text-color: var(--primary-text-color); 61 | --nested-tag-gutter-color: #4d4d4d; 62 | --graph-highlighted-edge-color: #8BC34A; 63 | --graph-highlighted-node-color: #8BC34A; 64 | --md-header-hashtag-color: #6e6e6e; 65 | } 66 | 67 | 68 | /* Styles for dynamic content */ 69 | cn-editor #markdown-renderer img { 70 | max-width: 100%; 71 | } 72 | -------------------------------------------------------------------------------- /src/app/editor/editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import {EditorComponent} from './editor.component'; 3 | import {MatDialog} from '@angular/material/dialog'; 4 | import {StorageService} from '../storage.service'; 5 | import {SubviewManagerService} from '../subview-manager.service'; 6 | import {SettingsService, Theme} from '../settings.service'; 7 | import {NotificationService} from '../notification.service'; 8 | import {MatSnackBar} from '@angular/material/snack-bar'; 9 | import {DomSanitizer} from '@angular/platform-browser'; 10 | import {BehaviorSubject, Subject} from 'rxjs'; 11 | import {MatInputModule} from '@angular/material/input'; 12 | import {FormsModule} from '@angular/forms'; 13 | import {MatMenuModule} from '@angular/material/menu'; 14 | 15 | 16 | describe('EditorComponent', () => { 17 | let component: EditorComponent; 18 | let fixture: ComponentFixture; 19 | 20 | const dialog = { open: () => {} }; 21 | const storage = { }; 22 | const subviewManager = { 23 | noteTitleChanged: new Subject(), 24 | tagChanged: new Subject(), 25 | }; 26 | const settings = { themeSetting: new BehaviorSubject(Theme.DARK) }; 27 | const notifications = { noteSaved: () => {} }; 28 | const snackBar = { }; 29 | const sanitizer = { }; 30 | 31 | beforeEach(async () => { 32 | await TestBed.configureTestingModule({ 33 | declarations: [ EditorComponent ], 34 | providers: [ 35 | { provide: MatDialog, useValue: dialog }, 36 | { provide: StorageService, useValue: storage }, 37 | { provide: SubviewManagerService, useValue: subviewManager }, 38 | { provide: SettingsService, useValue: settings }, 39 | { provide: NotificationService, useValue: notifications }, 40 | { provide: MatSnackBar, useValue: snackBar }, 41 | { provide: DomSanitizer, useValue: sanitizer }, 42 | ], 43 | imports: [ 44 | MatInputModule, 45 | FormsModule, 46 | MatMenuModule, 47 | ], 48 | }) 49 | .compileComponents(); 50 | }); 51 | 52 | beforeEach(() => { 53 | fixture = TestBed.createComponent(EditorComponent); 54 | component = fixture.componentInstance; 55 | spyOn(component, 'ngOnDestroy').and.callFake(() => {}); 56 | }); 57 | 58 | it('should be created', () => { 59 | expect(component).toBeTruthy(); 60 | }); 61 | 62 | }); 63 | -------------------------------------------------------------------------------- /src/app/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {BehaviorSubject} from 'rxjs'; 3 | import {BackendStatusNotification} from './types'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class NotificationService { 9 | 10 | private unsavedNotes = new BehaviorSubject([]); 11 | private sidebarNotifications = new BehaviorSubject([]); 12 | private saveIconNotifications = new BehaviorSubject(null); 13 | private fullScreenBlockingNotifications = new BehaviorSubject(null); 14 | unsaved = this.unsavedNotes.asObservable(); 15 | saveIcon = this.saveIconNotifications.asObservable(); 16 | sidebar = this.sidebarNotifications.asObservable(); 17 | fullScreenBlocking = this.fullScreenBlockingNotifications.asObservable(); 18 | 19 | private clearStatusUpdateFns = new Map(); 20 | 21 | constructor() {} 22 | 23 | createId() { 24 | return new Date().getTime().toString() + Math.random().toString(); 25 | } 26 | 27 | // Create notification to sidebar 28 | toSidebar(notificationId: string, message: string, removeAfterMillis?: number) { 29 | const cur = this.sidebarNotifications.value.find(status => status.id === notificationId); 30 | if (cur) { 31 | cur.message = message; 32 | // If there's already timeout we'll override it with this 33 | if (this.clearStatusUpdateFns.get(cur.id)) { 34 | const timeoutFnId = this.clearStatusUpdateFns.get(cur.id); 35 | clearTimeout(timeoutFnId); 36 | } 37 | } else { 38 | const newValue = this.sidebarNotifications.value; // TODO: is copy needed? 39 | newValue.push({id: notificationId, message}); 40 | this.sidebarNotifications.next(newValue); 41 | } 42 | 43 | if (removeAfterMillis) { 44 | const timeoutFnId = window.setTimeout(() => { 45 | const newValue = this.sidebarNotifications.value.filter(s => s.id !== notificationId); 46 | this.sidebarNotifications.next(newValue); 47 | this.clearStatusUpdateFns.delete(notificationId); 48 | }, removeAfterMillis); 49 | this.clearStatusUpdateFns.set(notificationId, timeoutFnId); 50 | } 51 | } 52 | 53 | noteSaved(fileId: string) { 54 | const curValues = this.unsavedNotes.value; 55 | const newValues = curValues.filter(noteId => noteId !== fileId); 56 | if (newValues.length === 0) { 57 | this.saveIconNotifications.next('saved'); 58 | } 59 | this.unsavedNotes.next(newValues); 60 | } 61 | 62 | unsavedChanged(fileId: string) { 63 | const newValues = this.unsavedNotes.value; 64 | newValues.push(fileId); 65 | this.saveIconNotifications.next('unsaved'); 66 | this.unsavedNotes.next(newValues); 67 | } 68 | 69 | showFullScreenBlockingMessage(msg: string) { 70 | this.fullScreenBlockingNotifications.next(msg); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /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 Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/app/upload-existing-dialog/upload-existing-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, fakeAsync, flush, flushMicrotasks, TestBed, tick} from '@angular/core/testing'; 2 | 3 | import { UploadExistingDialogComponent } from './upload-existing-dialog.component'; 4 | import {StorageService} from '../storage.service'; 5 | import {MatDialogRef} from '@angular/material/dialog'; 6 | import {ChangeDetectorRef} from '@angular/core'; 7 | 8 | 9 | 10 | const createFile = (fileName: string, fullPath: string, content: string, type: string) => { 11 | const f = new File([content], fileName, { type }); 12 | (f as any).fullPath = fullPath; // dropzone provided path 13 | return f; 14 | }; 15 | 16 | 17 | describe('UploadExistingDialogComponent', () => { 18 | let component: UploadExistingDialogComponent; 19 | let fixture: ComponentFixture; 20 | 21 | let createCounter = 0; 22 | let uploadCounter = 0; 23 | const storage = { 24 | createNote: () => { 25 | createCounter++; 26 | return Promise.resolve('fake note ID ' + createCounter); 27 | }, 28 | uploadFile: () => { 29 | uploadCounter++; 30 | return Promise.resolve('file ID ' + uploadCounter); 31 | }, 32 | saveNote: () => Promise.resolve(), 33 | notes: {value: []}, 34 | attachedFiles: {value: []}, 35 | }; 36 | 37 | beforeEach(async () => { 38 | await TestBed.configureTestingModule({ 39 | declarations: [ UploadExistingDialogComponent ], 40 | providers: [ 41 | { provide: StorageService, useValue: storage }, 42 | { provide: MatDialogRef, useValue: {} }, 43 | { provide: ChangeDetectorRef, useValue: { detectChanges: () => {}}}, 44 | ], 45 | }) 46 | .compileComponents(); 47 | createCounter = 0; 48 | uploadCounter = 0; 49 | }); 50 | 51 | beforeEach(() => { 52 | fixture = TestBed.createComponent(UploadExistingDialogComponent); 53 | component = fixture.componentInstance; 54 | fixture.detectChanges(); 55 | }); 56 | 57 | it('should rename notes if duplicate', async (done) => { 58 | const note1 = new File(['asd [image](img1.jpg) qwe'], 'name', { type: 'text/plain' }); 59 | const note2 = new File(['asd [image](img2.jpg) qwe'], 'name', { type: 'text/plain' }); 60 | component.files = [note1, note2] as any[]; 61 | const createSpy = spyOn(storage, 'createNote').and.callThrough(); 62 | 63 | await component.upload(); 64 | 65 | expect(createSpy).toHaveBeenCalledTimes(2); 66 | // @ts-ignore 67 | expect(createSpy.calls.argsFor(0)).toEqual(['name']); 68 | // @ts-ignore 69 | expect(createSpy.calls.argsFor(1)).toEqual(['name (1)']); 70 | done(); 71 | }); 72 | 73 | it('should skip notes with no matching attachment', async (done) => { 74 | const note = new File(['asd [image](img1.jpg) qwe'], 'name', { type: 'text/plain' }); 75 | component.files = [note] as any[]; 76 | const contentSpy = spyOn(storage, 'saveNote').and.callThrough(); 77 | await component.upload(); 78 | 79 | expect(contentSpy).toHaveBeenCalledTimes(1); 80 | done(); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Hi everyone, thank for your interest in this project. This is the 3 | still-in-development version of Connected Notes, a note taking app based on Zettelkasten. 4 | Some major features are still missing (see below for full list). 5 | 6 | I have set up a test version at connectednotes.net - I wouldn't use it for 7 | anything serious just yet, but I'd love to get some feedback via github bugs or IRC messages. 8 | 9 | # Goals 10 | 11 | - A modern take on Zettelkasten 12 | - Progressive web app (PWA) usable from both mobile and desktop 13 | - Low upkeep cost combined with open-sourcing to give reasonable long-term support 14 | - Give users as much control over their notes as possible 15 | - Notes are synced in personal cloud (currently Google Drive works) 16 | - Store notes in plain text with any metadata stored separately 17 | - Possibly support for local notes (although so far only Chrome has any sort of filesystem API available) 18 | - Markdown based 19 | - Graph view for exploring notes easily 20 | - Open source (duh) 21 | 22 | # Current status 23 | 24 | Integration with Google Drive works - however, since this is beta 25 | changes are possible. 26 | 27 | # Running locally 28 | 29 | First, run npm install && ng serve 30 | 31 | Then, navigate to localhost:4200/gd for the Google Drive backed version. 32 | I've included an API key that works when serving on ports 4200, 5000 and 8080. 33 | This API key is separate from prod and only able to use the drive API so 34 | there shouldn't be risk in having it committed (right...?). 35 | 36 | # Major missing features 37 | 38 | - Search from note contents (not just titles) 39 | - Reasonable offline support 40 | - Settings to control eg. showing timestamps on notes 41 | - Proper testing with browsers other than Chrome 42 | - Sorting notes by different criteria (eg. creation time, modification time) 43 | - Disentangle anything firebase related from the current app 44 | - Dropbox integration 45 | - On the technical side, needs more tests (currently only some default tests exist). 46 | 47 | # Tech stack 48 | 49 | Angular (ie. Typescript) is the main framework. Codemirror is used to provide markdown 50 | highlighting, marked.js provides markdown rendering. 51 | 52 | # Comparison to other Zettelkasten-ish note taking apps 53 | 54 | While there are some 55 | other open source Zettelkasten based note taking apps (shoutout to the great 56 | Zettlr) AFAIK none of those provide direct integration with cloud storage, 57 | accessibility via web/mobile and first-class flashcard integration. 58 | 59 | Also, many such apps (open source or not) are currently based on Electron, which 60 | has its own set of problems. 61 | 62 | # Setting up your own instance 63 | 64 | The only gotcha when setting up your own instance is that you need to create 65 | Google Drive API key and replace the current one with that (the API key only 66 | allows certain domains). Aside from that, everything deploying it should be 67 | a breeze. 68 | 69 | In addition, analytics currently works only for connectednotes.net so that would need 70 | to be set up. 71 | 72 | # Contact me 73 | 74 | tsiki @ freenode/IRCnet 75 | 76 | Or just create a github bug 77 | -------------------------------------------------------------------------------- /src/app/attachments-dialog/attachments-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, Inject, OnDestroy, OnInit} from '@angular/core'; 2 | import {AttachedFile} from '../types'; 3 | import {MAT_DIALOG_DATA} from '@angular/material/dialog'; 4 | import {Sort} from '@angular/material/sort'; 5 | import {StorageService} from '../storage.service'; 6 | import {Subscription} from 'rxjs'; 7 | 8 | function compare(a: number | string, b: number | string, isAsc: boolean) { 9 | return (a < b ? -1 : 1) * (isAsc ? 1 : -1); 10 | } 11 | 12 | @Component({ 13 | selector: 'cn-attachments-dialog', 14 | template: ` 15 |
This note doesn't have any attached files.
16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
{{attachment.name}}{{attachment.mimeType}}delete_outline
33 | `, 34 | styles: [` 35 | #header { 36 | margin: 10px; 37 | } 38 | 39 | td, th { 40 | padding: 10px; 41 | } 42 | 43 | .delete-icon { 44 | cursor: pointer; 45 | } 46 | `] 47 | }) 48 | export class AttachmentsDialogComponent implements OnDestroy { 49 | 50 | attachedFiles: AttachedFile[]; 51 | noteId: string; 52 | 53 | private prevSort: Sort = {active: 'name', direction: 'asc'}; 54 | private sub: Subscription; 55 | 56 | constructor( 57 | @Inject(MAT_DIALOG_DATA) public data: any, 58 | private readonly storage: StorageService) { 59 | this.noteId = data.noteId; 60 | this.attachedFiles = this.storage.attachmentMetadata.value[this.noteId] || []; 61 | this.sortData(this.prevSort); 62 | this.sub = this.storage.attachmentMetadata.subscribe(metadata => { 63 | if (metadata) { 64 | this.attachedFiles = metadata[this.noteId] || []; 65 | this.sortData(this.prevSort); 66 | } 67 | }); 68 | } 69 | 70 | getLink(id: string) { 71 | return StorageService.fileIdToLink(id); 72 | } 73 | 74 | sortData(sort: Sort) { 75 | this.prevSort = sort; 76 | const data = this.attachedFiles.slice(); 77 | if (!sort.active || sort.direction === '') { 78 | this.attachedFiles = data; 79 | return; 80 | } 81 | this.attachedFiles = data.sort((a, b) => { 82 | const isAsc = sort.direction === 'asc'; 83 | switch (sort.active) { 84 | case 'name': return compare(a.name, b.name, isAsc); 85 | case 'mimeType': return compare(a.mimeType, b.mimeType, isAsc); 86 | default: return 0; 87 | } 88 | }); 89 | } 90 | 91 | deleteAttachment(fileId: string) { 92 | this.storage.deleteAttachment(this.noteId, fileId); 93 | } 94 | 95 | ngOnDestroy(): void { 96 | this.sub.unsubscribe(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app/flashcard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {sortAscByNumeric} from './utils'; 3 | import {Flashcard} from './types'; 4 | import {StorageService} from './storage.service'; 5 | import {SettingsService} from './settings.service'; 6 | import {BehaviorSubject, interval} from 'rxjs'; 7 | import {debounceTime} from 'rxjs/operators'; 8 | import {INITIAL_FLASHCARD_LEARNING_DATA} from './constants'; 9 | 10 | @Injectable({ 11 | providedIn: 'root' 12 | }) 13 | export class FlashcardService { 14 | 15 | flashcards = new BehaviorSubject([]); 16 | dueFlashcards = new BehaviorSubject([]); 17 | numDueFlashcards = new BehaviorSubject(0); 18 | 19 | constructor(private readonly storage: StorageService, private readonly settings: SettingsService) { 20 | // weird pattern, should probably improve this 21 | const debouncedFcs = this.storage.flashcards.pipe(debounceTime(500)); 22 | debouncedFcs.subscribe(fcs => { 23 | for (const fc of fcs) { 24 | if (!fc.nextRepetitionEpochMillis) { 25 | fc.nextRepetitionEpochMillis = this.getNextRepetitionTimeEpochMillis(fc); 26 | } 27 | } 28 | this.flashcards.next(fcs); 29 | this.dueFlashcards.next(this.getDueFlashcards(fcs)); 30 | this.numDueFlashcards.next(this.dueFlashcards.value.length); 31 | }); 32 | } 33 | 34 | // Rating is between 0 and 3 where 0 is total blackout and 3 is total recall 35 | private static getNewEasinessFactor(previous: number, rating: number) { 36 | const newEasiness = previous - 0.8 + 0.28 * rating - 0.02 * Math.pow(rating, 2); 37 | return Math.max(1.3, newEasiness); 38 | } 39 | 40 | deleteFlashcard(id: string) { 41 | this.storage.deleteFlashcard(id); 42 | } 43 | 44 | submitFlashcardRating(rating: number, fc: Flashcard) { 45 | const newLearningData = Object.assign({}, INITIAL_FLASHCARD_LEARNING_DATA); 46 | if (rating !== 0) { 47 | newLearningData.easinessFactor = FlashcardService.getNewEasinessFactor(fc.learningData.easinessFactor, rating); 48 | newLearningData.prevRepetitionIntervalMillis = new Date().getTime() - fc.learningData.prevRepetitionEpochMillis; 49 | newLearningData.prevRepetitionEpochMillis = new Date().getTime(); 50 | newLearningData.numRepetitions = fc.learningData.numRepetitions + 1; 51 | } 52 | fc.learningData = newLearningData; 53 | fc.nextRepetitionEpochMillis = this.getNextRepetitionTimeEpochMillis(fc); 54 | return this.storage.saveFlashcard(fc); 55 | } 56 | 57 | isDue(fc: Flashcard) { 58 | const curTime = new Date().getTime(); 59 | return this.getNextRepetitionTimeEpochMillis(fc) < curTime; 60 | } 61 | 62 | private getDueFlashcards(fcs: Flashcard[]) { 63 | const curTime = new Date().getTime(); 64 | const activeFcs = fcs.filter(fc => curTime >= this.getNextRepetitionTimeEpochMillis(fc)); 65 | sortAscByNumeric(activeFcs, fc => fc.learningData.prevRepetitionEpochMillis); 66 | return activeFcs; 67 | } 68 | 69 | private getNextRepetitionTimeEpochMillis(fc: Flashcard): number { 70 | const prevRepetitionIntervalMillis = fc.learningData.prevRepetitionIntervalMillis || 0; 71 | const prevRepetitionEpochMillis = fc.learningData.prevRepetitionEpochMillis || fc.createdEpochMillis; 72 | const {numRepetitions, easinessFactor} = fc.learningData; 73 | if (numRepetitions < this.settings.flashcardInitialDelayPeriod.value.length) { 74 | return prevRepetitionEpochMillis + this.settings.flashcardInitialDelayPeriod.value[numRepetitions]; 75 | } 76 | const nextInterval = prevRepetitionIntervalMillis * easinessFactor; 77 | return prevRepetitionEpochMillis + nextInterval; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/app/backends/firebase.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {BehaviorSubject, Subject} from 'rxjs'; 3 | import {BackendStatusNotification, NoteFile, NoteObject, StorageBackend, UserSettings} from '../types'; 4 | import {AngularFireAuth} from '@angular/fire/auth'; 5 | import {AngularFirestore, DocumentReference} from '@angular/fire/firestore'; 6 | import {AngularFireStorage} from '@angular/fire/storage'; 7 | import {UploadTaskSnapshot} from '@angular/fire/storage/interfaces'; 8 | 9 | @Injectable({ 10 | providedIn: 'root' 11 | }) 12 | export class FirebaseService { 13 | 14 | user?: string; 15 | notes: Subject = new Subject(); 16 | 17 | // Not used TODO: should we just delete this backend for now 18 | backendStatusNotifications: Subject = new Subject(); 19 | 20 | constructor(private fireAuth: AngularFireAuth, 21 | private readonly firestore: AngularFirestore, 22 | private storage: AngularFireStorage) { 23 | this.fireAuth.user.subscribe(newUser => { 24 | if (!newUser) { 25 | this.user = null; 26 | return; 27 | } 28 | if (newUser.uid && newUser.uid !== this.user) { 29 | this.user = newUser.uid; 30 | this.requestRefreshAllNotes(); 31 | } 32 | }); 33 | } 34 | 35 | async updateSettings(settingsKey, settingsValue) { 36 | // Do nothing - should we get rid of firebase backend? 37 | } 38 | 39 | // Might not immediately refresh all notes if the user is undefined 40 | async requestRefreshAllNotes() { 41 | if (!this.user) { 42 | return; 43 | } 44 | this.firestore.collection(`users/${this.user}/notes`) 45 | .get() 46 | .subscribe(snapshot => { 47 | const notes = snapshot.docs.map(ss => { 48 | const noteFile = ss.data() as NoteFile; 49 | const note: NoteObject = { 50 | id: ss.id, 51 | title: noteFile.title, 52 | content: noteFile.content, 53 | lastChangedEpochMillis: noteFile.lastChangedEpochMillis, 54 | }; 55 | return note; 56 | }) as NoteObject[]; 57 | this.notes.next(notes); 58 | }); 59 | } 60 | 61 | async createNote(title: string): Promise { 62 | // TODO: get the timestamp by using firebase.firestore.FieldValue.serverTimestamp() 63 | const lastChangedEpochMillis = new Date().getTime(); 64 | const newNote = { title, content: '', lastChangedEpochMillis }; 65 | const docRef: DocumentReference = await this.firestore.collection(`users/${this.user}/notes`).add(newNote); 66 | return {id: docRef.id, title, content: '', lastChangedEpochMillis }; 67 | } 68 | 69 | renameNote(noteId: string, newTitle: string): Promise { 70 | return this.firestore.collection(`users/${this.user}/notes`).doc(noteId).update({title: newTitle}); 71 | } 72 | 73 | async deleteNote(noteId: string): Promise { 74 | return this.firestore.collection(`users/${this.user}/notes`).doc(noteId).delete(); 75 | } 76 | 77 | saveContent(noteId: string, content: string): Promise { 78 | // TODO: handle save failing 79 | return this.firestore.collection(`users/${this.user}/notes`).doc(noteId).update({content}); 80 | } 81 | 82 | async saveImage(img: any, unusedFileType: string, fileName: string): Promise { 83 | return new Promise(async (resolve, reject) => { 84 | // TODO: how do we know this is an image? 85 | if (fileName.length > 0) { 86 | const ref = this.storage.ref(`/users/${this.user}/images/${fileName}`); 87 | const task: UploadTaskSnapshot = await ref.put(img); 88 | ref.getDownloadURL().subscribe(downloadLink => resolve(downloadLink)); 89 | } 90 | reject(null); 91 | }); 92 | } 93 | 94 | logout() { 95 | this.fireAuth.signOut(); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "align": { 5 | "options": [ 6 | "parameters", 7 | "statements" 8 | ] 9 | }, 10 | "array-type": false, 11 | "arrow-return-shorthand": true, 12 | "curly": [true, "ignore-same-line"], 13 | "deprecation": { 14 | "severity": "warning" 15 | }, 16 | "component-class-suffix": true, 17 | "contextual-lifecycle": true, 18 | "directive-class-suffix": true, 19 | "directive-selector": [ 20 | true, 21 | "attribute", 22 | "cn", 23 | "camelCase" 24 | ], 25 | "component-selector": [ 26 | true, 27 | "element", 28 | "cn", 29 | "kebab-case" 30 | ], 31 | "eofline": true, 32 | "import-blacklist": [ 33 | true, 34 | "rxjs/Rx" 35 | ], 36 | "import-spacing": true, 37 | "indent": { 38 | "options": [ 39 | "spaces" 40 | ] 41 | }, 42 | "max-classes-per-file": false, 43 | "max-line-length": [ 44 | true, 45 | 140 46 | ], 47 | "member-ordering": [ 48 | false, 49 | { 50 | "order": [ 51 | "static-field", 52 | "instance-field", 53 | "static-method", 54 | "instance-method" 55 | ] 56 | } 57 | ], 58 | "no-console": [ 59 | true, 60 | "debug", 61 | "info", 62 | "time", 63 | "timeEnd", 64 | "trace" 65 | ], 66 | "no-empty": false, 67 | "no-inferrable-types": [ 68 | true, 69 | "ignore-params" 70 | ], 71 | "no-non-null-assertion": true, 72 | "no-redundant-jsdoc": true, 73 | "no-switch-case-fall-through": true, 74 | "no-var-requires": false, 75 | "object-literal-key-quotes": false, 76 | "quotemark": [ 77 | true, 78 | "single", 79 | "avoid-escape" 80 | ], 81 | "semicolon": { 82 | "options": [ 83 | "always" 84 | ] 85 | }, 86 | "space-before-function-paren": { 87 | "options": { 88 | "anonymous": "never", 89 | "asyncArrow": "always", 90 | "constructor": "never", 91 | "method": "never", 92 | "named": "never" 93 | } 94 | }, 95 | "typedef-whitespace": { 96 | "options": [ 97 | { 98 | "call-signature": "nospace", 99 | "index-signature": "nospace", 100 | "parameter": "nospace", 101 | "property-declaration": "nospace", 102 | "variable-declaration": "nospace" 103 | }, 104 | { 105 | "call-signature": "onespace", 106 | "index-signature": "onespace", 107 | "parameter": "onespace", 108 | "property-declaration": "onespace", 109 | "variable-declaration": "onespace" 110 | } 111 | ] 112 | }, 113 | "variable-name": { 114 | "options": [ 115 | "ban-keywords", 116 | "check-format", 117 | "allow-pascal-case", 118 | "allow-leading-underscore" 119 | ] 120 | }, 121 | "whitespace": { 122 | "options": [ 123 | "check-branch", 124 | "check-decl", 125 | "check-operator", 126 | "check-separator", 127 | "check-type", 128 | "check-typecast" 129 | ] 130 | }, 131 | "no-conflicting-lifecycle": true, 132 | "no-host-metadata-property": true, 133 | "no-input-rename": true, 134 | "no-inputs-metadata-property": true, 135 | "no-output-native": true, 136 | "no-output-on-prefix": true, 137 | "no-output-rename": true, 138 | "no-outputs-metadata-property": true, 139 | "template-banana-in-box": true, 140 | "template-no-negated-async": true, 141 | "use-lifecycle-interface": true, 142 | "use-pipe-transform-interface": true 143 | }, 144 | "rulesDirectory": [ 145 | "codelyzer" 146 | ] 147 | } 148 | -------------------------------------------------------------------------------- /src/app/editor/highlighted-programming-languages.ts: -------------------------------------------------------------------------------- 1 | 2 | declare interface ProgrammingLanguage { 3 | mimeType: string; 4 | selectors: string[]; 5 | } 6 | 7 | // When adding languages, also import the corresponding file in editor.ts 8 | export const PROGRAMMING_LANGUAGES: ProgrammingLanguage[] = [ 9 | { 10 | mimeType: 'text/javascript', 11 | selectors: [ 'javascript', 'js' ] 12 | }, 13 | { 14 | mimeType: 'text/typescript', 15 | selectors: [ 'typescript', 'ts' ] 16 | }, 17 | { 18 | mimeType: 'text/x-csrc', 19 | selectors: [ 'c' ] 20 | }, 21 | { 22 | mimeType: 'text/x-c++src', 23 | selectors: [ 'c++', 'cpp' ] 24 | }, 25 | { 26 | mimeType: 'text/x-c++src', 27 | selectors: [ 'c++', 'cpp' ] 28 | }, 29 | { 30 | mimeType: 'text/x-csharp', 31 | selectors: [ 'c#', 'csharp' ] 32 | }, 33 | { 34 | mimeType: 'text/x-clojure', 35 | selectors: [ 'clojure' ] 36 | }, 37 | { 38 | mimeType: 'text/x-elm', 39 | selectors: [ 'elm' ] 40 | }, 41 | { 42 | mimeType: 'text/x-java', 43 | selectors: [ 'java' ] 44 | }, 45 | { 46 | mimeType: 'text/x-kotlin', 47 | selectors: [ 'kotlin', 'kt' ] 48 | }, 49 | { 50 | mimeType: 'text/x-haskell', 51 | selectors: [ 'haskell', 'hs' ] 52 | }, 53 | { 54 | mimeType: 'text/x-objectivec', 55 | selectors: [ 'objective-c', 'objectivec', 'objc' ] 56 | }, 57 | { 58 | mimeType: 'text/x-scala', 59 | selectors: [ 'scala' ] 60 | }, 61 | { 62 | mimeType: 'text/x-css', 63 | selectors: [ 'css' ] 64 | }, 65 | { 66 | mimeType: 'text/x-scss', 67 | selectors: [ 'scss' ] 68 | }, 69 | { 70 | mimeType: 'text/x-less', 71 | selectors: [ 'less' ] 72 | }, 73 | { 74 | mimeType: 'text/x-html', 75 | selectors: [ 'html' ] 76 | }, 77 | { 78 | mimeType: 'text/x-markdown', 79 | selectors: [ 'markdown', 'md' ] 80 | }, 81 | { 82 | mimeType: 'text/x-xml', 83 | selectors: [ 'xml' ] 84 | }, 85 | { 86 | mimeType: 'text/x-stex', 87 | selectors: [ 'latex', 'tex' ] 88 | }, 89 | 90 | { 91 | mimeType: 'text/x-php', 92 | selectors: [ 'php' ] 93 | }, 94 | { 95 | mimeType: 'text/x-python', 96 | selectors: [ 'python', 'py' ] 97 | }, 98 | { 99 | mimeType: 'text/x-rsrc', 100 | selectors: [ 'r' ] 101 | }, 102 | { 103 | mimeType: 'text/x-ruby', 104 | selectors: [ 'ruby', 'rb' ] 105 | }, 106 | { 107 | mimeType: 'text/x-sql', 108 | selectors: [ 'sql' ] 109 | }, 110 | { 111 | mimeType: 'text/x-swift', 112 | selectors: [ 'swift' ] 113 | }, 114 | { 115 | mimeType: 'text/x-sh', 116 | selectors: [ 'shell', 'sh', 'bash' ] 117 | }, 118 | { 119 | mimeType: 'text/x-vb', 120 | selectors: [ 'vb', 'visualbasic' ] 121 | }, 122 | { 123 | mimeType: 'text/x-yaml', 124 | selectors: [ 'yaml', 'yml' ] 125 | }, 126 | { 127 | mimeType: 'text/x-go', 128 | selectors: [ 'go' ] 129 | }, 130 | { 131 | mimeType: 'text/x-rust', 132 | selectors: [ 'rust', 'rs' ] 133 | }, 134 | { 135 | mimeType: 'text/x-julia', 136 | selectors: [ 'julia', 'jl' ] 137 | }, 138 | { 139 | mimeType: 'text/x-tcl', 140 | selectors: [ 'tcl' ] 141 | }, 142 | { 143 | mimeType: 'text/x-scheme', 144 | selectors: [ 'scheme' ] 145 | }, 146 | { 147 | mimeType: 'text/x-common-lisp', 148 | selectors: [ 'commonlisp', 'clisp' ] 149 | }, 150 | { 151 | mimeType: 'text/x-powershell', 152 | selectors: [ 'powershell' ] 153 | }, 154 | { 155 | mimeType: 'text/x-stsrc', 156 | selectors: [ 'smalltalk', 'st' ] 157 | }, 158 | ]; 159 | -------------------------------------------------------------------------------- /src/codemirror-dark-styles.css: -------------------------------------------------------------------------------- 1 | /** 2 | Name: IntelliJ IDEA darcula theme 3 | From IntelliJ IDEA by JetBrains 4 | 5 | #A9B7C6 6 | */ 7 | 8 | .CodeMirror .cm-header.md-header-hashtags { 9 | color: var(--md-header-hashtag-color); 10 | font-weight: 300; 11 | } 12 | 13 | .CodeMirror .existing-tag { 14 | font-weight: 600; 15 | } 16 | 17 | .CodeMirror .not-existing-note-link { 18 | color: #ad0017 !important; 19 | } 20 | 21 | .cm-formatted-code { 22 | font-family: monospace; 23 | } 24 | 25 | .CodeMirror.cm-s-darcula { 26 | /* Set height, width, borders, and global font properties here */ 27 | /* font-family: monospace; */ 28 | font-family: Helvetica; 29 | font-size: 14px; 30 | font-weight: var(--editor-font-weight); 31 | height: 300px; 32 | direction: ltr; 33 | } 34 | 35 | .CodeMirror-foldmarker { 36 | color: blue; 37 | font-family: arial; 38 | line-height: .3; 39 | cursor: pointer; 40 | } 41 | .CodeMirror-foldgutter { 42 | background-color: var(--primary-background-color); 43 | width: .7em; 44 | } 45 | .CodeMirror-foldgutter-open, 46 | .CodeMirror-foldgutter-folded { 47 | cursor: pointer; 48 | } 49 | .CodeMirror-foldgutter-open:after { 50 | content: "\25BE"; 51 | } 52 | .CodeMirror-foldgutter-folded:after { 53 | content: "\25B8"; 54 | } 55 | 56 | .cm-s-darcula { font-family: Consolas, Menlo, Monaco, 'Lucida Console', 'Liberation Mono', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Courier New', monospace, serif;} 57 | .cm-s-darcula.CodeMirror { background: #2B2B2B; color: var(--primary-text-color); } 58 | 59 | .cm-s-darcula span.cm-meta { color: #BBB529; } 60 | .cm-s-darcula span.cm-number { color: #6897BB; } 61 | .cm-s-darcula span.cm-keyword { color: #CC7832; line-height: 1em; font-weight: bold; } 62 | .cm-s-darcula span.cm-def { color: #A9B7C6; font-style: italic; } 63 | .cm-s-darcula span.cm-variable { color: #A9B7C6; } 64 | .cm-s-darcula span.cm-variable-2 { } /* lists */ 65 | .cm-s-darcula span.cm-variable-3 { color: #9876AA; } 66 | .cm-s-darcula span.cm-type { color: #AABBCC; font-weight: bold; } 67 | .cm-s-darcula span.cm-property { color: #FFC66D; } 68 | .cm-s-darcula span.cm-operator { color: #A9B7C6; } 69 | .cm-s-darcula span.cm-string { color: #6A8759; } 70 | .cm-s-darcula span.cm-string-2 { color: #6A8759; } 71 | .cm-s-darcula span.cm-comment { background-color: #424242; font-family: monospace; } 72 | .cm-s-darcula span.cm-link { color: #CC7832; } 73 | .cm-s-darcula span.cm-atom { color: #CC7832; } 74 | .cm-s-darcula span.cm-error { color: #BC3F3C; } 75 | .cm-s-darcula span.cm-tag { color: #629755; font-weight: bold; font-style: italic; text-decoration: underline; } 76 | .cm-s-darcula span.cm-attribute { color: #6897bb; } 77 | .cm-s-darcula span.cm-qualifier { color: #6A8759; } 78 | .cm-s-darcula span.cm-bracket { color: #A9B7C6; } 79 | .cm-s-darcula span.cm-builtin { color: #FF9E59; } 80 | .cm-s-darcula span.cm-special { color: #FF9E59; } 81 | .cm-s-darcula span.cm-matchhighlight { color: #FFFFFF; background-color: rgba(50, 89, 48, .7); font-weight: normal;} 82 | .cm-s-darcula span.cm-searching { color: #FFFFFF; background-color: rgba(61, 115, 59, .7); font-weight: normal;} 83 | 84 | .cm-s-darcula .CodeMirror-cursor { border-left: 1px solid #A9B7C6; } 85 | .cm-s-darcula .CodeMirror-activeline-background { background: #323232; } 86 | .cm-s-darcula .CodeMirror-gutters { 87 | background: #313335; 88 | /*border-right: 1px solid #313335;*/ 89 | } 90 | .cm-s-darcula .CodeMirror-guttermarker { color: #FFEE80; } 91 | .cm-s-darcula .CodeMirror-guttermarker-subtle { color: #D0D0D0; } 92 | .cm-s-darcula .CodeMirrir-linenumber { color: #606366; } 93 | .cm-s-darcula .CodeMirror-matchingbracket { background-color: #3B514D; color: #FFEF28 !important; font-weight: bold; } 94 | 95 | .cm-s-darcula div.CodeMirror-selected { background: #214283; } 96 | 97 | .CodeMirror-hints.darcula { 98 | font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; 99 | color: #9C9E9E; 100 | background-color: #3B3E3F !important; 101 | } 102 | 103 | .CodeMirror-hints.darcula .CodeMirror-hint-active { 104 | background-color: #494D4E !important; 105 | color: #9C9E9E !important; 106 | } 107 | -------------------------------------------------------------------------------- /src/app/subview-manager.service.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter, Injectable, SecurityContext} from '@angular/core'; 2 | import {BehaviorSubject, Subject} from 'rxjs'; 3 | import {ActivatedRoute, Router} from '@angular/router'; 4 | import {DomSanitizer} from '@angular/platform-browser'; 5 | import {NoteTitleChanged, TagNameChanged} from './types'; 6 | 7 | export enum ViewType { 8 | NOTE, 9 | GRAPH, 10 | FLASHCARDS, 11 | } 12 | 13 | @Injectable({ 14 | providedIn: 'root' 15 | }) 16 | export class SubviewManagerService { 17 | 18 | subviews = new BehaviorSubject([]); // array of note IDs or 'graph' or 'flashcards' 19 | activeNotes = new BehaviorSubject([]); 20 | activeSubviewIdx: number|null = null; 21 | somethingOpened = new EventEmitter(); 22 | 23 | noteTitleChanged = new Subject(); 24 | tagChanged = new Subject(); 25 | 26 | constructor( 27 | private router: Router, 28 | private activatedRoute: ActivatedRoute, 29 | private sanitizer: DomSanitizer) { 30 | this.activatedRoute.queryParamMap.subscribe(qps => { 31 | const views = qps.getAll('views').map(v => sanitizer.sanitize(SecurityContext.URL, v)); 32 | if (views.length && this.activeSubviewIdx === null) { 33 | this.activeSubviewIdx = 0; 34 | } 35 | this.subviews.next(views); 36 | const notes = views.filter(v => !['graph', 'study'].includes(v)); 37 | this.activeNotes.next(notes); 38 | }); 39 | } 40 | 41 | static getViewType(s: string): ViewType { 42 | switch (s) { 43 | case 'graph': 44 | return ViewType.GRAPH; 45 | case 'flashcards': 46 | return ViewType.FLASHCARDS; 47 | default: 48 | return ViewType.NOTE; 49 | } 50 | } 51 | 52 | renameNoteInActiveViews(oldTitle: string, newTitle: string) { 53 | this.noteTitleChanged.next({oldTitle, newTitle}); 54 | } 55 | 56 | // Rename tag in the given note if the note is opened 57 | renameTagInActiveView(activeViewNoteId: string, oldTag: string, newTag: string) { 58 | this.tagChanged.next({oldTag, newTag, affectedNoteIds: [activeViewNoteId]}); 59 | } 60 | 61 | setActiveSubview(subview: string) { 62 | this.activeSubviewIdx = 63 | this.subviews.value.findIndex(s => s === subview); 64 | } 65 | 66 | openViewInNewWindow(view: string) { 67 | const views = this.subviews.value.slice(); 68 | const idx = views.findIndex(n => n === view); 69 | if (idx >= 0) { 70 | this.activeSubviewIdx = idx; 71 | return; 72 | } 73 | views.push(view); 74 | this.updateUrl(views); 75 | this.somethingOpened.emit(); 76 | } 77 | 78 | openNoteInNewWindow(noteId: string) { 79 | this.openViewInNewWindow(noteId); 80 | } 81 | 82 | openGraphInNewWindow() { 83 | this.openViewInNewWindow('graph'); 84 | } 85 | 86 | openFlashcardsInNewWindow() { 87 | this.openViewInNewWindow('flashcards'); 88 | } 89 | 90 | openViewInActiveWindow(viewId: string) { 91 | const views = this.subviews.value.slice(); 92 | if (views.length === 0) { 93 | views.push(viewId); 94 | this.activeSubviewIdx = 0; 95 | } else { 96 | const idx = views.findIndex(n => n === viewId); 97 | if (idx >= 0) { 98 | this.activeSubviewIdx = idx; 99 | return; 100 | } 101 | views[this.activeSubviewIdx] = viewId; 102 | } 103 | this.updateUrl(views); 104 | this.somethingOpened.emit(); 105 | } 106 | 107 | openGraphInActiveWindow() { 108 | this.openViewInActiveWindow('graph'); 109 | } 110 | 111 | openFlashcardsInActiveWindow() { 112 | this.openViewInActiveWindow('flashcards'); 113 | } 114 | 115 | closeView(viewId: string) { 116 | const views = this.subviews.value.slice(); 117 | const idx = views.findIndex(n => n === viewId); 118 | views.splice(idx, 1); 119 | if (this.activeSubviewIdx >= views.length) { 120 | this.activeSubviewIdx = views.length - 1; 121 | } 122 | this.updateUrl(views); 123 | } 124 | 125 | private updateUrl(views: string[]) { 126 | this.router.navigate( 127 | [], 128 | { 129 | queryParams: { views }, 130 | }); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/app/filelist/filelist.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; 2 | import {StorageService} from '../storage.service'; 3 | import {NoteDrag, TagNesting} from '../types'; 4 | import {NotificationService} from '../notification.service'; 5 | import {SortDirection} from '../zettelkasten/zettelkasten.component'; 6 | import {SubviewManagerService} from '../subview-manager.service'; 7 | import {CdkDragDrop, CdkDropList} from '@angular/cdk/drag-drop'; 8 | import {AUTOMATICALLY_GENERATED_TAG_NAMES, ROOT_TAG_NAME} from '../constants'; 9 | 10 | @Component({ 11 | selector: 'cn-filelist', 12 | templateUrl: './filelist.component.html', 13 | styles: [``], 14 | }) 15 | export class FilelistComponent implements OnInit { 16 | @ViewChild('titleRenameInput') titleRenameInput: ElementRef; 17 | @ViewChild('contextMenu') contextMenu: ElementRef; 18 | @ViewChild('droplist') droplist: ElementRef; 19 | 20 | ROOT_TAG_NAME = ROOT_TAG_NAME; 21 | 22 | @Input() set sortDirection(direction: SortDirection) { 23 | this.currentSortDirection = direction; 24 | } 25 | get sortDirection() { 26 | return this.currentSortDirection; 27 | } 28 | 29 | private currentSortDirection: SortDirection = SortDirection.MODIFIED_NEWEST_FIRST; 30 | private lastDragEvent: NoteDrag|TagNesting; 31 | 32 | forTesting = { 33 | setLastDragEvent: (e: TagNesting|NoteDrag) => this.lastDragEvent = e, 34 | }; 35 | 36 | constructor( 37 | readonly storage: StorageService, 38 | private readonly subviewManager: SubviewManagerService, 39 | private notifications: NotificationService) { 40 | } 41 | 42 | ngOnInit(): void { 43 | } 44 | 45 | async dragEnded(e: CdkDragDrop) { 46 | this.notifications.showFullScreenBlockingMessage(null); 47 | if (!e.isPointerOverContainer) { 48 | return; 49 | } 50 | if ('oldParentTag' in this.lastDragEvent) { 51 | await this.handleTagOverTagDrag(this.lastDragEvent); 52 | } else { 53 | await this.handleNoteOverTagDrag(this.lastDragEvent); 54 | } 55 | } 56 | 57 | private static isValidNoteOverTagPosition(e: NoteDrag) { 58 | return !(AUTOMATICALLY_GENERATED_TAG_NAMES.includes(e.targetTag) 59 | || e.targetTag === e.sourceTag 60 | || !e.sourceTag 61 | || !e.targetTag); 62 | } 63 | 64 | private static isValidTagOverTagPosition(e: TagNesting) { 65 | const {oldParentTag, newParentTag, childTag} = e; 66 | return !(newParentTag === null 67 | || AUTOMATICALLY_GENERATED_TAG_NAMES.includes(newParentTag) 68 | || oldParentTag === newParentTag 69 | || newParentTag === childTag); 70 | } 71 | 72 | async handleTagOverTagDrag(e: TagNesting) { 73 | const {oldParentTag, newParentTag, childTag} = e; 74 | if (!FilelistComponent.isValidTagOverTagPosition(e)) { 75 | return; 76 | } 77 | await this.storage.changeParentTag(oldParentTag, newParentTag, childTag); 78 | } 79 | 80 | async handleNoteOverTagDrag(e: NoteDrag) { 81 | if (!FilelistComponent.isValidNoteOverTagPosition(e)) { 82 | return; 83 | } 84 | const note = this.storage.getNoteForTitleCaseInsensitive(e.noteTitle); 85 | await this.storage.replaceTags(note.id, e.sourceTag, e.targetTag); 86 | } 87 | 88 | onTagDraggedOverOtherTag(e: TagNesting) { 89 | this.lastDragEvent = e; 90 | if (!FilelistComponent.isValidTagOverTagPosition(e)) { 91 | this.notifications.showFullScreenBlockingMessage(`Cancel drag`); 92 | } else { 93 | this.notifications.showFullScreenBlockingMessage( 94 | `Nest ${e.childTag} under ${e.newParentTag} instead of ${e.oldParentTag}`); 95 | } 96 | } 97 | 98 | onNoteDraggedOverTag(e: NoteDrag) { 99 | this.lastDragEvent = e; 100 | if (!FilelistComponent.isValidNoteOverTagPosition(e)) { 101 | this.notifications.showFullScreenBlockingMessage(`Cancel drag`); 102 | } else { 103 | const maybeTruncatedTitle = e.noteTitle.length > 40 ? e.noteTitle.slice(0, 40) + '...' : e.noteTitle; 104 | this.notifications.showFullScreenBlockingMessage( 105 | `Replace tag ${e.sourceTag} with ${e.targetTag} in 106 | "${maybeTruncatedTitle}"`); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/app/backends/test-data.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | AttachedFile, 4 | AttachmentMetadata, 5 | FileMetadata, 6 | Flashcard, 7 | NoteObject, 8 | ParentTagToChildTags, 9 | StorageBackend, 10 | UserSettings 11 | } from '../types'; 12 | import {BehaviorSubject} from 'rxjs'; 13 | import {NotificationService} from '../notification.service'; 14 | import {TEST_FLASHCARDS, TEST_NESTED_TAGS, TEST_NOTES} from './test-data'; 15 | import {TEXT_MIMETYPE} from '../constants'; 16 | 17 | @Injectable({ 18 | providedIn: 'root' 19 | }) 20 | export class TestDataService implements StorageBackend { 21 | 22 | flashcards = new BehaviorSubject(TEST_FLASHCARDS); 23 | nestedTagGroups = new BehaviorSubject(TEST_NESTED_TAGS); 24 | notes = new BehaviorSubject(TEST_NOTES); 25 | storedSettings = new BehaviorSubject({}); 26 | attachedFiles: BehaviorSubject; 27 | 28 | constructor(private notifications: NotificationService) { } 29 | 30 | updateNote(noteId: string, title: string, content: string) { 31 | const note = this.notes.value.find(n => n.id === noteId); 32 | note.content = content || note.content; 33 | note.title = title || note.title; 34 | this.notes.next(this.notes.value); 35 | return Promise.resolve(); 36 | } 37 | 38 | deleteNote(noteId: any): Promise { 39 | const idx = this.notes.value.findIndex(n => n.id === noteId); 40 | this.notes.value.splice(idx, 1); 41 | this.notes.next(this.notes.value); 42 | return Promise.resolve(); 43 | } 44 | 45 | updateFlashcard(fc: Flashcard): Promise { 46 | const newFc = this.flashcards.value.find(f => f.id === f.id); 47 | Object.assign(newFc, fc); 48 | Object.assign(newFc.learningData, fc.learningData); 49 | this.flashcards.next(this.flashcards.value); 50 | return Promise.resolve(); 51 | } 52 | 53 | deleteFlashcard(fcId: string): Promise { 54 | const idx = this.flashcards.value.findIndex(f => f.id === f.id); 55 | this.flashcards.value.splice(idx, 1); 56 | this.flashcards.next(this.flashcards.value); 57 | return Promise.resolve(); 58 | } 59 | 60 | deleteUploadedFile(fileId: string): Promise { 61 | throw new Error('Method not implemented.'); 62 | } 63 | 64 | addAttachmentToNote(noteId: string, fileId: string, fileName: string, mimeType: string) { 65 | } 66 | 67 | createFlashcard(fc: Flashcard): Promise { 68 | this.flashcards.value.push(fc); 69 | this.flashcards.next(this.flashcards.value); 70 | const curTime = new Date().getTime(); 71 | return Promise.resolve({ 72 | id: Math.random().toString(), 73 | title: 'fc', 74 | mimeType: TEXT_MIMETYPE, 75 | lastChangedEpochMillis: curTime, 76 | createdEpochMillis: curTime 77 | }); 78 | } 79 | 80 | createNote(title: string): Promise { 81 | const curTime = new Date().getTime(); 82 | const id = Math.random().toString(); 83 | const note = { id, title, content: '', lastChangedEpochMillis: curTime}; 84 | this.notes.value.push(note); 85 | this.notes.next(this.notes.value); 86 | return Promise.resolve({ 87 | id, 88 | title, 89 | mimeType: TEXT_MIMETYPE, 90 | lastChangedEpochMillis: curTime, 91 | createdEpochMillis: curTime 92 | }); 93 | } 94 | 95 | initialize(): Promise { 96 | return Promise.resolve(undefined); 97 | } 98 | 99 | removeAttachmentFromNote(noteId: string, fileId: string) { 100 | } 101 | 102 | renameFile(fileId: string, newTitle: string): Promise { 103 | return Promise.resolve(undefined); 104 | } 105 | 106 | saveNestedTagGroups(nestedTagGroups: ParentTagToChildTags) { 107 | this.nestedTagGroups.next(nestedTagGroups); 108 | } 109 | 110 | saveSettings(settings: UserSettings): Promise { 111 | return Promise.resolve(undefined); 112 | } 113 | 114 | shouldUseThisBackend(): Promise { 115 | return Promise.resolve(false); 116 | } 117 | 118 | uploadFile(content: any, fileType: string, fileName: string): Promise { 119 | return Promise.resolve(''); 120 | } 121 | 122 | logout() { 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/app/editor/editor.component.html: -------------------------------------------------------------------------------- 1 | 2 | 82 | 83 |
84 | 85 |
86 | 100 | 101 |
102 | 103 | Note name must be unique 104 | 105 |
106 | 107 | 110 | 117 | 122 | 126 | 130 | 131 | 132 |
133 |
134 |
135 | 136 |
137 |
141 | 142 |
143 |
144 | 145 |
146 |
147 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "zk": { 7 | "projectType": "application", 8 | "schematics": {}, 9 | "root": "", 10 | "sourceRoot": "src", 11 | "prefix": "cn", 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/zk", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "tsconfig.app.json", 21 | "aot": true, 22 | "assets": [ 23 | "src/favicon.ico", 24 | "src/assets", 25 | "src/manifest.webmanifest" 26 | ], 27 | "styles": [ 28 | "src/styles.css", 29 | "src/codemirror-styles.css", 30 | "src/codemirror-dark-styles.css", 31 | "src/themes.scss", 32 | "./node_modules/codemirror/addon/hint/show-hint.css", 33 | ], 34 | "scripts": [] 35 | }, 36 | "configurations": { 37 | "production": { 38 | "fileReplacements": [ 39 | { 40 | "replace": "src/environments/environment.ts", 41 | "with": "src/environments/environment.prod.ts" 42 | } 43 | ], 44 | "optimization": true, 45 | "outputHashing": "all", 46 | "sourceMap": false, 47 | "extractCss": true, 48 | "namedChunks": false, 49 | "extractLicenses": true, 50 | "vendorChunk": false, 51 | "buildOptimizer": true, 52 | "budgets": [ 53 | { 54 | "type": "initial", 55 | "maximumWarning": "2mb", 56 | "maximumError": "5mb" 57 | }, 58 | { 59 | "type": "anyComponentStyle", 60 | "maximumWarning": "6kb", 61 | "maximumError": "10kb" 62 | } 63 | ], 64 | "serviceWorker": true, 65 | "ngswConfigPath": "ngsw-config.json" 66 | } 67 | } 68 | }, 69 | "serve": { 70 | "builder": "@angular-devkit/build-angular:dev-server", 71 | "options": { 72 | "browserTarget": "zk:build" 73 | }, 74 | "configurations": { 75 | "production": { 76 | "browserTarget": "zk:build:production" 77 | } 78 | } 79 | }, 80 | "extract-i18n": { 81 | "builder": "@angular-devkit/build-angular:extract-i18n", 82 | "options": { 83 | "browserTarget": "zk:build" 84 | } 85 | }, 86 | "test": { 87 | "builder": "@angular-devkit/build-angular:karma", 88 | "options": { 89 | "main": "src/test.ts", 90 | "polyfills": "src/polyfills.ts", 91 | "tsConfig": "tsconfig.spec.json", 92 | "karmaConfig": "karma.conf.js", 93 | "assets": [ 94 | "src/favicon.ico", 95 | "src/assets", 96 | "src/manifest.webmanifest" 97 | ], 98 | "styles": [ 99 | "src/styles.css" 100 | ], 101 | "scripts": [] 102 | } 103 | }, 104 | "lint": { 105 | "builder": "@angular-devkit/build-angular:tslint", 106 | "options": { 107 | "tsConfig": [ 108 | "tsconfig.app.json", 109 | "tsconfig.spec.json", 110 | "e2e/tsconfig.json" 111 | ], 112 | "exclude": [ 113 | "**/node_modules/**" 114 | ] 115 | } 116 | }, 117 | "e2e": { 118 | "builder": "@angular-devkit/build-angular:protractor", 119 | "options": { 120 | "protractorConfig": "e2e/protractor.conf.js", 121 | "devServerTarget": "zk:serve" 122 | }, 123 | "configurations": { 124 | "production": { 125 | "devServerTarget": "zk:serve:production" 126 | } 127 | } 128 | }, 129 | "deploy": { 130 | "builder": "@angular/fire:deploy", 131 | "options": {} 132 | } 133 | } 134 | } 135 | }, 136 | "defaultProject": "zk" 137 | } 138 | -------------------------------------------------------------------------------- /src/app/flashcard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {discardPeriodicTasks, fakeAsync, TestBed, tick} from '@angular/core/testing'; 2 | 3 | import {FlashcardService} from './flashcard.service'; 4 | import {Flashcard} from './types'; 5 | import {BehaviorSubject} from 'rxjs'; 6 | import {StudyComponent} from './study/study.component'; 7 | import {StorageService} from './storage.service'; 8 | import {SettingsService} from './settings.service'; 9 | import {INITIAL_FLASHCARD_LEARNING_DATA} from './constants'; 10 | 11 | 12 | 13 | class MockNoteService { 14 | flashcards: BehaviorSubject = new BehaviorSubject(null); 15 | saveFlashcard = (fc: Flashcard) => {}; 16 | } 17 | 18 | const MILLIS_PER_DAY = 24 * 60 * 60 * 1000; 19 | const INITIAL_DELAY_PERIODS = [MILLIS_PER_DAY, 6 * MILLIS_PER_DAY]; 20 | 21 | 22 | class MockSettingsService { 23 | flashcardInitialDelayPeriod = new BehaviorSubject(INITIAL_DELAY_PERIODS); 24 | } 25 | 26 | const createFlashcard = (creationTime: number, numRepetitions = 0, prevRepetitionIntervalMillis = 0) => { 27 | const learningData = Object.assign({}, INITIAL_FLASHCARD_LEARNING_DATA); 28 | learningData.numRepetitions = numRepetitions; 29 | learningData.prevRepetitionIntervalMillis = prevRepetitionIntervalMillis; 30 | return { 31 | id: 'qwe', 32 | createdEpochMillis: creationTime, 33 | lastChangedEpochMillis: creationTime, 34 | tags: [], 35 | side1: 'asd', 36 | side2: 'asd!', 37 | isTwoWay: true, 38 | learningData, 39 | }; 40 | }; 41 | 42 | function waitForFlashcardDebounce() { 43 | tick(600); 44 | } 45 | 46 | describe('FlashcardService', () => { 47 | let service: FlashcardService; 48 | let flashcards: BehaviorSubject; 49 | let storage: StorageService; 50 | 51 | beforeEach(() => { 52 | storage = new MockNoteService() as StorageService; 53 | flashcards = storage.flashcards; 54 | TestBed.configureTestingModule({ 55 | providers: [ 56 | FlashcardService, 57 | { provide: StorageService, useValue: storage }, 58 | { provide: SettingsService, useClass: MockSettingsService }, 59 | ], 60 | }); 61 | service = TestBed.inject(FlashcardService); 62 | }); 63 | 64 | it('should be due after initial delay has passed', fakeAsync( () => { 65 | jasmine.clock().mockDate(new Date(1_000_000_000 + INITIAL_DELAY_PERIODS[0] + 1)); 66 | flashcards.next([ createFlashcard( new Date(1_000_000_000).getTime()) ]); 67 | waitForFlashcardDebounce(); 68 | expect(service.dueFlashcards.value.length).toBe(1); 69 | })); 70 | 71 | it("should not be due if first delay hasn't passed", fakeAsync( () => { 72 | jasmine.clock().mockDate(new Date(1_000_000_000 + INITIAL_DELAY_PERIODS[0] - 1000)); 73 | flashcards.next([ createFlashcard( new Date(1_000_000_000).getTime()) ]); 74 | waitForFlashcardDebounce(); 75 | expect(service.dueFlashcards.value.length).toBe(0); 76 | })); 77 | 78 | it('should be due after two initial delays have passed', fakeAsync( () => { 79 | let fc = createFlashcard( new Date(1_000_000_000).getTime()); 80 | jasmine.clock().mockDate(new Date(1_000_000_000 + INITIAL_DELAY_PERIODS[0] + 1)); 81 | spyOn(storage, 'saveFlashcard'); 82 | const spy = storage.saveFlashcard as jasmine.Spy; 83 | 84 | // Submit 'successfully remembered' rating, take the saved flashcard and re-insert it to the queue 85 | service.submitFlashcardRating(3, fc); 86 | fc = spy.calls.mostRecent().args[0]; 87 | tick(INITIAL_DELAY_PERIODS[1] - 1000); 88 | flashcards.next([ fc ]); 89 | 90 | // Make sure the re-inserted flashcard is displayed at the correct time 91 | expect(service.dueFlashcards.value.length).toBe(0); 92 | tick(2000); 93 | flashcards.next([ fc ]); 94 | waitForFlashcardDebounce(); 95 | expect(service.dueFlashcards.value.length).toBe(1); 96 | service.submitFlashcardRating(3, fc); 97 | 98 | // Re-insert it once more to test delay calculation logic. The 3rd period should be at least 99 | // as long as the 2nd with rating 3 ('successfully remembered') 100 | fc = spy.calls.mostRecent().args[0]; 101 | tick(INITIAL_DELAY_PERIODS[1] - 1000); 102 | flashcards.next([ fc ]); 103 | waitForFlashcardDebounce(); 104 | expect(service.dueFlashcards.value.length).toBe(0); 105 | discardPeriodicTasks(); 106 | })); 107 | 108 | it("should reset learning and re-enter queue if couldn't remember", fakeAsync( () => { 109 | const fc = createFlashcard( new Date(1_000_000_000).getTime()); 110 | jasmine.clock().mockDate(new Date(1_000_000_000 + INITIAL_DELAY_PERIODS[0] + 1000)); 111 | flashcards.next([ fc ]); 112 | waitForFlashcardDebounce(); 113 | service.submitFlashcardRating(0, fc); 114 | waitForFlashcardDebounce(); 115 | expect(service.dueFlashcards.value.length).toBe(1); 116 | })); 117 | }); 118 | -------------------------------------------------------------------------------- /src/app/study/study.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {async, ComponentFixture, fakeAsync, flush, TestBed, waitForAsync} from '@angular/core/testing'; 2 | 3 | import {ALL_FCS_QUEUE_NAME, StudyComponent} from './study.component'; 4 | import {FlashcardService} from '../flashcard.service'; 5 | import {BehaviorSubject} from 'rxjs'; 6 | import {Flashcard} from '../types'; 7 | import {By} from '@angular/platform-browser'; 8 | import {SubviewManagerService} from '../subview-manager.service'; 9 | import {MatDialogModule} from '@angular/material/dialog'; 10 | import {MatSelectModule} from '@angular/material/select'; 11 | import {MatFormFieldModule} from '@angular/material/form-field'; 12 | import {NoopAnimationsModule} from '@angular/platform-browser/animations'; 13 | import {MatIconModule} from '@angular/material/icon'; 14 | import {MatMenuModule} from '@angular/material/menu'; 15 | import {INITIAL_FLASHCARD_LEARNING_DATA} from '../constants'; 16 | 17 | describe('StudyComponent', () => { 18 | let component: StudyComponent; 19 | let fixture: ComponentFixture; 20 | let flashcardService; 21 | let subviewManager; 22 | 23 | beforeEach(waitForAsync(() => { 24 | flashcardService = { 25 | flashcards: new BehaviorSubject([]), 26 | submitFlashcardRating: () => {}, 27 | isDue: () => true, 28 | }; 29 | 30 | subviewManager = { 31 | closeView: () => {}, 32 | }; 33 | 34 | TestBed.configureTestingModule({ 35 | declarations: [ StudyComponent ], 36 | imports: [ 37 | MatDialogModule, 38 | MatSelectModule, 39 | MatFormFieldModule, 40 | NoopAnimationsModule, 41 | MatIconModule, 42 | MatMenuModule, 43 | ], 44 | providers: [ 45 | { provide: FlashcardService, useValue: flashcardService }, 46 | { provide: SubviewManagerService, useValue: subviewManager }, 47 | ], 48 | }) 49 | .compileComponents(); 50 | })); 51 | 52 | beforeEach(() => { 53 | fixture = TestBed.createComponent(StudyComponent); 54 | component = fixture.componentInstance; 55 | }); 56 | 57 | // test cases: 58 | // remove: if FC is in two queues we might mess up here, it needs to be removed from both 59 | // that FCs end up in 'all FCs' and/or 'due FCs' queue 60 | 61 | it("receive one flashcard, submit rating, ensure it's not shown again", fakeAsync(() => { 62 | const fc = { 63 | id: '1', 64 | side1: 'visible side', 65 | side2: 'hidden side', 66 | tags: [], 67 | learningData: INITIAL_FLASHCARD_LEARNING_DATA, 68 | } as Flashcard; 69 | 70 | const spy = spyOn(flashcardService, 'submitFlashcardRating'); 71 | fixture.detectChanges(); 72 | flashcardService.flashcards.next([ fc ]); 73 | fixture.detectChanges(); 74 | expect(fixture.componentInstance.displayedFc).toBeTruthy(); 75 | expect(fixture.componentInstance.front.nativeElement.innerHTML).toContain('visible side'); 76 | expect(fixture.componentInstance.back.nativeElement.innerHTML).toContain('hidden side'); 77 | const revealButton = fixture.debugElement.queryAll( 78 | By.css('#show-answer-button'))[0].nativeElement as HTMLButtonElement; 79 | revealButton.click(); 80 | fixture.detectChanges(); 81 | const remeberingWasEasyButton = fixture.debugElement.queryAll( 82 | By.css('#rating-container > button'))[0].nativeElement as HTMLButtonElement; 83 | remeberingWasEasyButton.click(); 84 | fixture.detectChanges(); 85 | expect(spy.calls.mostRecent().args).toEqual([3, fc]); 86 | flashcardService.isDue = f => false; 87 | flashcardService.flashcards.next([fc]); 88 | fixture.detectChanges(); 89 | flush(); 90 | expect(fixture.componentInstance.displayedFc).toBeFalsy(); 91 | expect(fixture.debugElement.query(By.css('#fc-container')).nativeElement.innerHTML) 92 | .toContain('All done'); 93 | })); 94 | 95 | it('displays queue for each tag in flashcard', fakeAsync(() => { 96 | const fc1 = { 97 | id: '1', 98 | side1: 'visible side', 99 | side2: 'hidden side', 100 | tags: ['#tag1', '#tag2'], 101 | learningData: INITIAL_FLASHCARD_LEARNING_DATA 102 | } as Flashcard; 103 | const fc2 = { 104 | id: '2', 105 | side1: 'visible side', 106 | side2: 'hidden side', 107 | tags: ['#tag2', '#tag3'], 108 | learningData: INITIAL_FLASHCARD_LEARNING_DATA 109 | } as Flashcard; 110 | 111 | fixture.detectChanges(); 112 | flashcardService.flashcards.next([ fc1, fc2 ]); 113 | fixture.detectChanges(); 114 | const queues = [...fixture.componentInstance.queueToFcs.keys()]; 115 | expect(queues).toEqual( 116 | jasmine.arrayContaining( 117 | [ALL_FCS_QUEUE_NAME, '#tag1', '#tag2', '#tag3'])); 118 | expect(fixture.componentInstance.queueToDueFcs.get(ALL_FCS_QUEUE_NAME).length).toBe(2); 119 | })); 120 | }); 121 | -------------------------------------------------------------------------------- /src/app/edit-tag-parents-dialog/edit-tag-parents-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, ElementRef, Inject, ViewChild} from '@angular/core'; 2 | import {COMMA, ENTER, SPACE} from '@angular/cdk/keycodes'; 3 | import {MatAutocomplete, MatAutocompleteSelectedEvent} from '@angular/material/autocomplete'; 4 | import {FormControl} from '@angular/forms'; 5 | import {Observable} from 'rxjs'; 6 | import {map} from 'rxjs/operators'; 7 | import {StorageService} from '../storage.service'; 8 | import {MatChipInputEvent} from '@angular/material/chips'; 9 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; 10 | import {AUTOMATICALLY_GENERATED_TAG_NAMES, ROOT_TAG_NAME} from '../constants'; 11 | import {MatSnackBar} from '@angular/material/snack-bar'; 12 | 13 | @Component({ 14 | selector: 'cn-edit-tag-parents-dialog', 15 | template: ` 16 |

Parent tags for {{tag}}

17 | 18 | Parent tags 19 | 20 | 24 | {{tag}} 25 | cancel 26 | 27 | 35 | 36 | 37 | 38 | {{tag}} 39 | 40 | 41 | 42 |
43 | 44 | 45 |
46 | `, 47 | styles: [` 48 | .chip-list { 49 | min-width: 500px; 50 | } 51 | `] 52 | }) 53 | export class EditTagParentsDialogComponent { 54 | @ViewChild('auto') matAutocomplete: MatAutocomplete; 55 | @ViewChild('parentTagInput') parentTagInput: ElementRef; 56 | 57 | tag: string; 58 | allTags: string[] = []; 59 | parentTags: string[] = []; 60 | parentTagCtrl = new FormControl(); 61 | separatorKeysCodes: number[] = [ENTER, COMMA, SPACE]; 62 | filteredParentTags: Observable; 63 | 64 | private originalParentTags: string[] = []; 65 | 66 | constructor( 67 | public dialogRef: MatDialogRef, 68 | @Inject(MAT_DIALOG_DATA) public data: any, 69 | private readonly storage: StorageService, 70 | private snackBar: MatSnackBar) { 71 | this.tag = data.tag; 72 | this.storage.tagGroups.subscribe( 73 | tgs => { 74 | const selectableParentTags = tgs.map(tg => tg.tag) 75 | .filter(tag => !AUTOMATICALLY_GENERATED_TAG_NAMES.includes(tag)); 76 | selectableParentTags.push(ROOT_TAG_NAME); 77 | this.allTags = selectableParentTags; 78 | }); 79 | this.storage.nestedTagGroups.subscribe(ntgs => { 80 | for (const [parentTag, childTags] of Object.entries(ntgs)) { 81 | if (childTags.includes(this.tag)) { 82 | this.parentTags.push(parentTag); 83 | } 84 | this.originalParentTags = this.parentTags.slice(); 85 | if (this.parentTags.length === 0) { 86 | this.parentTags.push(ROOT_TAG_NAME); 87 | } 88 | } 89 | }); 90 | this.filteredParentTags = this.parentTagCtrl.valueChanges.pipe( 91 | map((tag: string | null) => tag ? this._filter(tag) : this.allTags.slice())); 92 | } 93 | 94 | add(event: MatChipInputEvent): void { 95 | const input = event.input; 96 | const value = event.value; 97 | 98 | if ((value || '').trim()) { 99 | this.parentTags.push(value.trim()); 100 | } 101 | 102 | // Reset the input value 103 | if (input) { 104 | input.value = ''; 105 | } 106 | 107 | this.parentTagCtrl.setValue(null); 108 | } 109 | 110 | selected(event: MatAutocompleteSelectedEvent): void { 111 | this.parentTags.push(event.option.viewValue); 112 | this.parentTagInput.nativeElement.value = ''; 113 | this.parentTagCtrl.setValue(null); 114 | } 115 | 116 | removeParentTag(tag: string) { 117 | const index = this.parentTags.indexOf(tag); 118 | if (index >= 0) { 119 | this.parentTags.splice(index, 1); 120 | } 121 | this.snackBar.open( 122 | `If no tags are specified, the tag will appear at the root level.`, 123 | null, 124 | {duration: 5000}); 125 | } 126 | 127 | saveAndClose() { 128 | this.storage.updateParentTags(this.tag, this.parentTags); 129 | this.dialogRef.close(); 130 | } 131 | 132 | private _filter(value: string): string[] { 133 | const filterValue = value.toLowerCase(); 134 | return this.allTags.filter(tag => tag.toLowerCase().indexOf(filterValue) === 0); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { AngularFireModule } from '@angular/fire'; 4 | 5 | import { AppComponent } from './app.component'; 6 | import {environment} from '../environments/environment'; 7 | import { ServiceWorkerModule } from '@angular/service-worker'; 8 | import {FilelistComponent} from './filelist/filelist.component'; 9 | import { EditorComponent } from './editor/editor.component'; 10 | import { FrontpageComponent } from './frontpage/frontpage.component'; 11 | import { AppRoutingModule } from './app-routing.module'; 12 | import {CreateNoteDialog, ZettelkastenComponent} from './zettelkasten/zettelkasten.component'; // Added here 13 | import { AngularFirestoreModule } from '@angular/fire/firestore'; 14 | import { AngularFireAuthModule } from '@angular/fire/auth'; 15 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 16 | import {MatButtonModule} from '@angular/material/button'; 17 | import {MatFormFieldModule} from '@angular/material/form-field'; 18 | import {MatInputModule} from '@angular/material/input'; 19 | import {MatOptionModule, MatRippleModule} from '@angular/material/core'; 20 | import {MatDialogModule} from '@angular/material/dialog'; 21 | import {FormsModule, ReactiveFormsModule} from '@angular/forms'; 22 | import {MatIconModule} from '@angular/material/icon'; 23 | import {MatSidenavModule} from '@angular/material/sidenav'; 24 | import {MatButtonToggleModule} from '@angular/material/button-toggle'; 25 | import { GraphComponent } from './graph/graph.component'; 26 | import {MatDividerModule} from '@angular/material/divider'; 27 | import {AngularSplitModule} from 'angular-split'; 28 | import { SearchDialogComponent } from './search-dialog/search-dialog.component'; 29 | import {MatMenuModule} from '@angular/material/menu'; 30 | import {MatTooltipModule} from '@angular/material/tooltip'; 31 | import {MatListModule} from '@angular/material/list'; 32 | import {MatCardModule} from '@angular/material/card'; 33 | import {AngularFireStorageModule} from '@angular/fire/storage'; 34 | import {MatSnackBarModule} from '@angular/material/snack-bar'; 35 | import {HttpClientModule} from '@angular/common/http'; 36 | import { SettingsComponent } from './settings/settings.component'; 37 | import {MatSelectModule} from '@angular/material/select'; 38 | import { AttachmentsDialogComponent } from './attachments-dialog/attachments-dialog.component'; 39 | import {MatSortModule} from '@angular/material/sort'; 40 | import { BackreferencesDialogComponent } from './backreferences-dialog/backreferences-dialog.component'; 41 | import { AlreadyExistingNoteDirective } from './already-existing-note.directive'; 42 | import {MatChipsModule} from '@angular/material/chips'; 43 | import { StudyComponent } from './study/study.component'; 44 | import {MatTabsModule} from '@angular/material/tabs'; 45 | import { FlashcardDialogComponent } from './create-flashcard-dialog/flashcard-dialog.component'; 46 | import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; 47 | import {DragDropModule} from '@angular/cdk/drag-drop'; 48 | import { TagGroupComponent } from './tag-group/tag-group.component'; 49 | import { EditTagParentsDialogComponent } from './edit-tag-parents-dialog/edit-tag-parents-dialog.component'; 50 | import {MatAutocompleteModule} from '@angular/material/autocomplete'; 51 | import { ConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component'; 52 | import {GoogleDriveAuthConfirmationComponent} from './backends/google-drive-auth-confirmation.component'; 53 | import { UploadExistingDialogComponent } from './upload-existing-dialog/upload-existing-dialog.component'; 54 | import {MatCheckboxModule} from '@angular/material/checkbox'; 55 | 56 | @NgModule({ 57 | entryComponents: [CreateNoteDialog], // Not declared in template to must be here 58 | declarations: [ 59 | AppComponent, 60 | FilelistComponent, 61 | EditorComponent, 62 | FrontpageComponent, 63 | ZettelkastenComponent, 64 | CreateNoteDialog, 65 | GraphComponent, 66 | SearchDialogComponent, 67 | SettingsComponent, 68 | AttachmentsDialogComponent, 69 | BackreferencesDialogComponent, 70 | GoogleDriveAuthConfirmationComponent, 71 | AlreadyExistingNoteDirective, 72 | StudyComponent, 73 | FlashcardDialogComponent, 74 | TagGroupComponent, 75 | EditTagParentsDialogComponent, 76 | ConfirmationDialogComponent, 77 | UploadExistingDialogComponent, 78 | ], 79 | imports: [ 80 | BrowserModule, 81 | BrowserAnimationsModule, 82 | AngularFireModule.initializeApp(environment.firebase), 83 | AngularFireStorageModule, 84 | AngularSplitModule.forRoot(), 85 | ServiceWorkerModule.register('ngsw-worker.js', {enabled: environment.production}), 86 | AppRoutingModule, 87 | AngularFirestoreModule, 88 | AngularFireAuthModule, 89 | DragDropModule, 90 | FormsModule, 91 | HttpClientModule, 92 | BrowserAnimationsModule, 93 | MatButtonModule, 94 | MatButtonToggleModule, 95 | MatCardModule, 96 | MatDividerModule, 97 | MatFormFieldModule, 98 | MatIconModule, 99 | MatInputModule, 100 | MatListModule, 101 | MatMenuModule, 102 | MatSidenavModule, 103 | MatSnackBarModule, 104 | MatTooltipModule, 105 | MatRippleModule, 106 | MatDialogModule, 107 | MatOptionModule, 108 | MatSelectModule, 109 | MatSortModule, 110 | ReactiveFormsModule, 111 | MatChipsModule, 112 | MatTabsModule, 113 | MatProgressSpinnerModule, 114 | MatAutocompleteModule, 115 | MatCheckboxModule, 116 | ], 117 | providers: [], 118 | bootstrap: [AppComponent] 119 | }) 120 | export class AppModule { } 121 | -------------------------------------------------------------------------------- /src/app/types.d.ts: -------------------------------------------------------------------------------- 1 | import {BehaviorSubject} from 'rxjs'; 2 | import {Theme} from './settings.service'; 3 | import CodeMirror from 'codemirror'; 4 | 5 | declare interface NoteFile { 6 | title: string; // Maps to file name when exported 7 | content: string; 8 | lastChangedEpochMillis?: number; // Doesn't exist if the file hasn't been commited yet 9 | // lastChanged?: FirebaseTimestamp; // Doesn't exist if the file hasn't been commited yet 10 | } 11 | 12 | declare interface NoteObject extends NoteFile { 13 | id: string; 14 | } 15 | 16 | declare interface ParentTagToChildTags { 17 | [tag: string]: string[]; 18 | } 19 | 20 | declare interface TagGroup { 21 | tag: string; 22 | noteIds: string[]; 23 | newestNoteChangeTimestamp: number; 24 | } 25 | 26 | interface TagNesting { 27 | oldParentTag: string; 28 | newParentTag: string; 29 | childTag: string; 30 | } 31 | 32 | interface NoteDrag { 33 | noteTitle: string; 34 | sourceTag: string; 35 | targetTag: string; 36 | } 37 | 38 | declare interface FileMetadata { 39 | id: string; 40 | title: string; 41 | mimeType: string; 42 | lastChangedEpochMillis: number; 43 | createdEpochMillis: number; 44 | } 45 | 46 | declare interface FirebaseTimestamp { 47 | seconds: number; 48 | nanos: number; 49 | } 50 | 51 | declare interface NoteAndLinks { 52 | noteTitle: string; 53 | connectedTo: string[]; 54 | lastChanged: number; 55 | } 56 | 57 | interface SearchResult { 58 | titleSegments: FormattedSegment[]; 59 | contentSegments: FormattedSegment[][]; 60 | numContentMatches: number; 61 | noteId: string; 62 | } 63 | 64 | interface FormattedSegment { 65 | text: string; 66 | highlighted: boolean; 67 | } 68 | 69 | interface RenameResult { 70 | renamedBackRefCount: number; 71 | renamedNoteCount: number; 72 | status: Promise; 73 | } 74 | 75 | interface MessageStatusNotification { 76 | id: string; // A notification can be overwritten by sending another notification with the same ID 77 | message: string; // Actual message to be displayed to the user 78 | } 79 | 80 | interface BackendStatusNotification { 81 | id: string; // A notification can be overwritten by sending another notification with the same ID 82 | message: string; // Actual message to be displayed to the user 83 | } 84 | 85 | interface UserSettings { 86 | theme?: Theme; 87 | ignoredTags?: string[]; 88 | analyticsEnabled?: boolean; 89 | } 90 | 91 | interface AttachmentMetadata { 92 | [noteId: string]: AttachedFile[]; 93 | } 94 | 95 | interface AttachedFile { 96 | // The name should be viable 97 | name: string; 98 | fileId: string; 99 | mimeType: string; 100 | } 101 | 102 | interface Flashcard { 103 | id?: string; // Not set if unsaved 104 | createdEpochMillis?: number; // Not set if unsaved 105 | lastChangedEpochMillis?: number; // Not set if unsaved 106 | // Only for debugging - this might not reflect the actual next repetition time if 107 | // user has changed some settings (like initial delay period) after this was calculated 108 | nextRepetitionEpochMillis?: number; 109 | tags: string[]; 110 | side1: string; 111 | side2: string; 112 | isTwoWay: boolean; 113 | learningData: FlashcardLearningData; 114 | noteTitle?: string; // The note that was active when this was created - not always populated for early versions 115 | } 116 | 117 | interface FlashcardLearningData { 118 | easinessFactor: number; 119 | numRepetitions: number; 120 | prevRepetitionIntervalMillis: number; 121 | prevRepetitionEpochMillis: number; 122 | } 123 | 124 | // Rule for extracting a flashcard suggestion from text 125 | interface FlashcardSuggestionExtractionRule { 126 | start: RegExp; 127 | isStartInclusive?: boolean; // Defaults to false 128 | end: RegExp; 129 | isEndInclusive?: boolean; // Defaults to false 130 | description?: string; 131 | } 132 | 133 | interface FlashcardSuggestion { 134 | text: string; 135 | start: CodeMirror.Position; 136 | end: CodeMirror.Position; 137 | } 138 | 139 | export enum TextHidingLogic { 140 | HIDE_EVERYTHING_TO_RIGHT, 141 | HIDE_EVERYTHING_TO_LEFT, 142 | HIDE_MATCHING_ONLY, 143 | } 144 | 145 | interface FlashcardTextHidingRule { 146 | matcher: RegExp; 147 | hidingLogic: TextHidingLogic[]; 148 | } 149 | 150 | interface NoteTitleChanged { 151 | oldTitle: string; 152 | newTitle: string; 153 | } 154 | 155 | interface TagNameChanged { 156 | oldTag: string; 157 | newTag: string; 158 | affectedNoteIds?: string[]; // If unset, all notes are affected 159 | } 160 | 161 | interface StorageBackend { 162 | // These contain the latest notes/flashcards/etc and are synced with the backend 163 | notes: BehaviorSubject; 164 | flashcards: BehaviorSubject; 165 | attachedFiles: BehaviorSubject; 166 | storedSettings: BehaviorSubject; 167 | nestedTagGroups: BehaviorSubject; 168 | 169 | shouldUseThisBackend(): Promise; 170 | initialize(): Promise; 171 | 172 | createNote(title: string): Promise; 173 | // Updates the title and/or content of the note (update is only performed if the value is truthy). 174 | updateNote(noteId: string, title: string|null, content: string|null): Promise; 175 | deleteNote(noteId): Promise; 176 | 177 | createFlashcard(fc: Flashcard): Promise; 178 | updateFlashcard(fc: Flashcard): Promise; 179 | deleteFlashcard(fcId: string): Promise; 180 | 181 | uploadFile(content: any, filename: string, filetype: string): Promise; // for uploading larger files 182 | deleteUploadedFile(fileId: string): Promise; 183 | 184 | saveNestedTagGroups(nestedTagGroups: ParentTagToChildTags); 185 | saveSettings(settings: UserSettings): Promise; 186 | 187 | addAttachmentToNote(noteId: string, fileId: string, fileName: string, mimeType: string); 188 | removeAttachmentFromNote(noteId: string, fileId: string); 189 | 190 | logout(); 191 | } 192 | -------------------------------------------------------------------------------- /src/app/backends/test-data.ts: -------------------------------------------------------------------------------- 1 | import {Flashcard, FlashcardLearningData, NoteObject, ParentTagToChildTags} from '../types'; 2 | import {INITIAL_FLASHCARD_LEARNING_DATA} from '../constants'; 3 | 4 | export const TEST_NOTES: NoteObject[] = [ 5 | { 6 | id: '1', 7 | title: 'Connected Notes overview', 8 | content: 9 | `#connected-notes #zettelkasten 10 | 11 | Connected Notes is a note taking app that supports the [[Zettelkasten method]] and flashcards as first class citizen. 12 | 13 | # Zettelkasten support 14 | 15 | Zettelkasten support comes in two forms: support for tagging and connecting notes. 16 | 17 | ## Tagging 18 | 19 | Tagging notes can be done by placing a hashtag followed by the tag anywhere in the note, like is done on the first line of this document. You can also ignore tags by clicking on the 3-dots menu on the left and choosing 'ignore tag', to make sure it's not used to organize notes. 20 | 21 | ## Connecting notes 22 | 23 | Connecting notes is done by placing the note you want to connect to inside square brackets, like when we referenced [[Zettelkasten method]] note above. 24 | 25 | If you reference a note that doesn't exist, [[like this]], it will show up in red. 26 | 27 | # Navigation 28 | 29 | You can open notes by clicking on then in the left menu or by finding them using ctrl+shift+f. If you hold control (or cmd for mac users) at the same time, the note will be opened in a new view. 30 | 31 | If you have refenced a note, you can hold ctrl/cmd and click on the reference to jump directly to the note. You can then navigate back using the 'back' button of the browser. 32 | `, 33 | lastChangedEpochMillis: Math.random() 34 | }, 35 | { 36 | id: '2', 37 | title: 'Zettelkasten method', 38 | content: 39 | `#zettelkasten 40 | 41 | # Overview 42 | 43 | Zettelkasten is a knowledge management method based on tagging and connecting notes. 44 | 45 | Tagging notes is designed to allow for arbitrary grouping of notes. 46 | `, 47 | lastChangedEpochMillis: Math.random() 48 | }, 49 | { 50 | id: '3', 51 | title: 'TODO', 52 | content: 53 | `This is just an example of a note without any tags.`, 54 | lastChangedEpochMillis: Math.random() 55 | }, 56 | { 57 | id: '4', 58 | title: 'Texas Holdem overview', 59 | content: 60 | `#texas-holdem 61 | 62 | # Overview 63 | 64 | Texas hold 'em (also known as Texas holdem, hold 'em, and holdem) is one of the most popular variants of the card game of poker. It's usually played in groups of 2-10 people. 65 | 66 | # Rules 67 | 68 | Two players (usually) must post money to the pot. These people are called the 'blinds' because they put money to the pot without seeing their cards. These 2 people rotate after every hand. 69 | 70 | Everyone at the table is dealt 2 cards face down. Everyone can look at their cards, but won't see other players' cards. Then, the first betting round starts. 71 | 72 | The first players to the left of the last person to put money to the pot must either fold their hand (meaning they're out of the game), call (meaning they must match whatever money someone else has put to the pot) or raise (put more money to the pot than the amount to call). 73 | 74 | # Strategies 75 | 76 | It's sometimes claimed that the two basic approaches to holdem are [[game theory optimal poker]] and [[exploitative poker]], although this is a very simplistic view. Rather, some people see latter as a special case of the former. 77 | 78 | `, 79 | lastChangedEpochMillis: Math.random() 80 | }, 81 | { 82 | id: '5', 83 | title: 'Holdem preflop strategies', 84 | content: 85 | `#texas-holdem-strategies 86 | 87 | In [[Texas Holdem overview]] the position you're in is very important. The later your turn is, the better. This is because you can see what other players do before you, giving you information. 88 | 89 | # Early and middle positions 90 | 91 | Early position players are those who are first to act. These positions are considered the worst positions, and in a full-table game (~10 players) players in early position should fold the majority of their hands. However, it's considered to be a sign of a strong hand when a player in early position raises. 92 | 93 | Correspondingly, middle positions are somewhat better than early positions and they should fold slightly less hands than early positions, but the strategies are quite similar. 94 | 95 | # Late position 96 | 97 | Late positions usually refer to the button and cut-off. These players have the most information and playability and should play hands at a much higher rate than players in early or middle positions. 98 | `, 99 | lastChangedEpochMillis: Math.random() 100 | }, 101 | { 102 | id: '6', 103 | title: 'Game theory optimal poker', 104 | content: 105 | `#texas-holdem-strategies #game-theory 106 | 107 | [[Texas Holdem overview]] is a game where the optimal solution (in some sense) can be derived from game theory. 108 | 109 | Heads-up holdem (ie. involving two players) has a unique [[Nash equilibrium]], unlike the variant with more than 2 players. While this might seem like a niche case, often poker hands are quickly reduced into hands with only 2 players, so studying these spots is sensible. 110 | 111 | There's a lot of software out there that can approximate the [[Nash equilibrium]] of a situation, the most famous being piosolver. 112 | `, 113 | lastChangedEpochMillis: Math.random() 114 | }, 115 | { 116 | id: '7', 117 | title: 'Nash equilibrium', 118 | content: 119 | `#game-theory 120 | 121 | Nash equilibrium refers to a solution of a non-cooperative game involving two or more players. In a Nash equilibrium, each player is assumed to know the equilibrium strategies of the other players and no player has anything to gain by changing only his own strategy. 122 | `, 123 | lastChangedEpochMillis: Math.random() 124 | }, 125 | { 126 | id: '8', 127 | title: 'Exploitative poker', 128 | content: 129 | `#texas-holdem-strategies 130 | 131 | Exploitative poker refers to a style of poker where the focus is on trying to adjust to the playing style of the opponent to achieve maximum win rate. 132 | `, 133 | lastChangedEpochMillis: Math.random() 134 | }, 135 | ]; 136 | 137 | 138 | export const TEST_NESTED_TAGS: ParentTagToChildTags = { 139 | '#texas-holdem': ['#texas-holdem-strategies'] 140 | }; 141 | 142 | export const TEST_FLASHCARDS: Flashcard[] = [ 143 | { 144 | id: '1', 145 | createdEpochMillis: 0, 146 | lastChangedEpochMillis: 0, 147 | tags: ['#zettelkasten', '#some-another-tag'], 148 | side1: 'The two basic components of Zettelkasten are ...', 149 | side2: 'The two basic components of Zettelkasten are tagging and connecting notes', 150 | isTwoWay: false, 151 | learningData: INITIAL_FLASHCARD_LEARNING_DATA, 152 | }, 153 | { 154 | id: '2', 155 | createdEpochMillis: 0, 156 | lastChangedEpochMillis: 0, 157 | tags: ['#sample-tag', '#another-sample-tag'], 158 | side1: 'Some claim poker strategies can be roughly divided into two strategies, which are these?', 159 | side2: 'Game theory optimal and exploitative', 160 | isTwoWay: false, 161 | learningData: INITIAL_FLASHCARD_LEARNING_DATA, 162 | }, 163 | ]; 164 | 165 | // Holdem preflop strategies 166 | -------------------------------------------------------------------------------- /src/app/zettelkasten/zettelkasten.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectorRef, Component, ElementRef, HostListener, OnInit, ViewChild} from '@angular/core'; 2 | import {ActivatedRoute, Router} from '@angular/router'; 3 | import {Backend, StorageService} from '../storage.service'; 4 | import {MatDialog, MatDialogRef} from '@angular/material/dialog'; 5 | import { SplitAreaDirective } from 'angular-split'; 6 | import {SearchDialogComponent} from '../search-dialog/search-dialog.component'; 7 | import {animate, state, style, transition, trigger} from '@angular/animations'; 8 | import {SettingsComponent} from '../settings/settings.component'; 9 | import {BackendStatusNotification} from '../types'; 10 | import {SettingsService, Theme} from '../settings.service'; 11 | import {NotificationService} from '../notification.service'; 12 | import {MatSelect} from '@angular/material/select'; 13 | import {FilelistComponent} from '../filelist/filelist.component'; 14 | import {ValidateImmediatelyMatcher} from '../already-existing-note.directive'; 15 | import {SubviewManagerService, ViewType} from '../subview-manager.service'; 16 | import {FlashcardService} from '../flashcard.service'; 17 | import {ConfirmationDialogComponent, ConfirmDialogData} from '../confirmation-dialog/confirmation-dialog.component'; 18 | 19 | 20 | export enum SortDirection { 21 | MODIFIED_NEWEST_FIRST, 22 | MODIFIED_OLDEST_FIRST, 23 | ALPHABETICAL, 24 | ALPHABETICAL_REVERSED, 25 | } 26 | 27 | @Component({ 28 | selector: 'cn-zettelkasten', 29 | templateUrl: './zettelkasten.component.html', 30 | animations: [ 31 | trigger('openClose', [ 32 | state('open', style({ 33 | flex: '0 0 {{curWidth}}px', 34 | }), {params: {curWidth: 250}}), 35 | state('closed', style({ 36 | flex: '0 0 0px', 37 | })), 38 | transition('open => closed', [ 39 | animate('0.25s') 40 | ]), 41 | transition('closed => open', [ 42 | animate('0.25s') 43 | ]), 44 | ]), 45 | ], 46 | }) 47 | export class ZettelkastenComponent implements OnInit { 48 | @ViewChild('sidebarArea') sidebarArea: SplitAreaDirective; 49 | @ViewChild('sidebar') sidebar: ElementRef; 50 | @ViewChild('sortOptions') sortOptions: MatSelect; 51 | @ViewChild('filelist') filelist: FilelistComponent; 52 | 53 | theme: Theme; 54 | sidebarCollapsed: boolean; 55 | unCollapsedSidebarWidth: number; 56 | activeStatusUpdates: BackendStatusNotification[] = []; 57 | currentSortDirection = SortDirection.MODIFIED_NEWEST_FIRST; 58 | icon: string; 59 | viewType = ViewType; 60 | fullScreenMessage: string; 61 | 62 | constructor( 63 | private readonly route: ActivatedRoute, 64 | private router: Router, 65 | private readonly storage: StorageService, 66 | readonly flashcardService: FlashcardService, 67 | readonly subviewManager: SubviewManagerService, 68 | readonly settingsService: SettingsService, 69 | public dialog: MatDialog, 70 | private cdr: ChangeDetectorRef, 71 | private notifications: NotificationService, 72 | private readonly elRef: ElementRef) { } 73 | 74 | ngOnInit(): void { 75 | this.subviewManager.somethingOpened.subscribe(() => { 76 | if (this.elRef.nativeElement.getBoundingClientRect().width < 600 && !this.sidebarCollapsed) { 77 | this.toggleSidebar(); 78 | } 79 | }); 80 | this.settingsService.themeSetting.subscribe(newTheme => this.theme = newTheme); 81 | 82 | if (this.router.url.split('?')[0] === '/gd') { 83 | this.storage.initialize(Backend.GOOGLE_DRIVE); 84 | } else if (['/test', '/demo'].includes(this.router.url.split('?')[0])) { 85 | this.dialog.open(ConfirmationDialogComponent, { 86 | width: '600px', 87 | data: { 88 | title: 'Demo', 89 | message: 90 | "This is a (mostly) read-only demo of Connected Notes. It's meant for testing the overall flow and " 91 | + 'structure. Some changes are working (eg. restructuring notes) and some not (eg. creating notes), ' 92 | + 'but all changes will be lost when the page is refreshed.', 93 | confirmButtonText: 'ok', 94 | } as ConfirmDialogData, 95 | }); 96 | this.storage.initialize(Backend.TEST_DATA); 97 | } else { 98 | this.storage.initialize(Backend.FIREBASE); 99 | } 100 | 101 | this.setUpStorageBackendStatusUpdates(); 102 | } 103 | 104 | onWindowFocus(subview: string) { 105 | this.subviewManager.setActiveSubview(subview); 106 | } 107 | 108 | private setUpStorageBackendStatusUpdates() { 109 | this.notifications.sidebar.subscribe(newNotifications => { 110 | this.activeStatusUpdates = newNotifications; 111 | this.cdr.detectChanges(); 112 | }); 113 | this.notifications.saveIcon.subscribe(newIcon => this.icon = newIcon); 114 | this.notifications.fullScreenBlocking.subscribe(msg => { 115 | this.fullScreenMessage = msg; 116 | }); 117 | } 118 | 119 | logout() { 120 | this.storage.logout(); 121 | this.router.navigate(['']); 122 | } 123 | 124 | toggleSidebar() { 125 | // Keep track of sidebars width so we can also restore it to its former glory 126 | if (!this.sidebarCollapsed) { 127 | this.unCollapsedSidebarWidth = this.sidebar.nativeElement.getBoundingClientRect().width; 128 | } 129 | setTimeout(() => this.sidebarCollapsed = !this.sidebarCollapsed); 130 | } 131 | 132 | openNewNoteDialog() { 133 | const dialogRef = this.dialog.open(CreateNoteDialog); 134 | dialogRef.afterClosed().subscribe(async result => { 135 | if (result) { // result is undefined if user didn't create note 136 | const newNoteId = await this.storage.createNote(result); 137 | this.subviewManager.openNoteInNewWindow(newNoteId); 138 | } 139 | }); 140 | } 141 | 142 | openSearchDialog() { 143 | this.dialog.open(SearchDialogComponent, {position: {top: '10px'}}); 144 | } 145 | 146 | openSettings() { 147 | this.dialog.open(SettingsComponent, {position: {top: '10px'}}); 148 | } 149 | 150 | openLearnView(e) { 151 | if (e.metaKey || e.ctrlKey) { 152 | this.subviewManager.openFlashcardsInNewWindow(); 153 | } else { 154 | this.subviewManager.openFlashcardsInActiveWindow(); 155 | } 156 | } 157 | 158 | openGraphView(e) { 159 | if (e.metaKey || e.ctrlKey) { 160 | this.subviewManager.openGraphInNewWindow(); 161 | } else { 162 | this.subviewManager.openGraphInActiveWindow(); 163 | } 164 | } 165 | 166 | doSort(sortDirection: SortDirection) { 167 | this.currentSortDirection = sortDirection; 168 | } 169 | 170 | @HostListener('window:keydown', ['$event']) 171 | shortcutHandler(e) { 172 | const ctrlPressed = e.ctrlKey || e.metaKey; 173 | if (e.key === 'f' && ctrlPressed && e.shiftKey) { 174 | this.openSearchDialog(); 175 | } else if (e.key === 'k' && ctrlPressed) { 176 | this.openNewNoteDialog(); 177 | } 178 | } 179 | 180 | trackByFn(idx: number, subview: string) { 181 | return subview; 182 | } 183 | 184 | getViewType(subview: string): ViewType { 185 | return SubviewManagerService.getViewType(subview); 186 | } 187 | } 188 | 189 | @Component({ 190 | selector: 'cn-create-note-dialog', 191 | template: ` 192 | 193 | Note title 194 | 201 | 202 | Note name must be unique 203 | 204 | ` 205 | }) 206 | // tslint:disable-next-line:component-class-suffix 207 | export class CreateNoteDialog { 208 | noteTitle: string; 209 | matcher = new ValidateImmediatelyMatcher(); 210 | 211 | constructor(public dialogRef: MatDialogRef) {} 212 | 213 | close() { 214 | this.dialogRef.close(this.noteTitle.trim()); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/app/frontpage/frontpage.component.ts: -------------------------------------------------------------------------------- 1 | import {AfterViewInit, Component, ElementRef, Injector, OnDestroy, OnInit, ViewChild} from '@angular/core'; 2 | import {Router} from '@angular/router'; 3 | import {GoogleDriveService} from '../backends/google-drive.service'; 4 | import {StorageBackend} from '../types'; 5 | import {MatDialog} from '@angular/material/dialog'; 6 | import {ConfirmationDialogComponent, ConfirmDialogData} from '../confirmation-dialog/confirmation-dialog.component'; 7 | import {ANALYTICS_ENABLED_LOCAL_STORAGE_KEY} from '../constants'; 8 | import {ReplaySubject} from 'rxjs'; 9 | 10 | const RADIUS = 8; 11 | const LINE_WIDTH = 15; 12 | 13 | @Component({ 14 | selector: 'cn-frontpage', 15 | templateUrl: './frontpage.component.html', 16 | }) 17 | export class FrontpageComponent implements OnInit, AfterViewInit, OnDestroy { 18 | showSpinner = false; 19 | 20 | @ViewChild('canvas') canvas: ElementRef; 21 | 22 | private googleDriveBackend: StorageBackend; 23 | private readonly onDestroy = new ReplaySubject(1); 24 | 25 | constructor(private router: Router, private injector: Injector, public dialog: MatDialog) { 26 | this.googleDriveBackend = this.injector.get(GoogleDriveService); 27 | } 28 | 29 | async ngOnInit() { 30 | this.showSpinner = true; 31 | await this.checkIfSignedInAndMaybeRedirect(); 32 | this.showSpinner = false; 33 | } 34 | 35 | async checkIfSignedInAndMaybeRedirect() { 36 | const shouldRedirect = await this.googleDriveBackend.shouldUseThisBackend(); 37 | if (shouldRedirect) { 38 | await this.googleDriveBackend.initialize().then(() => 39 | this.router.navigate(['gd']) 40 | ); 41 | } 42 | } 43 | 44 | ngAfterViewInit(): void { 45 | // this.drawLoop(); 46 | } 47 | 48 | drawLoop() { 49 | const toDraw = Array(25).fill(true); 50 | const priorities = Array(25).fill(0); 51 | for (let i = 0; i < 25; i++) { 52 | priorities[i] = Math.random(); 53 | } 54 | 55 | this.mainDrawLoop(toDraw); 56 | 57 | let idx = -1; 58 | const drawFn = () => { 59 | let maxValIdx = -1; 60 | for (let i = 0; i < 25; i++) { 61 | if (toDraw[i] && maxValIdx === -1) { 62 | maxValIdx = i; 63 | } 64 | if (priorities[i] > priorities[maxValIdx] && toDraw[i]) { 65 | maxValIdx = i; 66 | } 67 | } 68 | toDraw[maxValIdx] = false; 69 | 70 | idx++; 71 | if (idx < toDraw.length) { 72 | this.mainDrawLoop(toDraw); 73 | } 74 | }; 75 | 76 | for (let i = 0; i < 25; i++) { 77 | setTimeout(() => drawFn(), Math.random() * 1000); 78 | } 79 | } 80 | 81 | mainDrawLoop(toDraw) { 82 | const ctx = this.canvas.nativeElement.getContext('2d'); 83 | ctx.strokeStyle = '#000'; 84 | ctx.fillStyle = '#000'; 85 | ctx.font = '94px Arial'; 86 | ctx.fillText('CONNECTED NOTES', 25, 115); 87 | // ctx.fillText('C', 360, 115); 88 | // ctx.fillText('D', 555, 115); 89 | // ctx.fillText('O', 720, 115); 90 | // ctx.fillText('S', 915, 115); 91 | 92 | ctx.strokeStyle = '#fff'; 93 | ctx.fillStyle = '#fff'; 94 | // ctx.strokeStyle = 'red'; 95 | // ctx.fillStyle = 'red'; 96 | 97 | const start1 = 180; 98 | const fns = []; 99 | fns.push(...this.drawN(ctx, start1, 50)); 100 | fns.push(...this.drawN(ctx, start1 + 68, 50)); 101 | fns.push(...this.drawE(ctx, start1 + 136, 50, -1)); 102 | 103 | const start2 = start1 + 260; 104 | fns.push(...this.drawT(ctx, start2, 50)); 105 | fns.push(...this.drawE(ctx, start2 + 63, 50, 0)); 106 | 107 | const start3 = 660; 108 | fns.push(...this.drawN(ctx, start3, 50)); 109 | 110 | const start4 = 795; 111 | fns.push(...this.drawT(ctx, start4, 50)); 112 | fns.push(...this.drawE(ctx, start4 + 62, 50, 2)); 113 | 114 | for (let i = 0; i < toDraw.length; i++) { 115 | if (toDraw[i]) { 116 | fns[i](); 117 | } 118 | } 119 | } 120 | 121 | 122 | drawT(ctx: CanvasRenderingContext2D, startX: number, startY: number) { 123 | const fn1 = () => { 124 | ctx.beginPath(); 125 | ctx.moveTo(startX - 10, startY); 126 | ctx.lineTo(startX + 55, startY); 127 | ctx.lineWidth = LINE_WIDTH; 128 | ctx.stroke(); 129 | }; 130 | 131 | const fn2 = () => { 132 | ctx.beginPath(); 133 | ctx.moveTo(startX + 22, startY + 6); 134 | ctx.lineTo(startX + 22, startY + 68); 135 | ctx.lineWidth = LINE_WIDTH; 136 | ctx.stroke(); 137 | }; 138 | 139 | return [fn1, fn2]; 140 | } 141 | 142 | drawE(ctx: CanvasRenderingContext2D, startX: number, startY: number, additionalOffset: number) { 143 | 144 | const fn1 = () => { 145 | ctx.beginPath(); 146 | ctx.moveTo(startX, startY - 10); 147 | ctx.lineTo(startX, startY + 70); 148 | ctx.lineWidth = LINE_WIDTH; 149 | ctx.stroke(); 150 | }; 151 | 152 | const offset = 3; 153 | const fn2 = () => { 154 | ctx.beginPath(); 155 | ctx.moveTo(startX + offset, startY); 156 | ctx.lineTo(startX + 50, startY); 157 | ctx.lineWidth = LINE_WIDTH; 158 | ctx.stroke(); 159 | }; 160 | 161 | const fn3 = () => { 162 | ctx.beginPath(); 163 | ctx.moveTo(startX + offset, startY + 60); 164 | ctx.lineTo(startX + 50, startY + 60); 165 | ctx.lineWidth = LINE_WIDTH; 166 | ctx.stroke(); 167 | }; 168 | 169 | const fn4 = () => { 170 | ctx.beginPath(); 171 | ctx.moveTo(startX + offset + additionalOffset, startY + 30); 172 | ctx.lineTo(startX + 45, startY + 30); 173 | ctx.lineWidth = LINE_WIDTH; 174 | ctx.stroke(); 175 | }; 176 | 177 | return [fn1, fn2, fn3, fn4]; 178 | } 179 | 180 | drawN(ctx: CanvasRenderingContext2D, startX: number, startY: number) { 181 | 182 | const fn1 = () => { 183 | ctx.beginPath(); 184 | ctx.moveTo(startX, startY - 10); 185 | ctx.lineTo(startX, startY + 70); 186 | ctx.lineWidth = LINE_WIDTH; 187 | ctx.stroke(); 188 | }; 189 | 190 | const fn2 = () => { 191 | ctx.beginPath(); 192 | ctx.moveTo(startX - 5, startY - 5); 193 | ctx.lineTo(startX + 45, startY + 70); 194 | ctx.lineWidth = LINE_WIDTH; 195 | ctx.stroke(); 196 | }; 197 | 198 | const fn3 = () => { 199 | ctx.beginPath(); 200 | ctx.moveTo(startX + 40, startY - 10); 201 | ctx.lineTo(startX + 40, startY + 70); 202 | ctx.lineWidth = LINE_WIDTH; 203 | ctx.stroke(); 204 | }; 205 | 206 | return [fn1, fn2, fn3]; 207 | } 208 | 209 | async toGd() { 210 | if (localStorage.getItem(ANALYTICS_ENABLED_LOCAL_STORAGE_KEY) === 'true') { 211 | this.router.navigate(['gd']); 212 | return; 213 | } 214 | 215 | const dialogRef = this.dialog.open(ConfirmationDialogComponent, { 216 | width: '600px', 217 | data: { 218 | title: 'Analytics usage', 219 | message: 'Would you like to enable analytics? We use analytics for detecting error states' 220 | + ' and particularly heavy usage to know which features and bug fixes should be prioritized.' 221 | + ' Enabling this option sends some anonymous data for us to analyze. Loading analytics might' 222 | + ' be blocked by adblockers - please consider disabling adblockers on this site if you would like to' 223 | + ' enable analytics (not like we have ads anyway).', 224 | confirmButtonText: 'Enable analytics', 225 | rejectButtonText: 'Disable analytics', 226 | } as ConfirmDialogData, 227 | }); 228 | 229 | const ans = await dialogRef.afterClosed().toPromise(); 230 | if (ans === undefined) { 231 | return; 232 | } 233 | if (ans) { 234 | (window as any).gtag('consent', 'default', { 235 | 'ad_storage': 'denied', 236 | 'analytics_storage': 'granted' 237 | }); 238 | } 239 | localStorage.setItem(ANALYTICS_ENABLED_LOCAL_STORAGE_KEY, ans.toString()); 240 | this.router.navigate(['gd']); 241 | } 242 | 243 | ngOnDestroy() { 244 | this.onDestroy.next(undefined); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/app/backends/in-memory-cache.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import {AttachmentMetadata, Flashcard, NoteObject, ParentTagToChildTags, UserSettings} from '../types'; 3 | import {BehaviorSubject} from 'rxjs'; 4 | import {NotificationService} from '../notification.service'; 5 | 6 | const NOTE_STORE_NAME = 'notes'; 7 | const FLASHCARD_STORE_NAME = 'flashcards'; 8 | const SETTINGS_AND_METADATA_STORE_NAME = 'settings_and_metadata'; 9 | 10 | const SETTINGS_ID = 'settings'; 11 | const ATTACHMENT_METADATA_ID = 'attachment_metadata'; 12 | const NESTED_TAG_GROUPS_ID = 'parent_tag_to_child_tag'; 13 | 14 | declare interface KeyValuePair { 15 | key: string; 16 | value: any; 17 | } 18 | 19 | @Injectable({ 20 | providedIn: 'root' 21 | }) 22 | export class InMemoryCache { 23 | 24 | notes: BehaviorSubject; 25 | flashcards: BehaviorSubject; 26 | storedSettings: BehaviorSubject; 27 | attachmentMetadata: BehaviorSubject; 28 | nestedTagGroups: BehaviorSubject; 29 | 30 | private db; 31 | private initPromise: Promise; 32 | 33 | constructor(private notifications: NotificationService) { 34 | this.initializeIndexedDb(); 35 | } 36 | 37 | /** Settings and metadata */ 38 | 39 | async upsertSettingsInCache(settings: UserSettings) { 40 | return this.addOrUpdateKeyValuePair(SETTINGS_ID, settings); 41 | } 42 | 43 | // TODO: figure out how to update the cache smoothly in case NoteObject structure changes 44 | // maybe just run indexedDB.deleteDatabase("ConnectedNotes") or change objectStore name? 45 | async addOrUpdateNoteInCache(noteId: string, lastChangedEpochMillis: number, title: string, content: string) { 46 | if (!this.db) { 47 | await this.initPromise; 48 | } 49 | const transaction = this.db.transaction(NOTE_STORE_NAME, 'readwrite'); 50 | const notes = transaction.objectStore(NOTE_STORE_NAME); 51 | const newNote: NoteObject = { 52 | id: noteId, 53 | lastChangedEpochMillis, 54 | title, 55 | content, 56 | }; 57 | 58 | const putReq = notes.put(newNote); 59 | return new Promise((resolve, reject) => { 60 | putReq.onsuccess = () => resolve(); 61 | putReq.onerror = () => reject(); 62 | }); 63 | } 64 | 65 | // TODO: the caching logic needs some tests 66 | async deleteNoteFromCache(noteId: string) { 67 | if (!this.db) { 68 | await this.initPromise; 69 | } 70 | const transaction = this.db.transaction(NOTE_STORE_NAME, 'readwrite'); 71 | const notes = transaction.objectStore(NOTE_STORE_NAME); 72 | notes.delete(noteId); 73 | } 74 | 75 | async getAllNoteIdToLastChangedTimestamp(): Promise> { 76 | if (!this.db) { 77 | await this.initPromise; 78 | } 79 | const notes = await this.getAllNotesInCache(); 80 | const noteIdToLastChanged = new Map(); 81 | for (const {id, lastChangedEpochMillis} of notes) { 82 | noteIdToLastChanged.set(id, lastChangedEpochMillis); 83 | } 84 | return noteIdToLastChanged; 85 | } 86 | 87 | async getAllNotesInCache(): Promise { 88 | if (!this.db) { 89 | await this.initPromise; 90 | } 91 | const transaction = this.db.transaction(NOTE_STORE_NAME, 'readwrite'); 92 | const notes = transaction.objectStore(NOTE_STORE_NAME); 93 | const req = notes.getAll(); 94 | return new Promise((resolve, reject) => { 95 | req.onsuccess = (e) => { 96 | resolve(req.result); 97 | }; 98 | req.onerror = (e) => { 99 | resolve([]); 100 | }; 101 | }); 102 | } 103 | 104 | /** Flashcard caching */ 105 | 106 | async addOrUpdateFlashcardInCache(flashcardId: string, flashcard: Flashcard) { 107 | if (!this.db) { 108 | await this.initPromise; 109 | } 110 | const transaction = this.db.transaction(FLASHCARD_STORE_NAME, 'readwrite'); 111 | const flashcards = transaction.objectStore(FLASHCARD_STORE_NAME); 112 | const putReq = flashcards.put(flashcard); 113 | return new Promise((resolve, reject) => { 114 | putReq.onsuccess = () => resolve(); 115 | putReq.onerror = () => reject(); 116 | }); 117 | } 118 | 119 | async deleteFlashcardFromCache(id: string) { 120 | if (!this.db) { 121 | await this.initPromise; 122 | } 123 | const transaction = this.db.transaction(FLASHCARD_STORE_NAME, 'readwrite'); 124 | const flashcards = transaction.objectStore(FLASHCARD_STORE_NAME); 125 | flashcards.delete(id); 126 | } 127 | 128 | async getAllFlashcardIdToLastChangedTimestamp(): Promise> { 129 | if (!this.db) { 130 | await this.initPromise; 131 | } 132 | const flashcards = await this.getAllFlashcardsInCache(); 133 | const flashcardIdToLastChanged = new Map(); 134 | for (const {id, lastChangedEpochMillis} of flashcards) { 135 | flashcardIdToLastChanged.set(id, lastChangedEpochMillis); 136 | } 137 | return flashcardIdToLastChanged; 138 | } 139 | 140 | async getAllFlashcardsInCache(): Promise { 141 | if (!this.db) { 142 | await this.initPromise; 143 | } 144 | const transaction = this.db.transaction(FLASHCARD_STORE_NAME, 'readwrite'); 145 | const flashcards = transaction.objectStore(FLASHCARD_STORE_NAME); 146 | const req = flashcards.getAll(); 147 | return new Promise((resolve, reject) => { 148 | req.onsuccess = (e) => { 149 | resolve(req.result); 150 | }; 151 | req.onerror = (e) => { 152 | resolve([]); 153 | }; 154 | }); 155 | } 156 | 157 | async getSettingsInCache(): Promise { 158 | return this.getByKeyFromKeyValuePair(SETTINGS_ID); 159 | } 160 | 161 | async upsertAttachmentMetadataInCache(settings: UserSettings) { 162 | return this.addOrUpdateKeyValuePair(ATTACHMENT_METADATA_ID, settings); 163 | } 164 | 165 | async getAttachmentMetadataInCache(): Promise { 166 | return this.getByKeyFromKeyValuePair(ATTACHMENT_METADATA_ID); 167 | } 168 | 169 | async upsertNestedTagGroupsInCache(nestedTagGroups: ParentTagToChildTags) { 170 | return this.addOrUpdateKeyValuePair(NESTED_TAG_GROUPS_ID, nestedTagGroups); 171 | } 172 | 173 | async getNestedTagGroupsInCache(): Promise { 174 | return this.getByKeyFromKeyValuePair(NESTED_TAG_GROUPS_ID); 175 | } 176 | 177 | private initializeIndexedDb() { 178 | const openRequest = indexedDB.open('ConnectedNotes', 3); 179 | this.initPromise = new Promise((resolve, reject) => { 180 | openRequest.onblocked = (unused) => { 181 | // If some other tab is loaded with the database, then it needs to be closed 182 | // before we can proceed. 183 | this.notifications.showFullScreenBlockingMessage( 184 | 'Connected Notes must update. Please close all other tabs with this site open.'); 185 | }; 186 | openRequest.onupgradeneeded = (e) => { 187 | this.db = openRequest.result; 188 | if (!this.db.objectStoreNames.contains(NOTE_STORE_NAME)) { 189 | this.db.createObjectStore(NOTE_STORE_NAME, {keyPath: 'id'}); 190 | } 191 | if (!this.db.objectStoreNames.contains(FLASHCARD_STORE_NAME)) { 192 | this.db.createObjectStore(FLASHCARD_STORE_NAME, {keyPath: 'id'}); 193 | } 194 | if (!this.db.objectStoreNames.contains(SETTINGS_AND_METADATA_STORE_NAME)) { 195 | this.db.createObjectStore(SETTINGS_AND_METADATA_STORE_NAME, {keyPath: 'key'}); 196 | } 197 | resolve(); 198 | }; 199 | openRequest.onsuccess = () => { 200 | this.db = openRequest.result; 201 | resolve(); 202 | }; 203 | openRequest.onerror = () => { 204 | reject(); 205 | }; 206 | }); 207 | } 208 | 209 | private async addOrUpdateKeyValuePair(key: string, value: any) { 210 | if (!this.db) { 211 | await this.initPromise; 212 | } 213 | const transaction = this.db.transaction(SETTINGS_AND_METADATA_STORE_NAME, 'readwrite'); 214 | const settingsAndMetadata = transaction.objectStore(SETTINGS_AND_METADATA_STORE_NAME); 215 | const obj: KeyValuePair = { key, value }; 216 | const putReq = settingsAndMetadata.put(obj); 217 | return new Promise((resolve, reject) => { 218 | putReq.onsuccess = () => resolve(); 219 | putReq.onerror = () => reject(); 220 | }); 221 | } 222 | 223 | private async getByKeyFromKeyValuePair(key: string) { 224 | if (!this.db) { 225 | await this.initPromise; 226 | } 227 | const transaction = this.db.transaction(SETTINGS_AND_METADATA_STORE_NAME, 'readwrite'); 228 | const store = transaction.objectStore(SETTINGS_AND_METADATA_STORE_NAME); 229 | const req = store.get(key); 230 | return new Promise((resolve, reject) => { 231 | req.onsuccess = (e) => { 232 | const res: KeyValuePair = req.result; 233 | resolve(res.value); 234 | }; 235 | req.onerror = (e) => { 236 | reject(); 237 | }; 238 | }); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/app/filelist/filelist.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {async, ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; 2 | 3 | import {FilelistComponent} from './filelist.component'; 4 | import {StorageService} from '../storage.service'; 5 | import {ParentTagToChildTags, TagGroup} from '../types'; 6 | import {BehaviorSubject} from 'rxjs'; 7 | import {NotificationService} from '../notification.service'; 8 | import {ROOT_TAG_NAME} from '../constants'; 9 | import {By} from '@angular/platform-browser'; 10 | import {SubviewManagerService} from '../subview-manager.service'; 11 | import {TagGroupComponent} from '../tag-group/tag-group.component'; 12 | import {MatDialog} from '@angular/material/dialog'; 13 | import {SettingsService} from '../settings.service'; 14 | import {MatMenuModule} from '@angular/material/menu'; 15 | import {MatIconModule} from '@angular/material/icon'; 16 | import {SortDirection} from '../zettelkasten/zettelkasten.component'; 17 | import {CdkDragDrop} from '@angular/cdk/drag-drop'; 18 | 19 | describe('FilelistComponent', () => { 20 | let component: FilelistComponent; 21 | let fixture: ComponentFixture; 22 | let storageService; 23 | let notifications; 24 | let subviewManager; 25 | 26 | beforeEach(async(() => { 27 | const tagGroups = new BehaviorSubject([]); 28 | storageService = { 29 | nestedTagGroups: new BehaviorSubject(null), 30 | tagGroups, 31 | getTagGroupForTag: tag => tagGroups.value.find(tg => tg.tag === tag), 32 | getNote: noteId => ({ id: noteId, title: noteId, lastChangedEpochMillis: 0 }), 33 | changeParentTag: () => {}, 34 | }; 35 | 36 | notifications = { 37 | unsaved: new BehaviorSubject([]), 38 | showFullScreenBlockingMessage: () => {}, 39 | }; 40 | 41 | subviewManager = { 42 | activeNotes: new BehaviorSubject([]), 43 | }; 44 | 45 | TestBed.configureTestingModule({ 46 | declarations: [ 47 | FilelistComponent, 48 | TagGroupComponent, 49 | ], 50 | imports: [ 51 | MatMenuModule, 52 | MatIconModule, 53 | ], 54 | providers: [ 55 | { provide: StorageService, useValue: storageService }, 56 | { provide: NotificationService, useValue: notifications }, 57 | { provide: SettingsService, useValue: {} }, 58 | { provide: SubviewManagerService, useValue: subviewManager }, 59 | { provide: MatDialog, useValue: {} }, 60 | ], 61 | }) 62 | .compileComponents(); 63 | })); 64 | 65 | beforeEach(() => { 66 | fixture = TestBed.createComponent(FilelistComponent); 67 | component = fixture.componentInstance; 68 | fixture.detectChanges(); 69 | }); 70 | 71 | it('should only show tags at the root level which have explicit root as parent or no parents', 72 | fakeAsync(() => { 73 | 74 | const ptct: ParentTagToChildTags = { 75 | '#root-tag-implicitly': ['#root-tag-explicitly-attached', '#non-root-tag'], 76 | [ROOT_TAG_NAME]: ['#root-tag-explicitly-attached'], 77 | }; 78 | const tagGroups: TagGroup[] = [ 79 | { tag: '#root-tag-implicitly', noteIds: ['id1', 'id2'], newestNoteChangeTimestamp: 0 }, 80 | { tag: '#root-tag-explicitly-attached', noteIds: ['id3', 'id4'], newestNoteChangeTimestamp: 0 }, 81 | { tag: '#non-root-tag', noteIds: ['id5', 'id6'], newestNoteChangeTimestamp: 0 }, 82 | ]; 83 | storageService.nestedTagGroups.next(ptct); 84 | storageService.tagGroups.next(tagGroups); 85 | 86 | fixture.detectChanges(); 87 | flush(); 88 | 89 | // We should only see the root level tags, #non-root-tag is hidden by *ngIf 90 | const rootTagGroups = fixture.debugElement.queryAll(By.css('cn-tag-group')); 91 | expect(rootTagGroups.length).toBe(3); 92 | 93 | // Then expand everything and check that the we have the right number of child tags 94 | for (const tg of rootTagGroups) { 95 | tg.componentInstance.expanded = true; 96 | } 97 | fixture.detectChanges(); 98 | flush(); 99 | const tagGroupsAfterExpanding = fixture.debugElement.queryAll(By.css('cn-tag-group')); 100 | expect(tagGroupsAfterExpanding.length).toBe(5); 101 | })); 102 | 103 | it('should sort tags', fakeAsync(() => { 104 | const tagGroups: TagGroup[] = [ 105 | { tag: '#rt1', noteIds: ['id1', 'id2'], newestNoteChangeTimestamp: 1 }, 106 | { tag: '#rt2', noteIds: ['id3', 'id4'], newestNoteChangeTimestamp: 2 }, 107 | { tag: '#rt3', noteIds: ['id5', 'id6'], newestNoteChangeTimestamp: 4 }, 108 | { tag: '#rt4', noteIds: ['id5', 'id6'], newestNoteChangeTimestamp: 3 }, 109 | ]; 110 | storageService.nestedTagGroups.next({}); 111 | storageService.tagGroups.next(tagGroups); 112 | 113 | // Default sorting is SortDirection.MODIFIED_NEWEST_FIRST 114 | fixture.detectChanges(); 115 | flush(); 116 | let rootTagGroups = fixture.debugElement.queryAll(By.css('cn-tag-group')); 117 | let tags = rootTagGroups 118 | .filter(tf => !tf.componentInstance.isRootTagGroup) 119 | .map(tg => tg.componentInstance.tag); 120 | expect(tags).toEqual(['#rt3', '#rt4', '#rt2', '#rt1']); 121 | 122 | fixture.componentInstance.sortDirection = SortDirection.MODIFIED_OLDEST_FIRST; 123 | fixture.detectChanges(); 124 | flush(); 125 | rootTagGroups = fixture.debugElement.queryAll(By.css('cn-tag-group')); 126 | tags = rootTagGroups 127 | .filter(tf => !tf.componentInstance.isRootTagGroup) 128 | .map(tg => tg.componentInstance.tag); 129 | expect(tags).toEqual(['#rt1', '#rt2', '#rt4', '#rt3']); 130 | 131 | fixture.componentInstance.sortDirection = SortDirection.ALPHABETICAL; 132 | fixture.detectChanges(); 133 | flush(); 134 | rootTagGroups = fixture.debugElement.queryAll(By.css('cn-tag-group')); 135 | tags = rootTagGroups 136 | .filter(tf => !tf.componentInstance.isRootTagGroup) 137 | .map(tg => tg.componentInstance.tag); 138 | expect(tags).toEqual(['#rt1', '#rt2', '#rt3', '#rt4']); 139 | 140 | fixture.componentInstance.sortDirection = SortDirection.ALPHABETICAL_REVERSED; 141 | fixture.detectChanges(); 142 | flush(); 143 | rootTagGroups = fixture.debugElement.queryAll(By.css('cn-tag-group')); 144 | tags = rootTagGroups 145 | .filter(tf => !tf.componentInstance.isRootTagGroup) 146 | .map(tg => tg.componentInstance.tag); 147 | expect(tags).toEqual(['#rt4', '#rt3', '#rt2', '#rt1']); 148 | })); 149 | 150 | it('should sort notes', fakeAsync(() => { 151 | const tagGroups: TagGroup[] = [ 152 | { tag: '#rt', noteIds: ['id1', 'id2', 'id3', 'id4'], newestNoteChangeTimestamp: 0 }, 153 | ]; 154 | storageService.getNote = noteId => { 155 | switch (noteId) { 156 | case 'id1': 157 | return { id: noteId, title: noteId, lastChangedEpochMillis: 1 }; 158 | case 'id2': 159 | return { id: noteId, title: noteId, lastChangedEpochMillis: 4 }; 160 | case 'id3': 161 | return { id: noteId, title: noteId, lastChangedEpochMillis: 3 }; 162 | case 'id4': 163 | return { id: noteId, title: noteId, lastChangedEpochMillis: 2 }; 164 | } 165 | }; 166 | storageService.nestedTagGroups.next({}); 167 | storageService.tagGroups.next(tagGroups); 168 | 169 | fixture.detectChanges(); 170 | flush(); 171 | 172 | const allTags = fixture.debugElement.queryAll(By.css('cn-tag-group')); 173 | const rootTag = allTags.filter(tf => !tf.componentInstance.isRootTagGroup)[0]; 174 | rootTag.componentInstance.expanded = true; 175 | fixture.detectChanges(); 176 | flush(); 177 | expect(rootTag.componentInstance.noteIds).toEqual(['id2', 'id3', 'id4', 'id1']); 178 | 179 | fixture.componentInstance.sortDirection = SortDirection.MODIFIED_OLDEST_FIRST; 180 | fixture.detectChanges(); 181 | flush(); 182 | expect(rootTag.componentInstance.noteIds).toEqual(['id1', 'id4', 'id3', 'id2']); 183 | 184 | fixture.componentInstance.sortDirection = SortDirection.ALPHABETICAL; 185 | fixture.detectChanges(); 186 | flush(); 187 | expect(rootTag.componentInstance.noteIds).toEqual(['id1', 'id2', 'id3', 'id4']); 188 | 189 | fixture.componentInstance.sortDirection = SortDirection.ALPHABETICAL_REVERSED; 190 | fixture.detectChanges(); 191 | flush(); 192 | expect(rootTag.componentInstance.noteIds).toEqual(['id4', 'id3', 'id2', 'id1']); 193 | })); 194 | 195 | it('should create nested tags when one is drag and dropped over another', fakeAsync(() => { 196 | storageService.nestedTagGroups.next({}); 197 | storageService.tagGroups.next([]); 198 | 199 | fixture.componentInstance.forTesting 200 | .setLastDragEvent({oldParentTag: ROOT_TAG_NAME, newParentTag: '#rt1', childTag: '#rt2'}); 201 | // fixture.componentInstance.forTesting.setLastParentTagDragged('#rt2'); 202 | 203 | const spy = spyOn(storageService, 'changeParentTag'); 204 | fixture.componentInstance.dragEnded({ isPointerOverContainer: true } as CdkDragDrop); 205 | 206 | // await this.storage.changeParentTag(oldParentTag, newParentTag, childTag); 207 | expect(spy.calls.mostRecent().args).toEqual(['(root)', '#rt1', '#rt2']); 208 | })); 209 | }); 210 | -------------------------------------------------------------------------------- /src/app/zettelkasten/zettelkasten.component.html: -------------------------------------------------------------------------------- 1 | 2 | 180 | 181 |
182 | 183 |
184 |
185 |
186 | 189 | 192 | 195 | 201 | 209 | 221 | 226 | 231 | 236 | 237 | 238 |
239 |
240 |
241 | 245 | check 246 | clear 247 | 248 | 251 | 254 |
255 |
256 | 257 | 258 | 262 |
263 | 264 | 265 | 266 |
267 |
268 |
269 | {{ statusUpdate.message }} 270 |
271 |
272 |
273 |
274 |
275 | 276 | 277 |
278 |

{{fullScreenMessage}}

279 |
280 |
281 |
284 | 285 | 286 | 287 | 288 |
289 |
290 |
291 |
292 |
293 | -------------------------------------------------------------------------------- /src/app/frontpage/frontpage.component.html: -------------------------------------------------------------------------------- 1 | 2 | 95 | 96 |
97 | 98 |
99 | 100 |
101 | 102 |

CONNECTED NOTES

103 | 104 | Demo (read-only) 105 | 106 | Try the beta version (Google Drive authorization required) 107 | 108 |
109 |

110 | Connected Notes is a tool for non-categorical notes and flashcards. It encourages 111 | usage of the Zettelkasten method and spaced repetition, 112 | and is open source and free to use. 113 |

114 | 115 |

116 | Connected Notes is in beta, so expect some roughness. 117 | Please report any bugs at the project's 118 | GitHub page. 119 |

120 | 121 |

Table of contents

122 |
    123 |
  • Main features
  • 124 |
  • Getting started
  • 125 |
  • Running your own instance
  • 126 |
  • Future work
  • 127 |
128 | 129 |

Main features

130 | 131 |

132 | Notes are stored in your personal cloud service, meaning they're accessible from anywhere, including 133 | mobile. 134 | Currently the only backend option is Google Drive, but Connected Notes is designed with interchangeable backends 135 | in mind, and the next major feature will be local filesystem support (using 136 | File System Access API). 137 |

138 | 139 |

140 | Connected Notes supports the Zettelkasten method. This means first class support for tagging and 141 | connecting notes - in fact, Connected Notes doesn't use folders to organize notes, but uses the tags you've 142 | added to structure them (see 'getting started' below). 143 |

144 | 145 |

146 | Notes are written using markdown and stored as plain text markdown files. 147 | This means your notes aren't locked in to a proprietary format, and since 148 | they're stored on your personal cloud you'll always have access to them, even if this site was to shut down one 149 | day. 150 |

151 | 152 |

153 | Connected Notes is free and open source. The project is designed to support notes that will live a 154 | long time (as any note taking system should), and open sourcing is an essential part of that promise. 155 |

156 | 157 |

158 | Spaced repetition (ie. flashcards) has been shown to give superior results in learning tasks, not only 159 | when measuring the ability to recall, but also when making inferences from learned material 160 | (source). The current version of Connected Notes 161 | has flashcard support built-in. The support is currently quite simple but more features will appear in the future. 162 |

163 | 164 | 165 | 166 | 167 | 168 | 169 |

Getting started

170 | 171 |

Creating a note

172 | 173 |

174 | Creating a note is as easy as clicking on the 'create note' icon (or pressing ctrl + k) and entering the name 175 | of the note. 176 | You don't need to choose which folder the note is added to, since folders don't exist - instead, organizing notes 177 | is based on tags (see next section). This greatly reduces the friction of creating notes and avoids having 178 | to think in strict categories and hierarchies 179 | (more). 180 |

181 | 182 |

Tagging notes

183 | 184 |

185 | Using tags is an integral part of Connected Notes. Since tags essentially replace folders, 186 | learning to use them is worthwhile. 187 | To add a tag, simply add a Twitter style hashtag anywhere to the note, like 188 | #this. You will then see the hashtag appear in the left side menu, and if you click on it, 189 | you will see all the notes that contain the tag: 190 |

191 |
192 | 193 |

194 | If a note 195 | doesn't have any hashtags, it appears under the special 'untagged' tag. Similarly, all notes appear under 'all'. 196 | Also, since notes can contain multiple tags, they can also appear under multiple tags: 197 |

198 | 199 |
200 | 201 |

202 | Tags can be nested under each other, creating a 'tag/subtag' structure. 203 | This might be sensible if you have a tag which is clearly a part of some 204 | other tag. For example, if you have tags #texas-holdem (a popular card game) and #texas-holdem-strategies, 205 | you might not want to have #texas-holdem-strategies appear on the top level. 206 |

207 | 208 |

209 | This can be achieved by dragging the child tag over the parent tag: 210 |

211 | 212 |
213 | 214 |

215 | Finally, tags can appear under multiple tags. Let's say you have tags #car-brands and #electric-cars. 216 | In a traditional 217 | folder structure you'd have to make a choice where to place #tesla, but in Connected Notes #tesla could 218 | appear under both tags. To achieve this, click on the three dots menu next to #tesla and choose 219 | the parent tags. 220 |

221 | 222 |

223 | In comparison, traditional folders are essentially 224 | equivalent to each note having a single tag and each tag having at most one other tag as their parent. 225 |

226 | 227 |

Connecting notes

228 | 229 |

230 | Notes can be connected by typing two square brackets '[[' and selecting the note you want to reference from the 231 | autocompletion list. 232 | 233 | You can also directly write the name of the note [[like so]] and the note will be linked. You can then use ctrl 234 | + click to jump to the note. If the note doesn't exist, a new note will be created when jumping into it. 235 |

236 | 237 |

Flashcards

238 | 239 |

Create a new flashcard by opening a note and pressing ctrl + j. This will open a dialog where you can edit 240 | the front and back of the card before saving it (TODO image).

241 | 242 |

By default, the flashcard is populated with the sentence your cursor is currently on. If you have selected a 243 | piece of text before pressing ctrl + j, that text will be prepopulated instead.

244 | 245 |

To study your flashcards, click on the 'study' icon in the left menu. By default, you'll be shown all due 246 | flashcards you have created.

247 | 248 |

Currently there's no interoperability with Anki but in the future I'd like to have a more Anki-like flashcard 249 | experience and allow exporting/importing.

250 | 251 |

Attachments

252 | 253 | You can upload attachments to notes by drag-n-dropping the file on the corresponding note. 254 | 255 |

Running your own instance

256 | 257 |

258 | More details on how to set up your own instance of Connected Notes will be at the 259 | GitHub page once the project exits beta. 260 |

261 | 262 |

Caveat

263 |

264 | If you're planning to start your own instance, note that Google Drive files are only accessible from whitelisted 265 | domains, so notes created in connectednotes.net won't be accessible from someotherdomain.com. The solution is to 266 | copy the files and re-upload them via the new correct domain, or whitelist the domain (although I'd discourage 267 | this). 268 |

269 | 270 | 271 |

Future work

272 | 273 |

274 | Currently the code base is lacking a lot of tests and has some known issues, but as we finalize the design 275 | and structure the number of tests should ramp up and that of issues down. 276 |

277 | 278 |

279 | 280 |

281 | 282 | 283 |
284 |
285 | -------------------------------------------------------------------------------- /src/app/search-dialog/search-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, HostBinding, OnInit} from '@angular/core'; 2 | import {MatDialogRef} from '@angular/material/dialog'; 3 | import {StorageService} from '../storage.service'; 4 | import {FormattedSegment, SearchResult} from '../types'; 5 | import {SettingsService, Theme} from '../settings.service'; 6 | import {SubviewManagerService} from '../subview-manager.service'; 7 | 8 | @Component({ 9 | selector: 'cn-search-dialog', 10 | template: ` 11 | 12 | Note search 13 | 18 | 19 |
20 |
23 | 33 |
34 |
35 | `, 36 | styles: [` 37 | :host { 38 | align-items: center; 39 | display: flex; 40 | flex-direction: column; 41 | max-height: 100vh; 42 | } 43 | 44 | #search-input { 45 | width: 200px; 46 | } 47 | 48 | .title-highlighted { 49 | background-color: var(--highlight-color); 50 | } 51 | 52 | .content-highlighted { 53 | background-color: var(--low-contrast-highlight-color); 54 | } 55 | 56 | .result-link { 57 | display: block; 58 | font-size: 18px; 59 | max-width: 100%; 60 | text-overflow: ellipsis; 61 | overflow: hidden; 62 | } 63 | 64 | #results-container { 65 | align-items: stretch; 66 | display: flex; 67 | flex-direction: column; 68 | max-width: 100%; 69 | } 70 | 71 | .focused-result { 72 | background-color: var(--selected-note-color); 73 | } 74 | 75 | .content { 76 | color: var(--low-contrast-text-color); 77 | font-size: 14px; 78 | font-weight: 400; 79 | line-height: initial; 80 | height: initial; 81 | } 82 | 83 | .result { 84 | align-items: center; 85 | display: flex; 86 | flex-direction: column; 87 | } 88 | `] 89 | }) 90 | export class SearchDialogComponent implements OnInit { 91 | 92 | noteTitle: string; 93 | searchResults: SearchResult[]; 94 | selectedListIndex = 0; 95 | 96 | @HostBinding('class.dark-theme') darkThemeActive = false; 97 | 98 | constructor( 99 | private readonly dialogRef: MatDialogRef, 100 | private readonly storage: StorageService, 101 | private readonly subviewManager: SubviewManagerService, 102 | private readonly settingsService: SettingsService) { 103 | this.settingsService.themeSetting.subscribe(theme => { 104 | this.darkThemeActive = theme === Theme.DARK; 105 | }); 106 | } 107 | 108 | close() { 109 | this.dialogRef.close(); 110 | } 111 | 112 | ngOnInit(): void { 113 | } 114 | 115 | onButtonPressed(e: MouseEvent, noteId: string) { 116 | if (e.metaKey || e.ctrlKey) { 117 | this.subviewManager.openNoteInNewWindow(noteId); 118 | } else { 119 | this.subviewManager.openViewInActiveWindow(noteId); 120 | } 121 | this.close(); 122 | } 123 | 124 | onKeyUp(e) { 125 | if (e.key === 'Enter') { 126 | const noteId = this.searchResults[this.selectedListIndex].noteId; 127 | // Checking for e.metaKey doesn't work here because keyup doesn't trigger when metakey is pressed, see 128 | // https://stackoverflow.com/questions/27380018/when-cmd-key-is-kept-pressed-keyup-is-not-triggered-for-any-other-key 129 | if (e.ctrlKey) { 130 | this.subviewManager.openNoteInNewWindow(noteId); 131 | } else { 132 | this.subviewManager.openViewInActiveWindow(noteId); 133 | } 134 | this.close(); 135 | } else if (e.key === 'ArrowDown') { 136 | this.selectedListIndex = (this.selectedListIndex + 1) % this.searchResults.length; 137 | } else if (e.key === 'ArrowUp') { 138 | this.selectedListIndex = (this.selectedListIndex + this.searchResults.length - 1) % this.searchResults.length; 139 | } else if (this.noteTitle && this.noteTitle.length > 0) { 140 | this.selectedListIndex = 0; 141 | this.searchResults = this.searchForNotesByTitle(this.noteTitle); 142 | } 143 | } 144 | 145 | // Searches notes for the corresponding term. Just search titles for now. 146 | public searchForNotesByTitle(searchTerm: string): SearchResult[] { 147 | const notes = this.storage.notes.value; 148 | 149 | // First try full match in title. 150 | const matchingNotes = notes 151 | .filter(note => note.title.toLowerCase().includes(searchTerm.toLowerCase())) 152 | .map(note => { 153 | const [numContentMatches, contentSegments] = this.getContentMatches(note.content, searchTerm); 154 | return { 155 | noteId: note.id, 156 | titleSegments: this.splitToHighlightedParts( 157 | note.title, 158 | this.getIndicesCoveredByWords(note.title.toLowerCase(), [searchTerm.toLowerCase()])), 159 | contentSegments, 160 | numContentMatches, 161 | }; 162 | }); 163 | 164 | // Then get full matches in content 165 | const alreadyAdded = new Set(matchingNotes.map(n => n.noteId)); 166 | const contentMatches = notes 167 | .filter(note => !alreadyAdded.has(note.id) && note.content.toLowerCase().includes(searchTerm.toLowerCase())) 168 | .map(note => { 169 | const [numContentMatches, contentSegments] = this.getContentMatches(note.content, searchTerm); 170 | return { 171 | noteId: note.id, 172 | titleSegments: [{ text: note.title, highlighted: false }], 173 | contentSegments, 174 | numContentMatches, 175 | }; 176 | }); 177 | 178 | matchingNotes.push(...contentMatches); 179 | 180 | // If we don't have that many full matches then try splitting the search term and checking the coverage in titles 181 | if (matchingNotes.length < 5) { 182 | const splitTerms = searchTerm.toLowerCase().split(' ').filter(term => term.length > 0); 183 | const addedNotes = new Set(matchingNotes.map(n => n.noteId)); 184 | const notesWithAtLeastOneTerm = 185 | notes.filter(n => !addedNotes.has(n.id) && splitTerms.some(term => n.title.toLowerCase().includes(term))); 186 | const highlightedTitleIndices = 187 | notesWithAtLeastOneTerm.map(note => this.getIndicesCoveredByWords(note.title.toLowerCase(), splitTerms)); 188 | const trueCounts = highlightedTitleIndices.map(indices => indices.reduce((prev, cur) => cur ? prev + 1 : prev, 0)); 189 | const trueCountPerLength = notesWithAtLeastOneTerm.map((note, idx) => trueCounts[idx] / note.title.length); 190 | const largestElementIndices = this.getLargestElementIndices(trueCountPerLength, 5); 191 | for (const idx of largestElementIndices) { 192 | const {id, title} = notesWithAtLeastOneTerm[idx]; 193 | const searchRes = { 194 | noteId: id, 195 | titleSegments: this.splitToHighlightedParts(title, highlightedTitleIndices[idx]), 196 | contentSegments: [], 197 | numContentMatches: 0, // Any content matches have been handled above 198 | }; 199 | matchingNotes.push(searchRes); 200 | } 201 | } 202 | return matchingNotes; 203 | } 204 | 205 | private getContentMatches(content: string, searchTerm: string): [number, FormattedSegment[][]] { 206 | const lcSearchTerm = searchTerm.toLowerCase(); 207 | const lcContent = content.toLowerCase(); 208 | let idx = lcContent.indexOf(lcSearchTerm); 209 | const indices = []; 210 | while (idx >= 0) { 211 | indices.push(idx); 212 | idx = lcContent.indexOf(lcSearchTerm, idx + 1); 213 | } 214 | // Take some 20 characters from before and after the occurrence 215 | const samples: FormattedSegment[][] = []; 216 | for (let i = 0; i < Math.min(/* max samples */ 1, indices.length); i++) { 217 | const curIdx = indices[i]; 218 | const prefix = (curIdx - 20 > 0 ? '...' : ''); 219 | const suffix = (curIdx + searchTerm.length + 20 >= content.length ? '' : '...'); 220 | const startIdx = Math.max(0, curIdx - 20); 221 | const endIdx = Math.min(content.length, curIdx + searchTerm.length + 20); 222 | samples.push([ 223 | {text: prefix + content.slice(startIdx, curIdx), highlighted: false}, 224 | {text: content.slice(curIdx, curIdx + searchTerm.length), highlighted: true}, 225 | {text: content.slice(curIdx + searchTerm.length, endIdx) + suffix, highlighted: false}, 226 | ]); 227 | } 228 | return [indices.length, samples]; 229 | } 230 | 231 | // Split given string to highlighted parts which are defined by the given boolean array, where 'true' corresponds to highlighted char. 232 | private splitToHighlightedParts(str: string, highlightedIndices: boolean[]): FormattedSegment[] { 233 | const ans: FormattedSegment[] = []; 234 | let subseqStartInx = 0; 235 | for (let i = 1; i < highlightedIndices.length; i++) { 236 | if (highlightedIndices[i] !== highlightedIndices[i - 1]) { 237 | const text = str.slice(subseqStartInx, i); 238 | ans.push({text, highlighted: highlightedIndices[subseqStartInx]}); 239 | subseqStartInx = i; 240 | } 241 | } 242 | ans.push({text: str.slice(subseqStartInx), highlighted: highlightedIndices[subseqStartInx]}); 243 | return ans; 244 | } 245 | 246 | // Returns the indices of the numbers that are among the 'numberOfLargestIndices' largest numbers in the given array. 247 | private getLargestElementIndices(arr: number[], numberOfLargestIndices: number) { 248 | const copy = arr.slice(); 249 | // noooo you cant sort its nlogn and time complexity will suffer!! 250 | copy.sort((a, b) => b - a); // descending sort 251 | // haha sort goes brrrrrr 252 | const ans = []; 253 | for (let i = 0; i < Math.min(arr.length, numberOfLargestIndices); i++) { 254 | ans.push(arr.indexOf(copy[i])); 255 | } 256 | return ans; 257 | } 258 | 259 | // Returns the indices of the given string that are part of at least one of the 260 | // given words. For example, if the word is 'aabaa' and words is 'ba' returns 261 | // [false, false, true, true, false]. 262 | private getIndicesCoveredByWords(str: string, words: string[]): boolean[] { 263 | const highlightIndices = new Array(str.length).fill(false); 264 | for (const term of words) { 265 | let occurrenceIdx = str.indexOf(term); 266 | while (occurrenceIdx !== -1) { 267 | for (let i = occurrenceIdx; i < occurrenceIdx + term.length; i++) { 268 | highlightIndices[i] = true; 269 | } 270 | occurrenceIdx = str.indexOf(term, occurrenceIdx + 1); 271 | } 272 | } 273 | return highlightIndices; 274 | } 275 | 276 | } 277 | -------------------------------------------------------------------------------- /src/app/study/study.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | ChangeDetectorRef, 4 | Component, 5 | ElementRef, 6 | OnDestroy, 7 | SecurityContext, 8 | ViewChild 9 | } from '@angular/core'; 10 | import {Flashcard} from '../types'; 11 | import {Subscription} from 'rxjs'; 12 | import {FlashcardService} from '../flashcard.service'; 13 | import {SubviewManagerService} from '../subview-manager.service'; 14 | import * as marked from 'marked'; 15 | import {DomSanitizer} from '@angular/platform-browser'; 16 | import {FlashcardDialogComponent, FlashcardDialogData} from '../create-flashcard-dialog/flashcard-dialog.component'; 17 | import {MatDialog} from '@angular/material/dialog'; 18 | import {ConfirmationDialogComponent, ConfirmDialogData} from '../confirmation-dialog/confirmation-dialog.component'; 19 | 20 | export const ALL_FCS_QUEUE_NAME = 'all flashcards'; 21 | 22 | @Component({ 23 | selector: 'cn-study', 24 | template: ` 25 |
26 | 27 | 28 | Flashcard queue 29 | 30 | 31 | 32 | 33 | {{kv.key}} 34 | 35 | 36 | {{queueToDueFcs.get(kv.key)?.length || 0}}/{{kv.value.length || 0}} due 37 | 38 | 39 | 40 | 43 |
44 |
45 | 52 | 56 | 57 | 58 |
59 |
You haven't created any flashcards.
60 |
61 | All done! 62 |
63 | 64 |
65 |
66 | {{tag}} 67 |
68 |
69 |
70 |
71 |
72 | 75 | 76 |
77 | 81 | 85 | 89 | 93 |
94 |
95 |
96 |
97 |
98 |
99 | `, 100 | styles: [` 101 | :host { 102 | background: var(--primary-background-color); 103 | display: flex; 104 | flex-direction: column; 105 | justify-content: space-around; 106 | } 107 | 108 | .fc-side { 109 | overflow-wrap: break-word; 110 | } 111 | 112 | #more-button { 113 | position: absolute; 114 | right: 0; 115 | } 116 | 117 | #container { 118 | position: relative; 119 | } 120 | 121 | .queue-option-container { 122 | align-items: center; 123 | display: flex; 124 | position: relative; 125 | } 126 | 127 | .queue-option-container mat-option { 128 | display: inline-block; 129 | flex-grow: 1; 130 | } 131 | 132 | #top-bar { 133 | display: flex; 134 | justify-content: space-between; 135 | height: var(--top-bar-height); 136 | background: var(--secondary-background-color); 137 | border-bottom: 1px solid var(--gutter-color); 138 | } 139 | 140 | #due-fc-container { 141 | border-radius: 6px; 142 | box-shadow: 0 0 10px #bdbdbd; 143 | display: flex; 144 | flex-direction: column; 145 | padding: 10px; 146 | } 147 | 148 | #queue-dropdown { 149 | margin-left: 60px; 150 | max-width: 350px; 151 | } 152 | 153 | #rating-container { 154 | display: flex; 155 | justify-content: space-between; 156 | } 157 | 158 | #rating-container > button { 159 | flex-grow: 1; 160 | } 161 | 162 | #fc-container { 163 | align-items: center; 164 | display: flex; 165 | justify-content: space-around; 166 | } 167 | 168 | .due-count { 169 | color: var(--low-contrast-text-color); 170 | position: absolute; 171 | right: 5px; 172 | } 173 | 174 | .queue-info-container { 175 | display: flex; 176 | justify-content: space-between; 177 | } 178 | 179 | #tags { 180 | display: flex; 181 | justify-content: space-around; 182 | margin: 20px 0; 183 | } 184 | 185 | .tag { 186 | margin-left: 10px; 187 | } 188 | 189 | .notification { 190 | margin-top: 10px; 191 | } 192 | 193 | #show-answer-button { 194 | width: 100%; 195 | } 196 | 197 | #wrapper { 198 | display: flex; 199 | flex-direction: column; 200 | flex-basis: 350px; 201 | } 202 | `] 203 | }) 204 | export class StudyComponent implements AfterViewInit, OnDestroy { 205 | 206 | @ViewChild('front') front: ElementRef; 207 | @ViewChild('back') back: ElementRef; 208 | 209 | displayedFc?: Flashcard; 210 | revealed: boolean; 211 | // allFcs: Flashcard[] = []; 212 | currentQueue: Flashcard[] = []; 213 | 214 | queueToFcs = new Map([[ALL_FCS_QUEUE_NAME, []]]); 215 | queueToDueFcs = new Map([[ALL_FCS_QUEUE_NAME, []]]); 216 | 217 | // numDueFcs: Map; 218 | selectedQueue = ALL_FCS_QUEUE_NAME; 219 | 220 | private sub: Subscription; 221 | private fcIds = new Set(); 222 | 223 | constructor( 224 | private readonly flashcardService: FlashcardService, 225 | private readonly subviewManager: SubviewManagerService, 226 | private sanitizer: DomSanitizer, 227 | private dialog: MatDialog, 228 | private cdr: ChangeDetectorRef) {} 229 | 230 | ngAfterViewInit() { 231 | this.sub = this.flashcardService.flashcards.subscribe(fcs => { 232 | if (!fcs) { 233 | return; 234 | } 235 | 236 | for (const fc of fcs) { 237 | if (!this.fcIds.has(fc.id)) { 238 | this.processFc(fc); 239 | this.fcIds.add(fc.id); 240 | } 241 | } 242 | 243 | // Present first note automatically 244 | this.setNextFlashcard(); 245 | 246 | // Tell angular things have changed to prevent ExpressionChangedAfter... error in tests 247 | this.cdr.detectChanges(); 248 | }); 249 | } 250 | 251 | queueChanged(e) { 252 | this.selectedQueue = e.value; 253 | this.currentQueue = this.queueToDueFcs.get(this.selectedQueue); 254 | this.setNextFlashcard(); 255 | } 256 | 257 | ngOnDestroy(): void { 258 | this.sub.unsubscribe(); 259 | } 260 | 261 | reveal() { 262 | this.revealed = true; 263 | } 264 | 265 | setNextFlashcard() { 266 | this.revealed = false; 267 | this.displayedFc = this.queueToDueFcs.get(this.selectedQueue)[0]; // Can be undefined if queue empty 268 | if (this.displayedFc) { 269 | this.setRenderedContents(this.displayedFc); 270 | } 271 | } 272 | 273 | submitRating(rating: number, fc: Flashcard) { 274 | this.flashcardService.submitFlashcardRating(rating, fc); 275 | if (rating === 0) { 276 | // If user couldn't remember the card at all it re-enters queue at the end 277 | this.queueToDueFcs.get(this.selectedQueue).splice(0, 1); 278 | this.queueToDueFcs.get(this.selectedQueue).push(fc); 279 | } else { 280 | this.removeFcFromDueQueues(fc); 281 | } 282 | this.setNextFlashcard(); 283 | this.cdr.detectChanges(); 284 | } 285 | 286 | closeView() { 287 | this.subviewManager.closeView('flashcards'); 288 | } 289 | 290 | async deleteFlashcard(id: string) { 291 | const dialogRef = this.dialog.open(ConfirmationDialogComponent, { 292 | width: '600px', 293 | data: { 294 | title: 'Confirmation', 295 | message: 'Delete this flashcard?', 296 | confirmButtonText: 'Delete', 297 | rejectButtonText: 'Cancel', 298 | } as ConfirmDialogData, 299 | }); 300 | const result = await dialogRef.afterClosed().toPromise(); 301 | if (result) { 302 | this.flashcardService.deleteFlashcard(id); 303 | } 304 | } 305 | 306 | editFlashcard(fc: Flashcard) { 307 | this.dialog.open(FlashcardDialogComponent, { 308 | position: { top: '10px' }, 309 | data: { 310 | flashcardToEdit: fc 311 | } as FlashcardDialogData, 312 | width: '100%', 313 | maxHeight: '90vh' /* to enable scrolling on overflow */, 314 | }); 315 | } 316 | 317 | private setRenderedContents(fc: Flashcard) { 318 | const side1UnsafeContent = (marked as any)(fc.side1); 319 | const side1SanitizedContent = this.sanitizer.sanitize(SecurityContext.HTML, side1UnsafeContent); 320 | this.front.nativeElement.innerHTML = this.sanitizer.sanitize(SecurityContext.HTML, side1SanitizedContent); 321 | const side2UnsafeContent = (marked as any)(fc.side2); 322 | const side2SanitizedContent = this.sanitizer.sanitize(SecurityContext.HTML, side2UnsafeContent); 323 | this.back.nativeElement.innerHTML = this.sanitizer.sanitize(SecurityContext.HTML, side2SanitizedContent); 324 | } 325 | 326 | private processFc(fc: Flashcard) { 327 | const isDue = this.flashcardService.isDue(fc); 328 | for (const tag of [ALL_FCS_QUEUE_NAME, ...fc.tags]) { 329 | if (!this.queueToFcs.has(tag)) { 330 | this.queueToFcs.set(tag, []); 331 | this.queueToDueFcs.set(tag, []); 332 | } 333 | this.queueToFcs.get(tag).push(fc); 334 | if (isDue) { 335 | // Presented FCs shouldn't be sorted in any special way, since if we're still loading FCs 336 | // and they're coming in they would keep switching places. 337 | this.queueToDueFcs.get(tag).push(fc); 338 | } 339 | } 340 | } 341 | 342 | private removeFcFromDueQueues(fc: Flashcard) { 343 | for (const tag of [ALL_FCS_QUEUE_NAME, ...fc.tags]) { 344 | const dueIdx = this.queueToDueFcs.get(tag).findIndex(otherFc => otherFc.id === fc.id); 345 | this.queueToDueFcs.get(tag).splice(dueIdx, 1); 346 | } 347 | } 348 | } 349 | --------------------------------------------------------------------------------