mirror of
https://github.com/standardnotes/app.git
synced 2026-01-16 23:01:30 +00:00
Merge branch 'release/3.5.1'
This commit is contained in:
commit
1bf08decea
289 changed files with 46182 additions and 12016 deletions
9
.babelrc
9
.babelrc
|
|
@ -1,14 +1,9 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"targets": "> 0.25%, not dead"
|
||||
}
|
||||
]
|
||||
"@babel/typescript",
|
||||
"@babel/preset-env"
|
||||
],
|
||||
"plugins": [
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-proposal-class-properties",
|
||||
"angularjs-annotate"
|
||||
]
|
||||
|
|
|
|||
6
.browserslistrc
Normal file
6
.browserslistrc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
chrome 80 # electron 8.2
|
||||
last 2 Firefox versions
|
||||
last 2 Edge versions
|
||||
last 2 Safari versions
|
||||
last 2 Opera versions
|
||||
Firefox ESR
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
# git
|
||||
.git/
|
||||
.gitignore
|
||||
node_modules
|
||||
public/assets
|
||||
|
|
|
|||
11
.env.sample
11
.env.sample
|
|
@ -1,14 +1,19 @@
|
|||
RAILS_ENV=development
|
||||
PORT=3000
|
||||
PORT=3001
|
||||
WEB_CONCURRENCY=0
|
||||
RAILS_LOG_TO_STDOUT=true
|
||||
RAILS_SERVE_STATIC_FILES=true
|
||||
SECRET_KEY_BASE=test
|
||||
APP_HOST=http://localhost:3000
|
||||
APP_HOST=http://localhost:3001
|
||||
|
||||
EXTENSIONS_MANAGER_LOCATION=extensions/extensions-manager/dist/index.html
|
||||
BATCH_MANAGER_LOCATION=extensions/batch-manager/dist/index.min.html
|
||||
SF_DEFAULT_SERVER=http://localhost:3001
|
||||
SF_DEFAULT_SERVER=http://localhost:3000
|
||||
|
||||
# Datadog
|
||||
DATADOG_ENABLED=false
|
||||
|
||||
# Development options
|
||||
DEV_DEFAULT_SYNC_SERVER=https://sync.standardnotes.org
|
||||
DEV_EXTENSIONS_MANAGER_LOCATION=public/extensions/extensions-manager/dist/index.html
|
||||
DEV_BATCH_MANAGER_LOCATION=public/extensions/batch-manager/dist/index.min.html
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"rules": {
|
||||
"standard/no-callback-literal": 0, // Disable this as we have too many callbacks relying on literals
|
||||
"no-throw-literal": 0,
|
||||
"no-console": "error",
|
||||
// "no-console": "error",
|
||||
"semi": 1
|
||||
},
|
||||
"env": {
|
||||
|
|
|
|||
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [standardnotes]
|
||||
83
.github/workflows/beta.yml
vendored
Normal file
83
.github/workflows/beta.yml
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
name: Beta
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ release/* ]
|
||||
|
||||
jobs:
|
||||
|
||||
tsc:
|
||||
|
||||
name: Check types
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typescript
|
||||
run: npm run tsc
|
||||
|
||||
deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: tsc
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Initiate submodules
|
||||
run: git submodule update --init
|
||||
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@master
|
||||
with:
|
||||
name: standardnotes/web
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
tags: "beta,${{ github.sha }}"
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v1
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-east-1
|
||||
|
||||
- name: Download task definition
|
||||
run: |
|
||||
aws ecs describe-task-definition --task-definition app-beta-prod --query taskDefinition > task-definition.json
|
||||
|
||||
- name: Fill in the new image ID in the Amazon ECS task definition
|
||||
id: task-def
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@v1
|
||||
with:
|
||||
task-definition: task-definition.json
|
||||
container-name: app-beta-prod
|
||||
image: "standardnotes/web:${{ github.sha }}"
|
||||
|
||||
- name: Deploy Amazon ECS task definition
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
|
||||
with:
|
||||
task-definition: ${{ steps.task-def.outputs.task-definition }}
|
||||
service: app-beta-prod
|
||||
cluster: prod
|
||||
wait-for-service-stability: true
|
||||
|
||||
notify_slack:
|
||||
needs: deploy
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Notify slack
|
||||
uses: pullreminders/slack-action@master
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
with:
|
||||
args: '{ \"channel\": \"${{ secrets.SLACK_NOTIFICATION_CHANNEL }}\", \"blocks\": [{\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Successfully deployed <https://app-beta.standardnotes.org|[BETA] Web App>\"}}, {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Changes: <https://github.com/standardnotes/web/commit/${{ github.sha }}|${{ github.sha }}>\"}, \"accessory\": {\"type\": \"image\", \"image_url\": \"https://website-dev.standardnotes.org/assets/icon.png\", \"alt_text\": \"Standard Notes\"}}, { \"type\": \"section\", \"fields\": [{\"type\": \"mrkdwn\", \"text\": \"<https://github.com/standardnotes/web/actions/runs/${{ github.run_id }}|Build details>\"}]}]}'
|
||||
21
.github/workflows/dev.yml
vendored
21
.github/workflows/dev.yml
vendored
|
|
@ -5,15 +5,34 @@ on:
|
|||
branches: [ develop ]
|
||||
|
||||
jobs:
|
||||
|
||||
tsc:
|
||||
|
||||
name: Check types
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typescript
|
||||
run: npm run tsc
|
||||
|
||||
deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: tsc
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Initiate submodules
|
||||
run: git submodule update --init --force --remote
|
||||
run: git submodule update --init
|
||||
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@master
|
||||
|
|
|
|||
18
.github/workflows/pr.yml
vendored
Normal file
18
.github/workflows/pr.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
|
||||
jobs:
|
||||
tsc:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Typescript
|
||||
run: npm run tsc
|
||||
22
.github/workflows/prod.yml
vendored
22
.github/workflows/prod.yml
vendored
|
|
@ -5,15 +5,35 @@ on:
|
|||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
|
||||
tsc:
|
||||
|
||||
name: Check types
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typescript
|
||||
run: npm run tsc
|
||||
|
||||
deploy:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
needs: tsc
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Initiate submodules
|
||||
run: git submodule update --init --force --remote
|
||||
run: git submodule update --init
|
||||
|
||||
- name: Publish to Registry
|
||||
uses: elgohr/Publish-Docker-Github-Action@master
|
||||
|
|
|
|||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -28,8 +28,6 @@
|
|||
|
||||
dump.rdb
|
||||
|
||||
index.html
|
||||
|
||||
# Ignore compiled assets
|
||||
/public/assets
|
||||
|
||||
|
|
@ -38,3 +36,8 @@ index.html
|
|||
!/public/uploads/.keep
|
||||
|
||||
.vscode
|
||||
|
||||
# Generated Files
|
||||
/dist/javascripts
|
||||
/dist/stylesheets
|
||||
/dist/fonts
|
||||
|
|
|
|||
19
Dockerfile
19
Dockerfile
|
|
@ -1,5 +1,10 @@
|
|||
FROM ruby:2.7.1-alpine
|
||||
|
||||
ARG UID=1000
|
||||
ARG GID=1000
|
||||
|
||||
RUN addgroup -S webapp -g $GID && adduser -D -S webapp -G webapp -u $UID
|
||||
|
||||
RUN apk add --update --no-cache \
|
||||
alpine-sdk \
|
||||
nodejs \
|
||||
|
|
@ -10,20 +15,24 @@ RUN apk add --update --no-cache \
|
|||
|
||||
WORKDIR /app/
|
||||
|
||||
COPY package.json package-lock.json Gemfile Gemfile.lock /app/
|
||||
RUN chown -R $UID:$GID .
|
||||
|
||||
COPY vendor /app/vendor
|
||||
USER webapp
|
||||
|
||||
COPY --chown=$UID:$GID package.json package-lock.json Gemfile Gemfile.lock /app/
|
||||
|
||||
COPY --chown=$UID:$GID vendor /app/vendor
|
||||
|
||||
RUN npm ci
|
||||
|
||||
RUN gem install bundler && bundle install
|
||||
|
||||
COPY . /app/
|
||||
|
||||
RUN bundle exec rails assets:precompile
|
||||
COPY --chown=$UID:$GID . /app/
|
||||
|
||||
RUN npm run bundle
|
||||
|
||||
RUN bundle exec rails assets:precompile
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT [ "./docker/entrypoint.sh" ]
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -62,6 +62,26 @@ Questions? Find answers on our [Help page](https://standardnotes.org/help).
|
|||
|
||||
---
|
||||
|
||||
### Docker setup
|
||||
|
||||
Docker is the quick and easy way to try out Standard Notes. We highly recommend using our official [Docker hub image](https://hub.docker.com/repository/docker/standardnotes/web).
|
||||
|
||||
### Standalone instance
|
||||
|
||||
Before you start make sure you have a `.env` file copied from the sample `.env.sample` and configured with your parameters.
|
||||
|
||||
If your intention is not contributing but just running the app we recommend using our official image from Docker hub like this:
|
||||
```
|
||||
docker run -d -p 3001:3001 --env-file=your-env-file standardnotes/web:stable
|
||||
```
|
||||
|
||||
Or if you want to use the `develop` branch that is in a work-in-progress state please use:
|
||||
```
|
||||
docker run -d -p 3001:3001 --env-file=your-env-file standardnotes/web:latest
|
||||
```
|
||||
|
||||
You can then access the app at `http://localhost:3001` (please check Docker container logs if the server has started already and is listening on connections).
|
||||
|
||||
### Running Locally
|
||||
|
||||
This repo contains the core code used in the web app, as well as the Electron-based [desktop application](https://github.com/standardnotes/desktop).
|
||||
|
|
@ -69,11 +89,10 @@ This repo contains the core code used in the web app, as well as the Electron-ba
|
|||
**Instructions:**
|
||||
|
||||
1. Clone the repo
|
||||
2. `npm install`
|
||||
3. `cp index.html.sample index.html`
|
||||
4. `npm start`
|
||||
1. `npm run setup`
|
||||
1. `npm start`
|
||||
|
||||
Then open your browser to `http://localhost:3000`.
|
||||
Then open your browser to `http://localhost:3001`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -81,7 +100,7 @@ Then open your browser to `http://localhost:3000`.
|
|||
|
||||
The web app makes use of two optional native extensions, which, when running the app with Rails, can be configured to work as follows:
|
||||
|
||||
1. `git submodule update --init --force --remote` (will load the submodules in the `public/extensions` folder)
|
||||
1. `git submodule update --init` (will load the submodules in the `public/extensions` folder)
|
||||
1. Set the following environment variables in the .env file:
|
||||
```
|
||||
EXTENSIONS_MANAGER_LOCATION=extensions/extensions-manager/dist/index.html
|
||||
|
|
@ -101,3 +120,17 @@ SF_DEFAULT_SERVER=https://sync.myserver
|
|||
- Desktop app: https://github.com/standardnotes/desktop
|
||||
- Mobile (iOS & Android): https://github.com/standardnotes/mobile
|
||||
- Extensions: https://github.com/sn-extensions
|
||||
|
||||
## Contributing
|
||||
|
||||
For contributing we highly recommend you use our docker-compose setup that is provided in this repository.
|
||||
|
||||
### Docker compose setup
|
||||
|
||||
Use the included [docker-compose.yml](docker-compose.yml) file to build Standard Notes with `docker-compose`. Once your `.env` file has been copied and configured, simply run:
|
||||
|
||||
```
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This should load the app container and run the necessary scripts. You should then be able to reach the app at `http://localhost:3001`
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import angular from 'angular';
|
||||
import { configRoutes } from './routes';
|
||||
|
||||
import {
|
||||
AppState
|
||||
} from './state';
|
||||
|
||||
import {
|
||||
Root,
|
||||
TagsPanel,
|
||||
NotesPanel,
|
||||
EditorPanel,
|
||||
Footer,
|
||||
LockScreen
|
||||
} from './controllers';
|
||||
|
||||
import {
|
||||
autofocus,
|
||||
clickOutside,
|
||||
delayHide,
|
||||
elemReady,
|
||||
fileChange,
|
||||
infiniteScroll,
|
||||
lowercase,
|
||||
selectOnClick,
|
||||
snEnter
|
||||
} from './directives/functional';
|
||||
|
||||
import {
|
||||
AccountMenu,
|
||||
ActionsMenu,
|
||||
ComponentModal,
|
||||
ComponentView,
|
||||
ConflictResolutionModal,
|
||||
EditorMenu,
|
||||
InputModal,
|
||||
MenuRow,
|
||||
PanelResizer,
|
||||
PasswordWizard,
|
||||
PermissionsModal,
|
||||
PrivilegesAuthModal,
|
||||
PrivilegesManagementModal,
|
||||
RevisionPreviewModal,
|
||||
SessionHistoryMenu,
|
||||
SyncResolutionMenu
|
||||
} from './directives/views';
|
||||
|
||||
import { trusted } from './filters';
|
||||
|
||||
import {
|
||||
ActionsManager,
|
||||
ArchiveManager,
|
||||
AuthManager,
|
||||
ComponentManager,
|
||||
DBManager,
|
||||
DesktopManager,
|
||||
HttpManager,
|
||||
KeyboardManager,
|
||||
MigrationManager,
|
||||
ModelManager,
|
||||
NativeExtManager,
|
||||
PasscodeManager,
|
||||
PrivilegesManager,
|
||||
SessionHistory,
|
||||
SingletonManager,
|
||||
StatusManager,
|
||||
StorageManager,
|
||||
SyncManager,
|
||||
ThemeManager,
|
||||
AlertManager,
|
||||
PreferencesManager
|
||||
} from './services';
|
||||
|
||||
angular.module('app', ['ngSanitize']);
|
||||
|
||||
// Config
|
||||
angular
|
||||
.module('app')
|
||||
.config(configRoutes)
|
||||
.constant('appVersion', __VERSION__);
|
||||
|
||||
// Controllers
|
||||
angular
|
||||
.module('app')
|
||||
.directive('root', () => new Root())
|
||||
.directive('tagsPanel', () => new TagsPanel())
|
||||
.directive('notesPanel', () => new NotesPanel())
|
||||
.directive('editorPanel', () => new EditorPanel())
|
||||
.directive('footer', () => new Footer())
|
||||
.directive('lockScreen', () => new LockScreen());
|
||||
|
||||
// Directives - Functional
|
||||
angular
|
||||
.module('app')
|
||||
.directive('snAutofocus', ['$timeout', autofocus])
|
||||
.directive('clickOutside', ['$document', clickOutside])
|
||||
.directive('delayHide', delayHide)
|
||||
.directive('elemReady', elemReady)
|
||||
.directive('fileChange', fileChange)
|
||||
.directive('infiniteScroll', [
|
||||
'$rootScope',
|
||||
'$window',
|
||||
'$timeout',
|
||||
infiniteScroll
|
||||
])
|
||||
.directive('lowercase', lowercase)
|
||||
.directive('selectOnClick', ['$window', selectOnClick])
|
||||
.directive('snEnter', snEnter);
|
||||
|
||||
// Directives - Views
|
||||
angular
|
||||
.module('app')
|
||||
.directive('accountMenu', () => new AccountMenu())
|
||||
.directive('actionsMenu', () => new ActionsMenu())
|
||||
.directive('componentModal', () => new ComponentModal())
|
||||
.directive(
|
||||
'componentView',
|
||||
($rootScope, componentManager, desktopManager, $timeout) =>
|
||||
new ComponentView($rootScope, componentManager, desktopManager, $timeout)
|
||||
)
|
||||
.directive('conflictResolutionModal', () => new ConflictResolutionModal())
|
||||
.directive('editorMenu', () => new EditorMenu())
|
||||
.directive('inputModal', () => new InputModal())
|
||||
.directive('menuRow', () => new MenuRow())
|
||||
.directive('panelResizer', () => new PanelResizer())
|
||||
.directive('passwordWizard', () => new PasswordWizard())
|
||||
.directive('permissionsModal', () => new PermissionsModal())
|
||||
.directive('privilegesAuthModal', () => new PrivilegesAuthModal())
|
||||
.directive('privilegesManagementModal', () => new PrivilegesManagementModal())
|
||||
.directive('revisionPreviewModal', () => new RevisionPreviewModal())
|
||||
.directive('sessionHistoryMenu', () => new SessionHistoryMenu())
|
||||
.directive('syncResolutionMenu', () => new SyncResolutionMenu());
|
||||
|
||||
// Filters
|
||||
angular
|
||||
.module('app')
|
||||
.filter('trusted', ['$sce', trusted]);
|
||||
|
||||
// Services
|
||||
angular
|
||||
.module('app')
|
||||
.service('appState', AppState)
|
||||
.service('preferencesManager', PreferencesManager)
|
||||
.service('actionsManager', ActionsManager)
|
||||
.service('archiveManager', ArchiveManager)
|
||||
.service('authManager', AuthManager)
|
||||
.service('componentManager', ComponentManager)
|
||||
.service('dbManager', DBManager)
|
||||
.service('desktopManager', DesktopManager)
|
||||
.service('httpManager', HttpManager)
|
||||
.service('keyboardManager', KeyboardManager)
|
||||
.service('migrationManager', MigrationManager)
|
||||
.service('modelManager', ModelManager)
|
||||
.service('nativeExtManager', NativeExtManager)
|
||||
.service('passcodeManager', PasscodeManager)
|
||||
.service('privilegesManager', PrivilegesManager)
|
||||
.service('sessionHistory', SessionHistory)
|
||||
.service('singletonManager', SingletonManager)
|
||||
.service('statusManager', StatusManager)
|
||||
.service('storageManager', StorageManager)
|
||||
.service('syncManager', SyncManager)
|
||||
.service('alertManager', AlertManager)
|
||||
.service('themeManager', ThemeManager);
|
||||
143
app/assets/javascripts/app.ts
Normal file
143
app/assets/javascripts/app.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
'use strict';
|
||||
|
||||
declare const __VERSION__: string;
|
||||
declare const __WEB__: boolean;
|
||||
|
||||
import angular from 'angular';
|
||||
import { configRoutes } from './routes';
|
||||
|
||||
import { ApplicationGroup } from './ui_models/application_group';
|
||||
import { AccountSwitcher } from './views/account_switcher/account_switcher';
|
||||
|
||||
import {
|
||||
ApplicationGroupView,
|
||||
ApplicationView,
|
||||
EditorGroupView,
|
||||
EditorView,
|
||||
TagsView,
|
||||
NotesView,
|
||||
FooterView,
|
||||
ChallengeModal
|
||||
} from '@/views';
|
||||
|
||||
import {
|
||||
autofocus,
|
||||
clickOutside,
|
||||
delayHide,
|
||||
elemReady,
|
||||
fileChange,
|
||||
infiniteScroll,
|
||||
lowercase,
|
||||
selectOnFocus,
|
||||
snEnter
|
||||
} from './directives/functional';
|
||||
|
||||
import {
|
||||
AccountMenu,
|
||||
ActionsMenu,
|
||||
ComponentModal,
|
||||
ComponentView,
|
||||
EditorMenu,
|
||||
InputModal,
|
||||
MenuRow,
|
||||
PanelResizer,
|
||||
PasswordWizard,
|
||||
PermissionsModal,
|
||||
PrivilegesAuthModal,
|
||||
PrivilegesManagementModal,
|
||||
RevisionPreviewModal,
|
||||
HistoryMenu,
|
||||
SyncResolutionMenu
|
||||
} from './directives/views';
|
||||
|
||||
import { trusted } from './filters';
|
||||
import { isDev } from './utils';
|
||||
import { Bridge, BrowserBridge } from './services/bridge';
|
||||
|
||||
if (__WEB__) {
|
||||
startApplication(
|
||||
(window as any)._default_sync_server,
|
||||
new BrowserBridge()
|
||||
);
|
||||
} else {
|
||||
(window as any).startApplication = startApplication;
|
||||
}
|
||||
|
||||
function startApplication(
|
||||
defaultSyncServerHost: string,
|
||||
bridge: Bridge
|
||||
) {
|
||||
angular.module('app', ['ngSanitize']);
|
||||
|
||||
// Config
|
||||
angular
|
||||
.module('app')
|
||||
.config(configRoutes)
|
||||
.constant('bridge', bridge)
|
||||
.constant('defaultSyncServerHost', defaultSyncServerHost)
|
||||
.constant('appVersion', __VERSION__);
|
||||
|
||||
// Controllers
|
||||
angular
|
||||
.module('app')
|
||||
.directive('applicationGroupView', () => new ApplicationGroupView())
|
||||
.directive('applicationView', () => new ApplicationView())
|
||||
.directive('editorGroupView', () => new EditorGroupView())
|
||||
.directive('editorView', () => new EditorView())
|
||||
.directive('tagsView', () => new TagsView())
|
||||
.directive('notesView', () => new NotesView())
|
||||
.directive('footerView', () => new FooterView())
|
||||
|
||||
// Directives - Functional
|
||||
angular
|
||||
.module('app')
|
||||
.directive('snAutofocus', ['$timeout', autofocus])
|
||||
.directive('clickOutside', ['$document', clickOutside])
|
||||
.directive('delayHide', delayHide)
|
||||
.directive('elemReady', elemReady)
|
||||
.directive('fileChange', fileChange)
|
||||
.directive('infiniteScroll', [infiniteScroll])
|
||||
.directive('lowercase', lowercase)
|
||||
.directive('selectOnFocus', ['$window', selectOnFocus])
|
||||
.directive('snEnter', snEnter);
|
||||
|
||||
// Directives - Views
|
||||
angular
|
||||
.module('app')
|
||||
.directive('accountMenu', () => new AccountMenu())
|
||||
.directive('accountSwitcher', () => new AccountSwitcher())
|
||||
.directive('actionsMenu', () => new ActionsMenu())
|
||||
.directive('challengeModal', () => new ChallengeModal())
|
||||
.directive('componentModal', () => new ComponentModal())
|
||||
.directive('componentView', () => new ComponentView())
|
||||
.directive('editorMenu', () => new EditorMenu())
|
||||
.directive('inputModal', () => new InputModal())
|
||||
.directive('menuRow', () => new MenuRow())
|
||||
.directive('panelResizer', () => new PanelResizer())
|
||||
.directive('passwordWizard', () => new PasswordWizard())
|
||||
.directive('permissionsModal', () => new PermissionsModal())
|
||||
.directive('privilegesAuthModal', () => new PrivilegesAuthModal())
|
||||
.directive('privilegesManagementModal', () => new PrivilegesManagementModal())
|
||||
.directive('revisionPreviewModal', () => new RevisionPreviewModal())
|
||||
.directive('historyMenu', () => new HistoryMenu())
|
||||
.directive('syncResolutionMenu', () => new SyncResolutionMenu());
|
||||
|
||||
// Filters
|
||||
angular
|
||||
.module('app')
|
||||
.filter('trusted', ['$sce', trusted]);
|
||||
|
||||
// Services
|
||||
angular.module('app').service('mainApplicationGroup', ApplicationGroup);
|
||||
|
||||
// Debug
|
||||
if (isDev) {
|
||||
Object.defineProperties(window, {
|
||||
application: {
|
||||
get: () =>
|
||||
(angular.element(document).injector().get('mainApplicationGroup') as any)
|
||||
.primaryApplication
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
export class PureCtrl {
|
||||
constructor(
|
||||
$timeout
|
||||
) {
|
||||
if(!$timeout) {
|
||||
throw 'Invalid PureCtrl construction.';
|
||||
}
|
||||
this.$timeout = $timeout;
|
||||
this.state = {};
|
||||
this.props = {};
|
||||
}
|
||||
|
||||
async setState(state) {
|
||||
return new Promise((resolve) => {
|
||||
this.$timeout(() => {
|
||||
this.state = Object.freeze(Object.assign({}, this.state, state));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initProps(props) {
|
||||
if (Object.keys(this.props).length > 0) {
|
||||
throw 'Already init-ed props.';
|
||||
}
|
||||
this.props = Object.freeze(Object.assign({}, this.props, props));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,382 +0,0 @@
|
|||
import { PrivilegesManager } from '@/services/privilegesManager';
|
||||
import { dateToLocalizedString } from '@/utils';
|
||||
import template from '%/footer.pug';
|
||||
import {
|
||||
APP_STATE_EVENT_EDITOR_FOCUSED,
|
||||
APP_STATE_EVENT_BEGAN_BACKUP_DOWNLOAD,
|
||||
APP_STATE_EVENT_ENDED_BACKUP_DOWNLOAD,
|
||||
EVENT_SOURCE_USER_INTERACTION
|
||||
} from '@/state';
|
||||
import {
|
||||
STRING_GENERIC_SYNC_ERROR,
|
||||
STRING_NEW_UPDATE_READY
|
||||
} from '@/strings';
|
||||
|
||||
class FooterCtrl {
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope,
|
||||
$timeout,
|
||||
alertManager,
|
||||
appState,
|
||||
authManager,
|
||||
componentManager,
|
||||
modelManager,
|
||||
nativeExtManager,
|
||||
passcodeManager,
|
||||
privilegesManager,
|
||||
statusManager,
|
||||
syncManager,
|
||||
) {
|
||||
this.$rootScope = $rootScope;
|
||||
this.$timeout = $timeout;
|
||||
this.alertManager = alertManager;
|
||||
this.appState = appState;
|
||||
this.authManager = authManager;
|
||||
this.componentManager = componentManager;
|
||||
this.modelManager = modelManager;
|
||||
this.nativeExtManager = nativeExtManager;
|
||||
this.passcodeManager = passcodeManager;
|
||||
this.privilegesManager = privilegesManager;
|
||||
this.statusManager = statusManager;
|
||||
this.syncManager = syncManager;
|
||||
|
||||
this.rooms = [];
|
||||
this.themesWithIcons = [];
|
||||
this.showSyncResolution = false;
|
||||
|
||||
this.addAppStateObserver();
|
||||
this.updateOfflineStatus();
|
||||
this.addSyncEventHandler();
|
||||
this.findErrors();
|
||||
this.registerMappingObservers();
|
||||
this.registerComponentHandler();
|
||||
this.addRootScopeListeners();
|
||||
|
||||
this.authManager.checkForSecurityUpdate().then((available) => {
|
||||
this.securityUpdateAvailable = available;
|
||||
});
|
||||
this.statusManager.addStatusObserver((string) => {
|
||||
this.$timeout(() => {
|
||||
this.arbitraryStatusMessage = string;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addRootScopeListeners() {
|
||||
this.$rootScope.$on("security-update-status-changed", () => {
|
||||
this.securityUpdateAvailable = this.authManager.securityUpdateAvailable;
|
||||
});
|
||||
this.$rootScope.$on("reload-ext-data", () => {
|
||||
this.reloadExtendedData();
|
||||
});
|
||||
this.$rootScope.$on("new-update-available", () => {
|
||||
this.$timeout(() => {
|
||||
this.onNewUpdateAvailable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.appState.addObserver((eventName, data) => {
|
||||
if(eventName === APP_STATE_EVENT_EDITOR_FOCUSED) {
|
||||
if (data.eventSource === EVENT_SOURCE_USER_INTERACTION) {
|
||||
this.closeAllRooms();
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
} else if(eventName === APP_STATE_EVENT_BEGAN_BACKUP_DOWNLOAD) {
|
||||
this.backupStatus = this.statusManager.addStatusFromString(
|
||||
"Saving local backup..."
|
||||
);
|
||||
} else if(eventName === APP_STATE_EVENT_ENDED_BACKUP_DOWNLOAD) {
|
||||
if(data.success) {
|
||||
this.backupStatus = this.statusManager.replaceStatusWithString(
|
||||
this.backupStatus,
|
||||
"Successfully saved backup."
|
||||
);
|
||||
} else {
|
||||
this.backupStatus = this.statusManager.replaceStatusWithString(
|
||||
this.backupStatus,
|
||||
"Unable to save local backup."
|
||||
);
|
||||
}
|
||||
this.$timeout(() => {
|
||||
this.backupStatus = this.statusManager.removeStatus(this.backupStatus);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSyncEventHandler() {
|
||||
this.syncManager.addEventHandler((syncEvent, data) => {
|
||||
this.$timeout(() => {
|
||||
if(syncEvent === "local-data-loaded") {
|
||||
if(this.offline && this.modelManager.noteCount() === 0) {
|
||||
this.showAccountMenu = true;
|
||||
}
|
||||
} else if(syncEvent === "enter-out-of-sync") {
|
||||
this.outOfSync = true;
|
||||
} else if(syncEvent === "exit-out-of-sync") {
|
||||
this.outOfSync = false;
|
||||
} else if(syncEvent === 'sync:completed') {
|
||||
this.syncUpdated();
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
} else if(syncEvent === 'sync:error') {
|
||||
this.findErrors();
|
||||
this.updateOfflineStatus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
registerMappingObservers() {
|
||||
this.modelManager.addItemSyncObserver(
|
||||
'room-bar',
|
||||
'SN|Component',
|
||||
(allItems, validItems, deletedItems, source) => {
|
||||
this.rooms = this.modelManager.components.filter((candidate) => {
|
||||
return candidate.area === 'rooms' && !candidate.deleted;
|
||||
});
|
||||
if(this.queueExtReload) {
|
||||
this.queueExtReload = false;
|
||||
this.reloadExtendedData();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this.modelManager.addItemSyncObserver(
|
||||
'footer-bar-themes',
|
||||
'SN|Theme',
|
||||
(allItems, validItems, deletedItems, source) => {
|
||||
const themes = this.modelManager.validItemsForContentType('SN|Theme')
|
||||
.filter((candidate) => {
|
||||
return (
|
||||
!candidate.deleted &&
|
||||
candidate.content.package_info &&
|
||||
candidate.content.package_info.dock_icon
|
||||
);
|
||||
}).sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
const differ = themes.length !== this.themesWithIcons.length;
|
||||
this.themesWithIcons = themes;
|
||||
if(differ) {
|
||||
this.reloadDockShortcuts();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
registerComponentHandler() {
|
||||
this.componentManager.registerHandler({
|
||||
identifier: "roomBar",
|
||||
areas: ["rooms", "modal"],
|
||||
activationHandler: (component) => {},
|
||||
actionHandler: (component, action, data) => {
|
||||
if(action === "set-size") {
|
||||
component.setLastSize(data);
|
||||
}
|
||||
},
|
||||
focusHandler: (component, focused) => {
|
||||
if(component.isEditor() && focused) {
|
||||
this.closeAllRooms();
|
||||
this.closeAccountMenu();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reloadExtendedData() {
|
||||
if(this.reloadInProgress) {
|
||||
return;
|
||||
}
|
||||
this.reloadInProgress = true;
|
||||
|
||||
/**
|
||||
* A reload consists of opening the extensions manager,
|
||||
* then closing it after a short delay.
|
||||
*/
|
||||
const extWindow = this.rooms.find((room) => {
|
||||
return room.package_info.identifier === this.nativeExtManager.extManagerId;
|
||||
});
|
||||
if(!extWindow) {
|
||||
this.queueExtReload = true;
|
||||
this.reloadInProgress = false;
|
||||
return;
|
||||
}
|
||||
this.selectRoom(extWindow);
|
||||
this.$timeout(() => {
|
||||
this.selectRoom(extWindow);
|
||||
this.reloadInProgress = false;
|
||||
this.$rootScope.$broadcast('ext-reload-complete');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.authManager.user;
|
||||
}
|
||||
|
||||
updateOfflineStatus() {
|
||||
this.offline = this.authManager.offline();
|
||||
}
|
||||
|
||||
openSecurityUpdate() {
|
||||
this.authManager.presentPasswordWizard('upgrade-security');
|
||||
}
|
||||
|
||||
findErrors() {
|
||||
this.error = this.syncManager.syncStatus.error;
|
||||
}
|
||||
|
||||
accountMenuPressed() {
|
||||
this.showAccountMenu = !this.showAccountMenu;
|
||||
this.closeAllRooms();
|
||||
}
|
||||
|
||||
toggleSyncResolutionMenu = () => {
|
||||
this.showSyncResolution = !this.showSyncResolution;
|
||||
}
|
||||
|
||||
closeAccountMenu = () => {
|
||||
this.showAccountMenu = false;
|
||||
}
|
||||
|
||||
hasPasscode() {
|
||||
return this.passcodeManager.hasPasscode();
|
||||
}
|
||||
|
||||
lockApp() {
|
||||
this.$rootScope.lockApplication();
|
||||
}
|
||||
|
||||
refreshData() {
|
||||
this.isRefreshing = true;
|
||||
this.syncManager.sync({
|
||||
force: true,
|
||||
performIntegrityCheck: true
|
||||
}).then((response) => {
|
||||
this.$timeout(() => {
|
||||
this.isRefreshing = false;
|
||||
}, 200);
|
||||
if(response && response.error) {
|
||||
this.alertManager.alert({
|
||||
text: STRING_GENERIC_SYNC_ERROR
|
||||
});
|
||||
} else {
|
||||
this.syncUpdated();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
syncUpdated() {
|
||||
this.lastSyncDate = dateToLocalizedString(new Date());
|
||||
}
|
||||
|
||||
onNewUpdateAvailable() {
|
||||
this.newUpdateAvailable = true;
|
||||
}
|
||||
|
||||
clickedNewUpdateAnnouncement() {
|
||||
this.newUpdateAvailable = false;
|
||||
this.alertManager.alert({
|
||||
text: STRING_NEW_UPDATE_READY
|
||||
});
|
||||
}
|
||||
|
||||
reloadDockShortcuts() {
|
||||
const shortcuts = [];
|
||||
for(const theme of this.themesWithIcons) {
|
||||
const name = theme.content.package_info.name;
|
||||
const icon = theme.content.package_info.dock_icon;
|
||||
if(!icon) {
|
||||
continue;
|
||||
}
|
||||
shortcuts.push({
|
||||
name: name,
|
||||
component: theme,
|
||||
icon: icon
|
||||
});
|
||||
}
|
||||
|
||||
this.dockShortcuts = shortcuts.sort((a, b) => {
|
||||
/** Circles first, then images */
|
||||
const aType = a.icon.type;
|
||||
const bType = b.icon.type;
|
||||
if(aType === bType) {
|
||||
return 0;
|
||||
} else if(aType === 'circle' && bType === 'svg') {
|
||||
return -1;
|
||||
} else if(bType === 'circle' && aType === 'svg') {
|
||||
return 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initSvgForShortcut(shortcut) {
|
||||
const id = 'dock-svg-' + shortcut.component.uuid;
|
||||
const element = document.getElementById(id);
|
||||
const parser = new DOMParser();
|
||||
const svg = shortcut.component.content.package_info.dock_icon.source;
|
||||
const doc = parser.parseFromString(svg, 'image/svg+xml');
|
||||
element.appendChild(doc.documentElement);
|
||||
}
|
||||
|
||||
selectShortcut(shortcut) {
|
||||
this.componentManager.toggleComponent(shortcut.component);
|
||||
}
|
||||
|
||||
onRoomDismiss(room) {
|
||||
room.showRoom = false;
|
||||
}
|
||||
|
||||
closeAllRooms() {
|
||||
for(const room of this.rooms) {
|
||||
room.showRoom = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectRoom(room) {
|
||||
const run = () => {
|
||||
this.$timeout(() => {
|
||||
room.showRoom = !room.showRoom;
|
||||
});
|
||||
};
|
||||
|
||||
if(!room.showRoom) {
|
||||
const requiresPrivilege = await this.privilegesManager.actionRequiresPrivilege(
|
||||
PrivilegesManager.ActionManageExtensions
|
||||
);
|
||||
if(requiresPrivilege) {
|
||||
this.privilegesManager.presentPrivilegesModal(
|
||||
PrivilegesManager.ActionManageExtensions,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
clickOutsideAccountMenu() {
|
||||
if(this.privilegesManager.authenticationInProgress()) {
|
||||
return;
|
||||
}
|
||||
this.showAccountMenu = false;
|
||||
}
|
||||
}
|
||||
|
||||
export class Footer {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.scope = {};
|
||||
this.template = template;
|
||||
this.controller = FooterCtrl;
|
||||
this.replace = true;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import template from '%/lock-screen.pug';
|
||||
|
||||
const ELEMENT_ID_PASSCODE_INPUT = 'passcode-input';
|
||||
|
||||
class LockScreenCtrl {
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$scope,
|
||||
alertManager,
|
||||
authManager,
|
||||
passcodeManager,
|
||||
) {
|
||||
this.$scope = $scope;
|
||||
this.alertManager = alertManager;
|
||||
this.authManager = authManager;
|
||||
this.passcodeManager = passcodeManager;
|
||||
this.formData = {};
|
||||
|
||||
this.addVisibilityObserver();
|
||||
this.addDestroyHandler();
|
||||
}
|
||||
|
||||
get passcodeInput() {
|
||||
return document.getElementById(
|
||||
ELEMENT_ID_PASSCODE_INPUT
|
||||
);
|
||||
}
|
||||
|
||||
addDestroyHandler() {
|
||||
this.$scope.$on('$destroy', () => {
|
||||
this.passcodeManager.removeVisibilityObserver(
|
||||
this.visibilityObserver
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
addVisibilityObserver() {
|
||||
this.visibilityObserver = this.passcodeManager
|
||||
.addVisibilityObserver((visible) => {
|
||||
if(visible) {
|
||||
const input = this.passcodeInput;
|
||||
if(input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitPasscodeForm($event) {
|
||||
if(
|
||||
!this.formData.passcode ||
|
||||
this.formData.passcode.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.passcodeInput.blur();
|
||||
this.passcodeManager.unlock(
|
||||
this.formData.passcode,
|
||||
(success) => {
|
||||
if(!success) {
|
||||
this.formData.passcode = null;
|
||||
this.alertManager.alert({
|
||||
text: "Invalid passcode. Please try again.",
|
||||
onClose: () => {
|
||||
this.passcodeInput.focus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.onSuccess()();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
forgotPasscode() {
|
||||
this.formData.showRecovery = true;
|
||||
}
|
||||
|
||||
beginDeleteData() {
|
||||
this.alertManager.confirm({
|
||||
text: "Are you sure you want to clear all local data?",
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
this.authManager.signout(true).then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class LockScreen {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = LockScreenCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
onSuccess: '&',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
export const SORT_KEY_CREATED_AT = 'created_at';
|
||||
export const SORT_KEY_UPDATED_AT = 'updated_at';
|
||||
export const SORT_KEY_CLIENT_UPDATED_AT = 'client_updated_at';
|
||||
export const SORT_KEY_TITLE = 'title';
|
||||
|
||||
export function filterAndSortNotes({
|
||||
notes,
|
||||
selectedTag,
|
||||
showArchived,
|
||||
hidePinned,
|
||||
filterText,
|
||||
sortBy,
|
||||
reverse
|
||||
}) {
|
||||
const filtered = filterNotes({
|
||||
notes,
|
||||
selectedTag,
|
||||
showArchived,
|
||||
hidePinned,
|
||||
filterText,
|
||||
});
|
||||
const sorted = sortNotes({
|
||||
notes: filtered,
|
||||
sortBy,
|
||||
reverse
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
export function filterNotes({
|
||||
notes,
|
||||
selectedTag,
|
||||
showArchived,
|
||||
hidePinned,
|
||||
filterText
|
||||
}) {
|
||||
return notes.filter((note) => {
|
||||
let canShowArchived = showArchived;
|
||||
const canShowPinned = !hidePinned;
|
||||
const isTrash = selectedTag.content.isTrashTag;
|
||||
if (!isTrash && note.content.trashed) {
|
||||
return false;
|
||||
}
|
||||
const isSmartTag = selectedTag.isSmartTag();
|
||||
if (isSmartTag) {
|
||||
canShowArchived = (
|
||||
canShowArchived ||
|
||||
selectedTag.content.isArchiveTag ||
|
||||
isTrash
|
||||
);
|
||||
}
|
||||
if (
|
||||
(note.archived && !canShowArchived) ||
|
||||
(note.pinned && !canShowPinned)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return noteMatchesQuery({
|
||||
note,
|
||||
query: filterText
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function noteMatchesQuery({
|
||||
note,
|
||||
query
|
||||
}) {
|
||||
if(query.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const title = note.safeTitle().toLowerCase();
|
||||
const text = note.safeText().toLowerCase();
|
||||
const lowercaseText = query.toLowerCase();
|
||||
|
||||
const quotedText = stringBetweenQuotes(lowercaseText);
|
||||
if(quotedText) {
|
||||
return title.includes(quotedText) || text.includes(quotedText);
|
||||
}
|
||||
|
||||
if (stringIsUuid(lowercaseText)) {
|
||||
return note.uuid === lowercaseText;
|
||||
}
|
||||
|
||||
const words = lowercaseText.split(" ");
|
||||
const matchesTitle = words.every((word) => {
|
||||
return title.indexOf(word) >= 0;
|
||||
});
|
||||
const matchesBody = words.every((word) => {
|
||||
return text.indexOf(word) >= 0;
|
||||
});
|
||||
|
||||
return matchesTitle || matchesBody;
|
||||
}
|
||||
|
||||
function stringBetweenQuotes(text) {
|
||||
const matches = text.match(/"(.*?)"/);
|
||||
return matches ? matches[1] : null;
|
||||
}
|
||||
|
||||
function stringIsUuid(text) {
|
||||
const matches = text.match(
|
||||
/\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/
|
||||
);
|
||||
// eslint-disable-next-line no-unneeded-ternary
|
||||
return matches ? true : false;
|
||||
}
|
||||
|
||||
export function sortNotes({
|
||||
notes = [],
|
||||
sortBy,
|
||||
reverse
|
||||
}) {
|
||||
const sortValueFn = (a, b, pinCheck = false) => {
|
||||
if (a.dummy) { return -1; }
|
||||
if (b.dummy) { return 1; }
|
||||
if (!pinCheck) {
|
||||
if (a.pinned && b.pinned) {
|
||||
return sortValueFn(a, b, true);
|
||||
}
|
||||
if (a.pinned) { return -1; }
|
||||
if (b.pinned) { return 1; }
|
||||
}
|
||||
|
||||
let aValue = a[sortBy] || '';
|
||||
let bValue = b[sortBy] || '';
|
||||
let vector = 1;
|
||||
if (reverse) {
|
||||
vector *= -1;
|
||||
}
|
||||
if (sortBy === SORT_KEY_TITLE) {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
if (aValue.length === 0 && bValue.length === 0) {
|
||||
return 0;
|
||||
} else if (aValue.length === 0 && bValue.length !== 0) {
|
||||
return 1 * vector;
|
||||
} else if (aValue.length !== 0 && bValue.length === 0) {
|
||||
return -1 * vector;
|
||||
} else {
|
||||
vector *= -1;
|
||||
}
|
||||
}
|
||||
if (aValue > bValue) { return -1 * vector; }
|
||||
else if (aValue < bValue) { return 1 * vector; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
const result = notes.sort(function (a, b) {
|
||||
return sortValueFn(a, b);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,724 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import angular from 'angular';
|
||||
import template from '%/notes.pug';
|
||||
import { SFAuthManager } from 'snjs';
|
||||
import { KeyboardManager } from '@/services/keyboardManager';
|
||||
import { PureCtrl } from '@Controllers';
|
||||
import {
|
||||
APP_STATE_EVENT_NOTE_CHANGED,
|
||||
APP_STATE_EVENT_TAG_CHANGED,
|
||||
APP_STATE_EVENT_PREFERENCES_CHANGED,
|
||||
APP_STATE_EVENT_EDITOR_FOCUSED
|
||||
} from '@/state';
|
||||
import {
|
||||
PREF_NOTES_PANEL_WIDTH,
|
||||
PREF_SORT_NOTES_BY,
|
||||
PREF_SORT_NOTES_REVERSE,
|
||||
PREF_NOTES_SHOW_ARCHIVED,
|
||||
PREF_NOTES_HIDE_PINNED,
|
||||
PREF_NOTES_HIDE_NOTE_PREVIEW,
|
||||
PREF_NOTES_HIDE_DATE,
|
||||
PREF_NOTES_HIDE_TAGS
|
||||
} from '@/services/preferencesManager';
|
||||
import {
|
||||
PANEL_NAME_NOTES
|
||||
} from '@/controllers/constants';
|
||||
import {
|
||||
SORT_KEY_CREATED_AT,
|
||||
SORT_KEY_UPDATED_AT,
|
||||
SORT_KEY_CLIENT_UPDATED_AT,
|
||||
SORT_KEY_TITLE,
|
||||
filterAndSortNotes
|
||||
} from './note_utils';
|
||||
|
||||
/**
|
||||
* This is the height of a note cell with nothing but the title,
|
||||
* which *is* a display option
|
||||
*/
|
||||
const MIN_NOTE_CELL_HEIGHT = 51.0;
|
||||
const DEFAULT_LIST_NUM_NOTES = 20;
|
||||
|
||||
|
||||
const ELEMENT_ID_SEARCH_BAR = 'search-bar';
|
||||
const ELEMENT_ID_SCROLL_CONTAINER = 'notes-scrollable';
|
||||
|
||||
class NotesCtrl extends PureCtrl {
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout,
|
||||
$rootScope,
|
||||
appState,
|
||||
authManager,
|
||||
desktopManager,
|
||||
keyboardManager,
|
||||
modelManager,
|
||||
preferencesManager,
|
||||
privilegesManager,
|
||||
syncManager,
|
||||
) {
|
||||
super($timeout);
|
||||
this.$rootScope = $rootScope;
|
||||
this.appState = appState;
|
||||
this.authManager = authManager;
|
||||
this.desktopManager = desktopManager;
|
||||
this.keyboardManager = keyboardManager;
|
||||
this.modelManager = modelManager;
|
||||
this.preferencesManager = preferencesManager;
|
||||
this.privilegesManager = privilegesManager;
|
||||
this.syncManager = syncManager;
|
||||
|
||||
this.state = {
|
||||
notes: [],
|
||||
renderedNotes: [],
|
||||
selectedNote: null,
|
||||
tag: null,
|
||||
sortBy: null,
|
||||
showArchived: null,
|
||||
hidePinned: null,
|
||||
sortReverse: null,
|
||||
panelTitle: null,
|
||||
mutable: { showMenu: false },
|
||||
noteFilter: { text: '' },
|
||||
};
|
||||
|
||||
this.panelController = {};
|
||||
window.onresize = (event) => {
|
||||
this.resetPagination({
|
||||
keepCurrentIfLarger: true
|
||||
});
|
||||
};
|
||||
|
||||
this.addAppStateObserver();
|
||||
this.addSignInObserver();
|
||||
this.addSyncEventHandler();
|
||||
this.addMappingObserver();
|
||||
this.reloadPreferences();
|
||||
this.resetPagination();
|
||||
this.registerKeyboardShortcuts();
|
||||
angular.element(document).ready(() => {
|
||||
this.reloadPreferences();
|
||||
});
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.appState.addObserver((eventName, data) => {
|
||||
if (eventName === APP_STATE_EVENT_TAG_CHANGED) {
|
||||
this.handleTagChange(this.appState.getSelectedTag(), data.previousTag);
|
||||
} else if (eventName === APP_STATE_EVENT_NOTE_CHANGED) {
|
||||
this.handleNoteSelection(this.appState.getSelectedNote());
|
||||
} else if (eventName === APP_STATE_EVENT_PREFERENCES_CHANGED) {
|
||||
this.reloadPreferences();
|
||||
this.reloadNotes();
|
||||
} else if (eventName === APP_STATE_EVENT_EDITOR_FOCUSED) {
|
||||
this.setShowMenuFalse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSignInObserver() {
|
||||
this.authManager.addEventHandler((event) => {
|
||||
if (event === SFAuthManager.DidSignInEvent) {
|
||||
/** Delete dummy note if applicable */
|
||||
if (this.state.selectedNote && this.state.selectedNote.dummy) {
|
||||
this.modelManager.removeItemLocally(this.state.selectedNote);
|
||||
this.selectNote(null).then(() => {
|
||||
this.reloadNotes();
|
||||
});
|
||||
/**
|
||||
* We want to see if the user will download any items from the server.
|
||||
* If the next sync completes and our notes are still 0,
|
||||
* we need to create a dummy.
|
||||
*/
|
||||
this.createDummyOnSynCompletionIfNoNotes = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSyncEventHandler() {
|
||||
this.syncManager.addEventHandler((syncEvent, data) => {
|
||||
if (syncEvent === 'local-data-loaded') {
|
||||
if (this.state.notes.length === 0) {
|
||||
this.createNewNote();
|
||||
}
|
||||
} else if (syncEvent === 'sync:completed') {
|
||||
if (this.createDummyOnSynCompletionIfNoNotes && this.state.notes.length === 0) {
|
||||
this.createDummyOnSynCompletionIfNoNotes = false;
|
||||
this.createNewNote();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMappingObserver() {
|
||||
this.modelManager.addItemSyncObserver(
|
||||
'note-list',
|
||||
'*',
|
||||
async (allItems, validItems, deletedItems, source, sourceKey) => {
|
||||
await this.reloadNotes();
|
||||
const selectedNote = this.state.selectedNote;
|
||||
if (selectedNote) {
|
||||
const discarded = selectedNote.deleted || selectedNote.content.trashed;
|
||||
if (discarded) {
|
||||
this.selectNextOrCreateNew();
|
||||
}
|
||||
} else {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
|
||||
/** Note has changed values, reset its flags */
|
||||
const notes = allItems.filter((item) => item.content_type === 'Note');
|
||||
for (const note of notes) {
|
||||
this.loadFlagsForNote(note);
|
||||
note.cachedCreatedAtString = note.createdAtString();
|
||||
note.cachedUpdatedAtString = note.updatedAtString();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleTagChange(tag, previousTag) {
|
||||
if (this.state.selectedNote && this.state.selectedNote.dummy) {
|
||||
this.modelManager.removeItemLocally(this.state.selectedNote);
|
||||
if (previousTag) {
|
||||
_.remove(previousTag.notes, this.state.selectedNote);
|
||||
}
|
||||
await this.selectNote(null);
|
||||
}
|
||||
|
||||
await this.setState({
|
||||
tag: tag
|
||||
});
|
||||
|
||||
this.resetScrollPosition();
|
||||
this.setShowMenuFalse();
|
||||
await this.setNoteFilterText('');
|
||||
this.desktopManager.searchText();
|
||||
this.resetPagination();
|
||||
|
||||
await this.reloadNotes();
|
||||
|
||||
if (this.state.notes.length > 0) {
|
||||
this.selectFirstNote();
|
||||
} else if (this.syncManager.initialDataLoaded()) {
|
||||
if (!tag.isSmartTag() || tag.content.isAllTag) {
|
||||
this.createNewNote();
|
||||
} else if (
|
||||
this.state.selectedNote &&
|
||||
!this.state.notes.includes(this.state.selectedNote)
|
||||
) {
|
||||
this.selectNote(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetScrollPosition() {
|
||||
const scrollable = document.getElementById(ELEMENT_ID_SCROLL_CONTAINER);
|
||||
if (scrollable) {
|
||||
scrollable.scrollTop = 0;
|
||||
scrollable.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @template
|
||||
* @internal
|
||||
*/
|
||||
async selectNote(note) {
|
||||
this.appState.setSelectedNote(note);
|
||||
}
|
||||
|
||||
async removeNoteFromList(note) {
|
||||
const notes = this.state.notes;
|
||||
_.pull(notes, note);
|
||||
await this.setState({
|
||||
notes: notes,
|
||||
renderedNotes: notes.slice(0, this.notesToDisplay)
|
||||
});
|
||||
}
|
||||
|
||||
async reloadNotes() {
|
||||
if (!this.state.tag) {
|
||||
return;
|
||||
}
|
||||
const notes = filterAndSortNotes({
|
||||
notes: this.state.tag.notes,
|
||||
selectedTag: this.state.tag,
|
||||
showArchived: this.state.showArchived,
|
||||
hidePinned: this.state.hidePinned,
|
||||
filterText: this.state.noteFilter.text.toLowerCase(),
|
||||
sortBy: this.state.sortBy,
|
||||
reverse: this.state.sortReverse
|
||||
});
|
||||
for (const note of notes) {
|
||||
if (note.errorDecrypting) {
|
||||
this.loadFlagsForNote(note);
|
||||
}
|
||||
note.shouldShowTags = this.shouldShowTagsForNote(note);
|
||||
}
|
||||
await this.setState({
|
||||
notes: notes,
|
||||
renderedNotes: notes.slice(0, this.notesToDisplay)
|
||||
});
|
||||
this.reloadPanelTitle();
|
||||
}
|
||||
|
||||
setShowMenuFalse() {
|
||||
this.setState({
|
||||
mutable: {
|
||||
...this.state.mutable,
|
||||
showMenu: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleNoteSelection(note) {
|
||||
if (this.state.selectedNote === note) {
|
||||
return;
|
||||
}
|
||||
const previousNote = this.state.selectedNote;
|
||||
if (previousNote && previousNote.dummy) {
|
||||
this.modelManager.removeItemLocally(previousNote);
|
||||
this.removeNoteFromList(previousNote);
|
||||
}
|
||||
await this.setState({
|
||||
selectedNote: note
|
||||
});
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedIndex = Math.max(0, this.displayableNotes().indexOf(note));
|
||||
if (note.content.conflict_of) {
|
||||
note.content.conflict_of = null;
|
||||
this.modelManager.setItemDirty(note);
|
||||
this.syncManager.sync();
|
||||
}
|
||||
if (this.isFiltering()) {
|
||||
this.desktopManager.searchText(this.state.noteFilter.text);
|
||||
}
|
||||
}
|
||||
|
||||
reloadPreferences() {
|
||||
const viewOptions = {};
|
||||
const prevSortValue = this.state.sortBy;
|
||||
let sortBy = this.preferencesManager.getValue(
|
||||
PREF_SORT_NOTES_BY,
|
||||
SORT_KEY_CREATED_AT
|
||||
);
|
||||
if (sortBy === SORT_KEY_UPDATED_AT) {
|
||||
/** Use client_updated_at instead */
|
||||
sortBy = SORT_KEY_CLIENT_UPDATED_AT;
|
||||
}
|
||||
viewOptions.sortBy = sortBy;
|
||||
viewOptions.sortReverse = this.preferencesManager.getValue(
|
||||
PREF_SORT_NOTES_REVERSE,
|
||||
false
|
||||
);
|
||||
viewOptions.showArchived = this.preferencesManager.getValue(
|
||||
PREF_NOTES_SHOW_ARCHIVED,
|
||||
false
|
||||
);
|
||||
viewOptions.hidePinned = this.preferencesManager.getValue(
|
||||
PREF_NOTES_HIDE_PINNED,
|
||||
false
|
||||
);
|
||||
viewOptions.hideNotePreview = this.preferencesManager.getValue(
|
||||
PREF_NOTES_HIDE_NOTE_PREVIEW,
|
||||
false
|
||||
);
|
||||
viewOptions.hideDate = this.preferencesManager.getValue(
|
||||
PREF_NOTES_HIDE_DATE,
|
||||
false
|
||||
);
|
||||
viewOptions.hideTags = this.preferencesManager.getValue(
|
||||
PREF_NOTES_HIDE_TAGS,
|
||||
false
|
||||
);
|
||||
this.setState({
|
||||
...viewOptions
|
||||
});
|
||||
if (prevSortValue && prevSortValue !== sortBy) {
|
||||
this.selectFirstNote();
|
||||
}
|
||||
const width = this.preferencesManager.getValue(
|
||||
PREF_NOTES_PANEL_WIDTH
|
||||
);
|
||||
if (width) {
|
||||
this.panelController.setWidth(width);
|
||||
if (this.panelController.isCollapsed()) {
|
||||
this.appState.panelDidResize({
|
||||
name: PANEL_NAME_NOTES,
|
||||
collapsed: this.panelController.isCollapsed()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPanelResize = (newWidth, lastLeft, isAtMaxWidth, isCollapsed) => {
|
||||
this.preferencesManager.setUserPrefValue(
|
||||
PREF_NOTES_PANEL_WIDTH,
|
||||
newWidth
|
||||
);
|
||||
this.preferencesManager.syncUserPreferences();
|
||||
this.appState.panelDidResize({
|
||||
name: PANEL_NAME_NOTES,
|
||||
collapsed: isCollapsed
|
||||
});
|
||||
}
|
||||
|
||||
paginate() {
|
||||
this.notesToDisplay += this.pageSize;
|
||||
this.reloadNotes();
|
||||
if (this.searchSubmitted) {
|
||||
this.desktopManager.searchText(this.state.noteFilter.text);
|
||||
}
|
||||
}
|
||||
|
||||
resetPagination({ keepCurrentIfLarger } = {}) {
|
||||
const clientHeight = document.documentElement.clientHeight;
|
||||
this.pageSize = clientHeight / MIN_NOTE_CELL_HEIGHT;
|
||||
if (this.pageSize === 0) {
|
||||
this.pageSize = DEFAULT_LIST_NUM_NOTES;
|
||||
}
|
||||
if (keepCurrentIfLarger && this.notesToDisplay > this.pageSize) {
|
||||
return;
|
||||
}
|
||||
this.notesToDisplay = this.pageSize;
|
||||
}
|
||||
|
||||
reloadPanelTitle() {
|
||||
let title;
|
||||
if (this.isFiltering()) {
|
||||
const resultCount = this.state.notes.length;
|
||||
title = `${resultCount} search results`;
|
||||
} else if (this.state.tag) {
|
||||
title = `${this.state.tag.title}`;
|
||||
}
|
||||
this.setState({
|
||||
panelTitle: title
|
||||
});
|
||||
}
|
||||
|
||||
optionsSubtitle() {
|
||||
let base = "";
|
||||
if (this.state.sortBy === 'created_at') {
|
||||
base += " Date Added";
|
||||
} else if (this.state.sortBy === 'client_updated_at') {
|
||||
base += " Date Modified";
|
||||
} else if (this.state.sortBy === 'title') {
|
||||
base += " Title";
|
||||
}
|
||||
if (this.state.showArchived) {
|
||||
base += " | + Archived";
|
||||
}
|
||||
if (this.state.hidePinned) {
|
||||
base += " | – Pinned";
|
||||
}
|
||||
if (this.state.sortReverse) {
|
||||
base += " | Reversed";
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
loadFlagsForNote(note) {
|
||||
const flags = [];
|
||||
if (note.pinned) {
|
||||
flags.push({
|
||||
text: "Pinned",
|
||||
class: 'info'
|
||||
});
|
||||
}
|
||||
if (note.archived) {
|
||||
flags.push({
|
||||
text: "Archived",
|
||||
class: 'warning'
|
||||
});
|
||||
}
|
||||
if (note.content.protected) {
|
||||
flags.push({
|
||||
text: "Protected",
|
||||
class: 'success'
|
||||
});
|
||||
}
|
||||
if (note.locked) {
|
||||
flags.push({
|
||||
text: "Locked",
|
||||
class: 'neutral'
|
||||
});
|
||||
}
|
||||
if (note.content.trashed) {
|
||||
flags.push({
|
||||
text: "Deleted",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
if (note.content.conflict_of) {
|
||||
flags.push({
|
||||
text: "Conflicted Copy",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
if (note.errorDecrypting) {
|
||||
flags.push({
|
||||
text: "Missing Keys",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
if (note.deleted) {
|
||||
flags.push({
|
||||
text: "Deletion Pending Sync",
|
||||
class: 'danger'
|
||||
});
|
||||
}
|
||||
note.flags = flags;
|
||||
return flags;
|
||||
}
|
||||
|
||||
displayableNotes() {
|
||||
return this.state.notes;
|
||||
}
|
||||
|
||||
getFirstNonProtectedNote() {
|
||||
const displayableNotes = this.displayableNotes();
|
||||
let index = 0;
|
||||
let note = displayableNotes[index];
|
||||
while (note && note.content.protected) {
|
||||
index++;
|
||||
if (index >= displayableNotes.length) {
|
||||
break;
|
||||
}
|
||||
note = displayableNotes[index];
|
||||
}
|
||||
return note;
|
||||
}
|
||||
|
||||
selectFirstNote() {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note);
|
||||
}
|
||||
}
|
||||
|
||||
selectNextNote() {
|
||||
const displayableNotes = this.displayableNotes();
|
||||
const currentIndex = displayableNotes.indexOf(this.state.selectedNote);
|
||||
if (currentIndex + 1 < displayableNotes.length) {
|
||||
this.selectNote(displayableNotes[currentIndex + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
selectNextOrCreateNew() {
|
||||
const note = this.getFirstNonProtectedNote();
|
||||
if (note) {
|
||||
this.selectNote(note);
|
||||
} else if (!this.state.tag || !this.state.tag.isSmartTag()) {
|
||||
this.createNewNote();
|
||||
} else {
|
||||
this.selectNote(null);
|
||||
}
|
||||
}
|
||||
|
||||
selectPreviousNote() {
|
||||
const displayableNotes = this.displayableNotes();
|
||||
const currentIndex = displayableNotes.indexOf(this.state.selectedNote);
|
||||
if (currentIndex - 1 >= 0) {
|
||||
this.selectNote(displayableNotes[currentIndex - 1]);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
createNewNote() {
|
||||
let title;
|
||||
let isDummyNote = true;
|
||||
if (this.isFiltering()) {
|
||||
title = this.state.noteFilter.text;
|
||||
isDummyNote = false;
|
||||
} else if (this.state.selectedNote && this.state.selectedNote.dummy) {
|
||||
return;
|
||||
} else {
|
||||
title = `Note ${this.state.notes.length + 1}`;
|
||||
}
|
||||
const newNote = this.modelManager.createItem({
|
||||
content_type: 'Note',
|
||||
content: {
|
||||
text: '',
|
||||
title: title
|
||||
}
|
||||
});
|
||||
newNote.client_updated_at = new Date();
|
||||
newNote.dummy = isDummyNote;
|
||||
this.modelManager.addItem(newNote);
|
||||
this.modelManager.setItemDirty(newNote);
|
||||
const selectedTag = this.appState.getSelectedTag();
|
||||
if (!selectedTag.isSmartTag()) {
|
||||
selectedTag.addItemAsRelationship(newNote);
|
||||
this.modelManager.setItemDirty(selectedTag);
|
||||
}
|
||||
this.selectNote(newNote);
|
||||
}
|
||||
|
||||
isFiltering() {
|
||||
return this.state.noteFilter.text &&
|
||||
this.state.noteFilter.text.length > 0;
|
||||
}
|
||||
|
||||
async setNoteFilterText(text) {
|
||||
await this.setState({
|
||||
noteFilter: {
|
||||
...this.state.noteFilter,
|
||||
text: text
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async clearFilterText() {
|
||||
await this.setNoteFilterText('');
|
||||
this.onFilterEnter();
|
||||
this.filterTextChanged();
|
||||
this.resetPagination();
|
||||
}
|
||||
|
||||
async filterTextChanged() {
|
||||
if (this.searchSubmitted) {
|
||||
this.searchSubmitted = false;
|
||||
}
|
||||
await this.reloadNotes();
|
||||
}
|
||||
|
||||
onFilterEnter() {
|
||||
/**
|
||||
* For Desktop, performing a search right away causes
|
||||
* input to lose focus. We wait until user explicity hits
|
||||
* enter before highlighting desktop search results.
|
||||
*/
|
||||
this.searchSubmitted = true;
|
||||
this.desktopManager.searchText(this.state.noteFilter.text);
|
||||
}
|
||||
|
||||
selectedMenuItem() {
|
||||
this.setShowMenuFalse();
|
||||
}
|
||||
|
||||
togglePrefKey(key) {
|
||||
this.preferencesManager.setUserPrefValue(key, !this.state[key]);
|
||||
this.preferencesManager.syncUserPreferences();
|
||||
}
|
||||
|
||||
selectedSortByCreated() {
|
||||
this.setSortBy(SORT_KEY_CREATED_AT);
|
||||
}
|
||||
|
||||
selectedSortByUpdated() {
|
||||
this.setSortBy(SORT_KEY_CLIENT_UPDATED_AT);
|
||||
}
|
||||
|
||||
selectedSortByTitle() {
|
||||
this.setSortBy(SORT_KEY_TITLE);
|
||||
}
|
||||
|
||||
toggleReverseSort() {
|
||||
this.selectedMenuItem();
|
||||
this.preferencesManager.setUserPrefValue(
|
||||
PREF_SORT_NOTES_REVERSE,
|
||||
!this.state.sortReverse
|
||||
);
|
||||
this.preferencesManager.syncUserPreferences();
|
||||
}
|
||||
|
||||
setSortBy(type) {
|
||||
this.preferencesManager.setUserPrefValue(
|
||||
PREF_SORT_NOTES_BY,
|
||||
type
|
||||
);
|
||||
this.preferencesManager.syncUserPreferences();
|
||||
}
|
||||
|
||||
shouldShowTagsForNote(note) {
|
||||
if (this.state.hideTags || note.content.protected) {
|
||||
return false;
|
||||
}
|
||||
if (this.state.tag.content.isAllTag) {
|
||||
return note.tags && note.tags.length > 0;
|
||||
}
|
||||
if (this.state.tag.isSmartTag()) {
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Inside a tag, only show tags string if
|
||||
* note contains tags other than this.state.tag
|
||||
*/
|
||||
return note.tags && note.tags.length > 1;
|
||||
}
|
||||
|
||||
getSearchBar() {
|
||||
return document.getElementById(ELEMENT_ID_SEARCH_BAR);
|
||||
}
|
||||
|
||||
registerKeyboardShortcuts() {
|
||||
/**
|
||||
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
|
||||
* use Control modifier as well. These rules don't apply to desktop, but
|
||||
* probably better to be consistent.
|
||||
*/
|
||||
this.newNoteKeyObserver = this.keyboardManager.addKeyObserver({
|
||||
key: 'n',
|
||||
modifiers: [
|
||||
KeyboardManager.KeyModifierMeta,
|
||||
KeyboardManager.KeyModifierCtrl
|
||||
],
|
||||
onKeyDown: (event) => {
|
||||
event.preventDefault();
|
||||
this.createNewNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.nextNoteKeyObserver = this.keyboardManager.addKeyObserver({
|
||||
key: KeyboardManager.KeyDown,
|
||||
elements: [
|
||||
document.body,
|
||||
this.getSearchBar()
|
||||
],
|
||||
onKeyDown: (event) => {
|
||||
const searchBar = this.getSearchBar();
|
||||
if (searchBar === document.activeElement) {
|
||||
searchBar.blur();
|
||||
}
|
||||
this.selectNextNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.nextNoteKeyObserver = this.keyboardManager.addKeyObserver({
|
||||
key: KeyboardManager.KeyUp,
|
||||
element: document.body,
|
||||
onKeyDown: (event) => {
|
||||
this.selectPreviousNote();
|
||||
}
|
||||
});
|
||||
|
||||
this.searchKeyObserver = this.keyboardManager.addKeyObserver({
|
||||
key: "f",
|
||||
modifiers: [
|
||||
KeyboardManager.KeyModifierMeta,
|
||||
KeyboardManager.KeyModifierShift
|
||||
],
|
||||
onKeyDown: (event) => {
|
||||
const searchBar = this.getSearchBar();
|
||||
if (searchBar) { searchBar.focus(); };
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotesPanel {
|
||||
constructor() {
|
||||
this.scope = {};
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = NotesCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,329 +0,0 @@
|
|||
import { SFAuthManager } from 'snjs';
|
||||
import { getPlatformString } from '@/utils';
|
||||
import template from '%/root.pug';
|
||||
import {
|
||||
APP_STATE_EVENT_PANEL_RESIZED
|
||||
} from '@/state';
|
||||
import {
|
||||
PANEL_NAME_NOTES,
|
||||
PANEL_NAME_TAGS
|
||||
} from '@/controllers/constants';
|
||||
import {
|
||||
STRING_SESSION_EXPIRED,
|
||||
STRING_DEFAULT_FILE_ERROR,
|
||||
StringSyncException
|
||||
} from '@/strings';
|
||||
|
||||
/** How often to automatically sync, in milliseconds */
|
||||
const AUTO_SYNC_INTERVAL = 30000;
|
||||
|
||||
class RootCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$location,
|
||||
$rootScope,
|
||||
$scope,
|
||||
$timeout,
|
||||
alertManager,
|
||||
appState,
|
||||
authManager,
|
||||
dbManager,
|
||||
modelManager,
|
||||
passcodeManager,
|
||||
preferencesManager,
|
||||
themeManager /** Unused below, required to load globally */,
|
||||
statusManager,
|
||||
storageManager,
|
||||
syncManager,
|
||||
) {
|
||||
this.$rootScope = $rootScope;
|
||||
this.$scope = $scope;
|
||||
this.$location = $location;
|
||||
this.$timeout = $timeout;
|
||||
this.dbManager = dbManager;
|
||||
this.syncManager = syncManager;
|
||||
this.statusManager = statusManager;
|
||||
this.storageManager = storageManager;
|
||||
this.appState = appState;
|
||||
this.authManager = authManager;
|
||||
this.modelManager = modelManager;
|
||||
this.alertManager = alertManager;
|
||||
this.preferencesManager = preferencesManager;
|
||||
this.passcodeManager = passcodeManager;
|
||||
|
||||
this.defineRootScopeFunctions();
|
||||
this.initializeStorageManager();
|
||||
this.addAppStateObserver();
|
||||
this.defaultLoad();
|
||||
this.handleAutoSignInFromParams();
|
||||
this.addDragDropHandlers();
|
||||
}
|
||||
|
||||
defineRootScopeFunctions() {
|
||||
this.$rootScope.lockApplication = () => {
|
||||
/** Reloading wipes current objects from memory */
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
this.$rootScope.safeApply = (fn) => {
|
||||
const phase = this.$scope.$root.$$phase;
|
||||
if(phase === '$apply' || phase === '$digest') {
|
||||
this.$scope.$eval(fn);
|
||||
} else {
|
||||
this.$scope.$apply(fn);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
defaultLoad() {
|
||||
this.$scope.platform = getPlatformString();
|
||||
|
||||
if(this.passcodeManager.isLocked()) {
|
||||
this.$scope.needsUnlock = true;
|
||||
} else {
|
||||
this.loadAfterUnlock();
|
||||
}
|
||||
|
||||
this.$scope.onSuccessfulUnlock = () => {
|
||||
this.$timeout(() => {
|
||||
this.$scope.needsUnlock = false;
|
||||
this.loadAfterUnlock();
|
||||
});
|
||||
};
|
||||
|
||||
this.$scope.onUpdateAvailable = () => {
|
||||
this.$rootScope.$broadcast('new-update-available');
|
||||
};
|
||||
}
|
||||
|
||||
initializeStorageManager() {
|
||||
this.storageManager.initialize(
|
||||
this.passcodeManager.hasPasscode(),
|
||||
this.authManager.isEphemeralSession()
|
||||
);
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.appState.addObserver((eventName, data) => {
|
||||
if(eventName === APP_STATE_EVENT_PANEL_RESIZED) {
|
||||
if(data.panel === PANEL_NAME_NOTES) {
|
||||
this.notesCollapsed = data.collapsed;
|
||||
}
|
||||
if(data.panel === PANEL_NAME_TAGS) {
|
||||
this.tagsCollapsed = data.collapsed;
|
||||
}
|
||||
let appClass = "";
|
||||
if(this.notesCollapsed) { appClass += "collapsed-notes"; }
|
||||
if(this.tagsCollapsed) { appClass += " collapsed-tags"; }
|
||||
this.$scope.appClass = appClass;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadAfterUnlock() {
|
||||
this.openDatabase();
|
||||
this.authManager.loadInitialData();
|
||||
this.preferencesManager.load();
|
||||
this.addSyncStatusObserver();
|
||||
this.configureKeyRequestHandler();
|
||||
this.addSyncEventHandler();
|
||||
this.addSignOutObserver();
|
||||
this.loadLocalData();
|
||||
}
|
||||
|
||||
openDatabase() {
|
||||
this.dbManager.setLocked(false);
|
||||
this.dbManager.openDatabase({
|
||||
onUpgradeNeeded: () => {
|
||||
/**
|
||||
* New database, delete syncToken so that items
|
||||
* can be refetched entirely from server
|
||||
*/
|
||||
this.syncManager.clearSyncToken();
|
||||
this.syncManager.sync();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addSyncStatusObserver() {
|
||||
this.syncStatusObserver = this.syncManager.registerSyncStatusObserver((status) => {
|
||||
if(status.retrievedCount > 20) {
|
||||
const text = `Downloading ${status.retrievedCount} items. Keep app open.`;
|
||||
this.syncStatus = this.statusManager.replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
text
|
||||
);
|
||||
this.showingDownloadStatus = true;
|
||||
} else if(this.showingDownloadStatus) {
|
||||
this.showingDownloadStatus = false;
|
||||
const text = "Download Complete.";
|
||||
this.syncStatus = this.statusManager.replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
text
|
||||
);
|
||||
setTimeout(() => {
|
||||
this.syncStatus = this.statusManager.removeStatus(this.syncStatus);
|
||||
}, 2000);
|
||||
} else if(status.total > 20) {
|
||||
this.uploadSyncStatus = this.statusManager.replaceStatusWithString(
|
||||
this.uploadSyncStatus,
|
||||
`Syncing ${status.current}/${status.total} items...`
|
||||
);
|
||||
} else if(this.uploadSyncStatus) {
|
||||
this.uploadSyncStatus = this.statusManager.removeStatus(
|
||||
this.uploadSyncStatus
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
configureKeyRequestHandler() {
|
||||
this.syncManager.setKeyRequestHandler(async () => {
|
||||
const offline = this.authManager.offline();
|
||||
const authParams = (
|
||||
offline
|
||||
? this.passcodeManager.passcodeAuthParams()
|
||||
: await this.authManager.getAuthParams()
|
||||
);
|
||||
const keys = offline
|
||||
? this.passcodeManager.keys()
|
||||
: await this.authManager.keys();
|
||||
return {
|
||||
keys: keys,
|
||||
offline: offline,
|
||||
auth_params: authParams
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
addSyncEventHandler() {
|
||||
let lastShownDate;
|
||||
this.syncManager.addEventHandler((syncEvent, data) => {
|
||||
this.$rootScope.$broadcast(
|
||||
syncEvent,
|
||||
data || {}
|
||||
);
|
||||
if(syncEvent === 'sync-session-invalid') {
|
||||
/** Don't show repeatedly; at most 30 seconds in between */
|
||||
const SHOW_INTERVAL = 30;
|
||||
const lastShownSeconds = (new Date() - lastShownDate) / 1000;
|
||||
if(!lastShownDate || lastShownSeconds > SHOW_INTERVAL) {
|
||||
lastShownDate = new Date();
|
||||
setTimeout(() => {
|
||||
this.alertManager.alert({
|
||||
text: STRING_SESSION_EXPIRED
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
} else if(syncEvent === 'sync-exception') {
|
||||
this.alertManager.alert({
|
||||
text: StringSyncException(data)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadLocalData() {
|
||||
const encryptionEnabled = this.authManager.user || this.passcodeManager.hasPasscode();
|
||||
this.syncStatus = this.statusManager.addStatusFromString(
|
||||
encryptionEnabled ? "Decrypting items..." : "Loading items..."
|
||||
);
|
||||
const incrementalCallback = (current, total) => {
|
||||
const notesString = `${current}/${total} items...`;
|
||||
const status = encryptionEnabled
|
||||
? `Decrypting ${notesString}`
|
||||
: `Loading ${notesString}`;
|
||||
this.syncStatus = this.statusManager.replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
status
|
||||
);
|
||||
};
|
||||
this.syncManager.loadLocalItems({incrementalCallback}).then(() => {
|
||||
this.$timeout(() => {
|
||||
this.$rootScope.$broadcast("initial-data-loaded");
|
||||
this.syncStatus = this.statusManager.replaceStatusWithString(
|
||||
this.syncStatus,
|
||||
"Syncing..."
|
||||
);
|
||||
this.syncManager.sync({
|
||||
performIntegrityCheck: true
|
||||
}).then(() => {
|
||||
this.syncStatus = this.statusManager.removeStatus(this.syncStatus);
|
||||
});
|
||||
setInterval(() => {
|
||||
this.syncManager.sync();
|
||||
}, AUTO_SYNC_INTERVAL);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addSignOutObserver() {
|
||||
this.authManager.addEventHandler((event) => {
|
||||
if(event === SFAuthManager.DidSignOutEvent) {
|
||||
this.modelManager.handleSignout();
|
||||
this.syncManager.handleSignout();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addDragDropHandlers() {
|
||||
/**
|
||||
* Disable dragging and dropping of files (but allow text) into main SN interface.
|
||||
* both 'dragover' and 'drop' are required to prevent dropping of files.
|
||||
* This will not prevent extensions from receiving drop events.
|
||||
*/
|
||||
window.addEventListener('dragover', (event) => {
|
||||
if (event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, false);
|
||||
|
||||
window.addEventListener('drop', (event) => {
|
||||
if(event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
this.alertManager.alert({
|
||||
text: STRING_DEFAULT_FILE_ERROR
|
||||
});
|
||||
}
|
||||
}, false);
|
||||
}
|
||||
|
||||
async handleAutoSignInFromParams() {
|
||||
const params = this.$location.search();
|
||||
const server = params.server;
|
||||
const email = params.email;
|
||||
const password = params.pw;
|
||||
if (!server || !email || !password) return;
|
||||
|
||||
if (this.authManager.offline()) {
|
||||
const { error } = await this.authManager.login(
|
||||
server,
|
||||
email,
|
||||
password,
|
||||
false,
|
||||
false,
|
||||
{}
|
||||
);
|
||||
if (!error) {
|
||||
window.location.reload();
|
||||
}
|
||||
} else if (
|
||||
this.authManager.user.email === email &&
|
||||
(await this.syncManager.getServerURL()) === server
|
||||
) {
|
||||
/** Already signed in, return */
|
||||
// eslint-disable-next-line no-useless-return
|
||||
return;
|
||||
} else {
|
||||
this.authManager.signout(true);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Root {
|
||||
constructor() {
|
||||
this.template = template;
|
||||
this.controller = RootCtrl;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
import { SNNote, SNSmartTag } from 'snjs';
|
||||
import template from '%/tags.pug';
|
||||
import {
|
||||
APP_STATE_EVENT_PREFERENCES_CHANGED,
|
||||
APP_STATE_EVENT_TAG_CHANGED
|
||||
} from '@/state';
|
||||
import { PANEL_NAME_TAGS } from '@/controllers/constants';
|
||||
import { PREF_TAGS_PANEL_WIDTH } from '@/services/preferencesManager';
|
||||
import { STRING_DELETE_TAG } from '@/strings';
|
||||
import { PureCtrl } from '@Controllers';
|
||||
|
||||
class TagsPanelCtrl extends PureCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope,
|
||||
$timeout,
|
||||
alertManager,
|
||||
appState,
|
||||
componentManager,
|
||||
modelManager,
|
||||
preferencesManager,
|
||||
syncManager,
|
||||
) {
|
||||
super($timeout);
|
||||
this.$rootScope = $rootScope;
|
||||
this.alertManager = alertManager;
|
||||
this.appState = appState;
|
||||
this.componentManager = componentManager;
|
||||
this.modelManager = modelManager;
|
||||
this.preferencesManager = preferencesManager;
|
||||
this.syncManager = syncManager;
|
||||
this.panelController = {};
|
||||
this.addSyncEventHandler();
|
||||
this.addAppStateObserver();
|
||||
this.addMappingObserver();
|
||||
this.loadPreferences();
|
||||
this.registerComponentHandler();
|
||||
this.state = {
|
||||
smartTags: this.modelManager.getSmartTags(),
|
||||
noteCounts: {}
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.selectTag(this.state.smartTags[0]);
|
||||
}
|
||||
|
||||
addSyncEventHandler() {
|
||||
this.syncManager.addEventHandler(async (syncEvent, data) => {
|
||||
if (
|
||||
syncEvent === 'local-data-loaded' ||
|
||||
syncEvent === 'sync:completed' ||
|
||||
syncEvent === 'local-data-incremental-load'
|
||||
) {
|
||||
await this.setState({
|
||||
tags: this.modelManager.tags,
|
||||
smartTags: this.modelManager.getSmartTags()
|
||||
});
|
||||
this.reloadNoteCounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addAppStateObserver() {
|
||||
this.appState.addObserver((eventName, data) => {
|
||||
if (eventName === APP_STATE_EVENT_PREFERENCES_CHANGED) {
|
||||
this.loadPreferences();
|
||||
} else if (eventName === APP_STATE_EVENT_TAG_CHANGED) {
|
||||
this.setState({
|
||||
selectedTag: this.appState.getSelectedTag()
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addMappingObserver() {
|
||||
this.modelManager.addItemSyncObserver(
|
||||
'tags-list-tags',
|
||||
'Tag',
|
||||
(allItems, validItems, deletedItems, source, sourceKey) => {
|
||||
this.reloadNoteCounts();
|
||||
|
||||
if (!this.state.selectedTag) {
|
||||
return;
|
||||
}
|
||||
/** If the selected tag has been deleted, revert to All view. */
|
||||
const selectedTag = allItems.find((tag) => {
|
||||
return tag.uuid === this.state.selectedTag.uuid;
|
||||
});
|
||||
if (selectedTag && selectedTag.deleted) {
|
||||
this.selectTag(this.state.smartTags[0]);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
reloadNoteCounts() {
|
||||
let allTags = [];
|
||||
if (this.state.tags) {
|
||||
allTags = allTags.concat(this.state.tags);
|
||||
}
|
||||
if (this.state.smartTags) {
|
||||
allTags = allTags.concat(this.state.smartTags);
|
||||
}
|
||||
const noteCounts = {};
|
||||
for (const tag of allTags) {
|
||||
const validNotes = SNNote.filterDummyNotes(tag.notes).filter((note) => {
|
||||
return !note.archived && !note.content.trashed;
|
||||
});
|
||||
noteCounts[tag.uuid] = validNotes.length;
|
||||
}
|
||||
this.setState({
|
||||
noteCounts: noteCounts
|
||||
});
|
||||
}
|
||||
|
||||
loadPreferences() {
|
||||
const width = this.preferencesManager.getValue(PREF_TAGS_PANEL_WIDTH);
|
||||
if (width) {
|
||||
this.panelController.setWidth(width);
|
||||
if (this.panelController.isCollapsed()) {
|
||||
this.appState.panelDidResize({
|
||||
name: PANEL_NAME_TAGS,
|
||||
collapsed: this.panelController.isCollapsed()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onPanelResize = (newWidth, lastLeft, isAtMaxWidth, isCollapsed) => {
|
||||
this.preferencesManager.setUserPrefValue(
|
||||
PREF_TAGS_PANEL_WIDTH,
|
||||
newWidth,
|
||||
true
|
||||
);
|
||||
this.appState.panelDidResize({
|
||||
name: PANEL_NAME_TAGS,
|
||||
collapsed: isCollapsed
|
||||
});
|
||||
}
|
||||
|
||||
registerComponentHandler() {
|
||||
this.componentManager.registerHandler({
|
||||
identifier: 'tags',
|
||||
areas: ['tags-list'],
|
||||
activationHandler: (component) => {
|
||||
this.component = component;
|
||||
},
|
||||
contextRequestHandler: (component) => {
|
||||
return null;
|
||||
},
|
||||
actionHandler: (component, action, data) => {
|
||||
if (action === 'select-item') {
|
||||
if (data.item.content_type === 'Tag') {
|
||||
const tag = this.modelManager.findItem(data.item.uuid);
|
||||
if (tag) {
|
||||
this.selectTag(tag);
|
||||
}
|
||||
} else if (data.item.content_type === 'SN|SmartTag') {
|
||||
const smartTag = new SNSmartTag(data.item);
|
||||
this.selectTag(smartTag);
|
||||
}
|
||||
} else if (action === 'clear-selection') {
|
||||
this.selectTag(this.state.smartTags[0]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async selectTag(tag) {
|
||||
if (tag.isSmartTag()) {
|
||||
Object.defineProperty(tag, 'notes', {
|
||||
get: () => {
|
||||
return this.modelManager.notesMatchingSmartTag(tag);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (tag.content.conflict_of) {
|
||||
tag.content.conflict_of = null;
|
||||
this.modelManager.setItemDirty(tag);
|
||||
this.syncManager.sync();
|
||||
}
|
||||
this.appState.setSelectedTag(tag);
|
||||
}
|
||||
|
||||
clickedAddNewTag() {
|
||||
if (this.state.editingTag) {
|
||||
return;
|
||||
}
|
||||
const newTag = this.modelManager.createItem({
|
||||
content_type: 'Tag'
|
||||
});
|
||||
this.setState({
|
||||
previousTag: this.state.selectedTag,
|
||||
selectedTag: newTag,
|
||||
editingTag: newTag,
|
||||
newTag: newTag
|
||||
});
|
||||
this.modelManager.addItem(newTag);
|
||||
}
|
||||
|
||||
tagTitleDidChange(tag) {
|
||||
this.setState({
|
||||
editingTag: tag
|
||||
});
|
||||
}
|
||||
|
||||
async saveTag($event, tag) {
|
||||
$event.target.blur();
|
||||
await this.setState({
|
||||
editingTag: null
|
||||
});
|
||||
if (!tag.title || tag.title.length === 0) {
|
||||
if (this.state.editingTag) {
|
||||
tag.title = this.editingOriginalName;
|
||||
this.editingOriginalName = null;
|
||||
} else if(this.state.newTag) {
|
||||
this.modelManager.removeItemLocally(tag);
|
||||
this.setState({
|
||||
selectedTag: this.state.previousTag
|
||||
});
|
||||
}
|
||||
this.setState({ newTag: null });
|
||||
return;
|
||||
}
|
||||
|
||||
this.editingOriginalName = null;
|
||||
|
||||
const matchingTag = this.modelManager.findTag(tag.title);
|
||||
const alreadyExists = matchingTag && matchingTag !== tag;
|
||||
if (this.state.newTag === tag && alreadyExists) {
|
||||
this.alertManager.alert({
|
||||
text: "A tag with this name already exists."
|
||||
});
|
||||
this.modelManager.removeItemLocally(tag);
|
||||
this.setState({ newTag: null });
|
||||
return;
|
||||
}
|
||||
|
||||
this.modelManager.setItemDirty(tag);
|
||||
this.syncManager.sync();
|
||||
this.modelManager.resortTag(tag);
|
||||
this.selectTag(tag);
|
||||
this.setState({
|
||||
newTag: null
|
||||
});
|
||||
}
|
||||
|
||||
async selectedRenameTag($event, tag) {
|
||||
this.editingOriginalName = tag.title;
|
||||
await this.setState({
|
||||
editingTag: tag
|
||||
});
|
||||
document.getElementById('tag-' + tag.uuid).focus();
|
||||
}
|
||||
|
||||
selectedDeleteTag(tag) {
|
||||
this.removeTag(tag);
|
||||
this.selectTag(this.state.smartTags[0]);
|
||||
}
|
||||
|
||||
removeTag(tag) {
|
||||
this.alertManager.confirm({
|
||||
text: STRING_DELETE_TAG,
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
this.modelManager.setItemToBeDeleted(tag);
|
||||
this.syncManager.sync();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TagsPanel {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.scope = {};
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = TagsPanelCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
219
app/assets/javascripts/database.ts
Normal file
219
app/assets/javascripts/database.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { SNAlertService } from "snjs/dist/@types";
|
||||
|
||||
const STORE_NAME = 'items';
|
||||
const READ_WRITE = 'readwrite';
|
||||
|
||||
const OUT_OF_SPACE =
|
||||
'Unable to save changes locally because your device is out of space. ' +
|
||||
'Please free up some disk space and try again, otherwise, your data may end ' +
|
||||
'up in an inconsistent state.';
|
||||
|
||||
const DB_DELETION_BLOCKED =
|
||||
'Your browser is blocking Standard Notes from deleting the local database. ' +
|
||||
'Make sure there are no other open windows of this app and try again. ' +
|
||||
'If the issue persists, please manually delete app data to sign out.';
|
||||
|
||||
const QUOTE_EXCEEDED_ERROR = 'QuotaExceededError';
|
||||
|
||||
export class Database {
|
||||
private locked = true
|
||||
private db?: IDBDatabase
|
||||
|
||||
constructor(
|
||||
public databaseName: string,
|
||||
private alertService: SNAlertService) {
|
||||
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
(this.alertService as any) = undefined;
|
||||
this.db = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relinquishes the lock and allows db operations to proceed
|
||||
*/
|
||||
public unlock() {
|
||||
this.locked = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the database natively, or returns the existing database object if already opened.
|
||||
* @param onNewDatabase - Callback to invoke when a database has been created
|
||||
* as part of the open process. This can happen on new application sessions, or if the
|
||||
* browser deleted the database without the user being aware.
|
||||
*/
|
||||
public async openDatabase(onNewDatabase?: () => void): Promise<IDBDatabase | undefined> {
|
||||
if (this.locked) {
|
||||
throw Error('Attempting to open locked database');
|
||||
}
|
||||
if (this.db) {
|
||||
return this.db;
|
||||
}
|
||||
const request = window.indexedDB.open(this.databaseName, 1);
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onerror = (event) => {
|
||||
const target = event!.target! as any;
|
||||
if (target.errorCode) {
|
||||
this.showAlert('Offline database issue: ' + target.errorCode);
|
||||
} else {
|
||||
this.displayOfflineAlert();
|
||||
}
|
||||
reject(new Error('Unable to open db'));
|
||||
};
|
||||
request.onblocked = (event) => {
|
||||
reject(Error('IndexedDB open request blocked'));
|
||||
};
|
||||
request.onsuccess = (event) => {
|
||||
const target = event!.target! as IDBOpenDBRequest;
|
||||
const db = target.result;
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
};
|
||||
db.onerror = (errorEvent) => {
|
||||
const target = errorEvent?.target as any;
|
||||
throw Error('Database error: ' + target.errorCode);
|
||||
};
|
||||
this.db = db;
|
||||
resolve(db);
|
||||
};
|
||||
request.onupgradeneeded = (event) => {
|
||||
const target = event!.target! as IDBOpenDBRequest;
|
||||
const db = target.result;
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
};
|
||||
/* Create an objectStore for this database */
|
||||
const objectStore = db.createObjectStore(
|
||||
STORE_NAME,
|
||||
{ keyPath: 'uuid' }
|
||||
);
|
||||
objectStore.createIndex(
|
||||
'uuid',
|
||||
'uuid',
|
||||
{ unique: true }
|
||||
);
|
||||
objectStore.transaction.oncomplete = () => {
|
||||
/* Ready to store values in the newly created objectStore. */
|
||||
if (db.version === 1 && onNewDatabase) {
|
||||
onNewDatabase && onNewDatabase();
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async getAllPayloads(): Promise<any[]> {
|
||||
const db = (await this.openDatabase())!;
|
||||
return new Promise((resolve) => {
|
||||
const objectStore =
|
||||
db.transaction(STORE_NAME).
|
||||
objectStore(STORE_NAME);
|
||||
const payloads: any = [];
|
||||
const cursorRequest = objectStore.openCursor();
|
||||
cursorRequest.onsuccess = (event) => {
|
||||
const target = event!.target! as any;
|
||||
const cursor = target.result;
|
||||
if (cursor) {
|
||||
payloads.push(cursor.value);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(payloads);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async savePayload(payload: any): Promise<void> {
|
||||
return this.savePayloads([payload]);
|
||||
}
|
||||
|
||||
public async savePayloads(payloads: any[]): Promise<void> {
|
||||
if (payloads.length === 0) {
|
||||
return;
|
||||
}
|
||||
const db = (await this.openDatabase())!;
|
||||
const transaction = db.transaction(STORE_NAME, READ_WRITE);
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => { };
|
||||
transaction.onerror = (event) => {
|
||||
const target = event!.target! as any;
|
||||
this.showGenericError(target.error);
|
||||
};
|
||||
transaction.onabort = (event) => {
|
||||
const target = event!.target! as any;
|
||||
const error = target.error;
|
||||
if (error.name === QUOTE_EXCEEDED_ERROR) {
|
||||
this.showAlert(OUT_OF_SPACE);
|
||||
} else {
|
||||
this.showGenericError(error);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
this.putItems(objectStore, payloads).then(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private async putItems(objectStore: IDBObjectStore, items: any[]): Promise<void> {
|
||||
await Promise.all(items.map((item) => {
|
||||
return new Promise((resolve) => {
|
||||
const request = objectStore.put(item);
|
||||
request.onerror = resolve;
|
||||
request.onsuccess = resolve;
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public async deletePayload(uuid: string): Promise<void> {
|
||||
const db = (await this.openDatabase())!;
|
||||
return new Promise((resolve, reject) => {
|
||||
const request =
|
||||
db.transaction(STORE_NAME, READ_WRITE)
|
||||
.objectStore(STORE_NAME)
|
||||
.delete(uuid);
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
request.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
public async clearAllPayloads(): Promise<void> {
|
||||
const deleteRequest = window.indexedDB.deleteDatabase(this.databaseName);
|
||||
return new Promise((resolve, reject) => {
|
||||
deleteRequest.onerror = () => {
|
||||
reject(Error('Error deleting database.'));
|
||||
};
|
||||
deleteRequest.onsuccess = () => {
|
||||
this.db = undefined;
|
||||
resolve();
|
||||
};
|
||||
deleteRequest.onblocked = (event) => {
|
||||
this.showAlert(DB_DELETION_BLOCKED);
|
||||
reject(Error('Delete request blocked'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private showAlert(message: string) {
|
||||
this.alertService!.alert(message);
|
||||
}
|
||||
|
||||
private showGenericError(error: { code: number, name: string }) {
|
||||
const message =
|
||||
`Unable to save changes locally due to an unknown system issue. ` +
|
||||
`Issue Code: ${error.code} Issue Name: ${error.name}.`;
|
||||
this.showAlert(message);
|
||||
}
|
||||
|
||||
private displayOfflineAlert() {
|
||||
const message =
|
||||
"There was an issue loading your offline database. This could happen for two reasons:" +
|
||||
"\n\n1. You're in a private window in your browser. We can't save your data without " +
|
||||
"access to the local database. Please use a non-private window." +
|
||||
"\n\n2. You have two windows of the app open at the same time. " +
|
||||
"Please close any other app instances and reload the page.";
|
||||
this.alertService!.alert(message);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/* @ngInject */
|
||||
export function autofocus($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
shouldFocus: '='
|
||||
},
|
||||
link: function($scope, $element) {
|
||||
$timeout(function() {
|
||||
if ($scope.shouldFocus) {
|
||||
$element[0].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
19
app/assets/javascripts/directives/functional/autofocus.ts
Normal file
19
app/assets/javascripts/directives/functional/autofocus.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/* @ngInject */
|
||||
export function autofocus($timeout: ng.ITimeoutService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
shouldFocus: '='
|
||||
},
|
||||
link: function (
|
||||
$scope: ng.IScope,
|
||||
$element: JQLite
|
||||
) {
|
||||
$timeout(() => {
|
||||
if (($scope as any).shouldFocus) {
|
||||
$element[0].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/* @ngInject */
|
||||
export function clickOutside($document) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
link: function($scope, $element, attrs) {
|
||||
var didApplyClickOutside = false;
|
||||
|
||||
$element.bind('click', function(e) {
|
||||
didApplyClickOutside = false;
|
||||
if (attrs.isOpen) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
$document.bind('click', function() {
|
||||
// Ignore click if on SKAlert
|
||||
if (event.target.closest(".sk-modal")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!didApplyClickOutside) {
|
||||
$scope.$apply(attrs.clickOutside);
|
||||
didApplyClickOutside = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/* @ngInject */
|
||||
export function clickOutside($document: ng.IDocumentService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
replace: false,
|
||||
link($scope: ng.IScope, $element: JQLite, attrs: any) {
|
||||
let didApplyClickOutside = false;
|
||||
|
||||
function onElementClick(event: JQueryEventObject) {
|
||||
didApplyClickOutside = false;
|
||||
if (attrs.isOpen) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick(event: JQueryEventObject) {
|
||||
/** Ignore click if on SKAlert */
|
||||
if (event.target.closest('.sk-modal')) {
|
||||
return;
|
||||
}
|
||||
if (!didApplyClickOutside) {
|
||||
$scope.$apply(attrs.clickOutside);
|
||||
didApplyClickOutside = true;
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$on('$destroy', () => {
|
||||
attrs.clickOutside = undefined;
|
||||
$element.unbind('click', onElementClick);
|
||||
$document.unbind('click', onDocumentClick);
|
||||
});
|
||||
|
||||
$element.bind('click', onElementClick);
|
||||
$document.bind('click', onDocumentClick);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import angular from 'angular';
|
||||
|
||||
/* @ngInject */
|
||||
export function delayHide($timeout) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
show: '=',
|
||||
delay: '@'
|
||||
},
|
||||
link: function(scope, elem, attrs) {
|
||||
showElement(false);
|
||||
|
||||
// This is where all the magic happens!
|
||||
// Whenever the scope variable updates we simply
|
||||
// show if it evaluates to 'true' and hide if 'false'
|
||||
scope.$watch('show', function(newVal) {
|
||||
newVal ? showSpinner() : hideSpinner();
|
||||
});
|
||||
|
||||
function showSpinner() {
|
||||
if (scope.hidePromise) {
|
||||
$timeout.cancel(scope.hidePromise);
|
||||
scope.hidePromise = null;
|
||||
}
|
||||
showElement(true);
|
||||
}
|
||||
|
||||
function hideSpinner() {
|
||||
scope.hidePromise = $timeout(showElement.bind(this, false), getDelay());
|
||||
}
|
||||
|
||||
function showElement(show) {
|
||||
show ? elem.css({ display: '' }) : elem.css({ display: 'none' });
|
||||
}
|
||||
|
||||
function getDelay() {
|
||||
var delay = parseInt(scope.delay);
|
||||
|
||||
return angular.isNumber(delay) ? delay : 200;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
45
app/assets/javascripts/directives/functional/delay-hide.ts
Normal file
45
app/assets/javascripts/directives/functional/delay-hide.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import angular from 'angular';
|
||||
|
||||
/* @ngInject */
|
||||
export function delayHide($timeout: ng.ITimeoutService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
show: '=',
|
||||
delay: '@'
|
||||
},
|
||||
link: function (scope: ng.IScope, elem: JQLite) {
|
||||
const scopeAny = scope as any;
|
||||
const showSpinner = () => {
|
||||
if (scopeAny.hidePromise) {
|
||||
$timeout.cancel(scopeAny.hidePromise);
|
||||
scopeAny.hidePromise = null;
|
||||
}
|
||||
showElement(true);
|
||||
}
|
||||
|
||||
const hideSpinner = () => {
|
||||
scopeAny.hidePromise = $timeout(
|
||||
showElement.bind(this as any, false),
|
||||
getDelay()
|
||||
);
|
||||
}
|
||||
|
||||
const showElement = (show: boolean) => {
|
||||
show ? elem.css({ display: '' }) : elem.css({ display: 'none' });
|
||||
}
|
||||
|
||||
const getDelay = () => {
|
||||
const delay = parseInt(scopeAny.delay);
|
||||
return angular.isNumber(delay) ? delay : 200;
|
||||
}
|
||||
|
||||
showElement(false);
|
||||
// Whenever the scope variable updates we simply
|
||||
// show if it evaluates to 'true' and hide if 'false'
|
||||
scope.$watch('show', function (newVal) {
|
||||
newVal ? showSpinner() : hideSpinner();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/* @ngInject */
|
||||
export function elemReady($parse) {
|
||||
export function elemReady($parse: ng.IParseService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function($scope, elem, attrs) {
|
||||
link: function($scope: ng.IScope, elem: JQLite, attrs: any) {
|
||||
elem.ready(function() {
|
||||
$scope.$apply(function() {
|
||||
var func = $parse(attrs.elemReady);
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
/* @ngInject */
|
||||
export function fileChange() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
handler: '&'
|
||||
},
|
||||
link: function(scope, element) {
|
||||
element.on('change', function(event) {
|
||||
scope.$apply(function() {
|
||||
scope.handler({ files: event.target.files });
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
19
app/assets/javascripts/directives/functional/file-change.ts
Normal file
19
app/assets/javascripts/directives/functional/file-change.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/* @ngInject */
|
||||
export function fileChange() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
scope: {
|
||||
handler: '&'
|
||||
},
|
||||
link: function (scope: ng.IScope, element: JQLite) {
|
||||
element.on('change', (event) => {
|
||||
scope.$apply(() => {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
(scope as any).handler({
|
||||
files: files
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -5,5 +5,5 @@ export { elemReady } from './elemReady';
|
|||
export { fileChange } from './file-change';
|
||||
export { infiniteScroll } from './infiniteScroll';
|
||||
export { lowercase } from './lowercase';
|
||||
export { selectOnClick } from './selectOnClick';
|
||||
export { selectOnFocus } from './selectOnFocus';
|
||||
export { snEnter } from './snEnter';
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/* @ngInject */
|
||||
export function infiniteScroll($rootScope, $window, $timeout) {
|
||||
return {
|
||||
link: function(scope, elem, attrs) {
|
||||
const offset = parseInt(attrs.threshold) || 0;
|
||||
const e = elem[0];
|
||||
elem.on('scroll', function() {
|
||||
if (
|
||||
scope.$eval(attrs.canLoad) &&
|
||||
e.scrollTop + e.offsetHeight >= e.scrollHeight - offset
|
||||
) {
|
||||
scope.$apply(attrs.infiniteScroll);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { debounce } from '@/utils';
|
||||
/* @ngInject */
|
||||
export function infiniteScroll() {
|
||||
return {
|
||||
link: function (scope: ng.IScope, elem: JQLite, attrs: any) {
|
||||
const scopeAny = scope as any;
|
||||
const offset = parseInt(attrs.threshold) || 0;
|
||||
const element = elem[0];
|
||||
scopeAny.paginate = debounce(() => {
|
||||
scope.$apply(attrs.infiniteScroll);
|
||||
}, 10);
|
||||
scopeAny.onScroll = () => {
|
||||
if (
|
||||
scope.$eval(attrs.canLoad) &&
|
||||
element.scrollTop + element.offsetHeight >= element.scrollHeight - offset
|
||||
) {
|
||||
scopeAny.paginate();
|
||||
}
|
||||
};
|
||||
elem.on('scroll', scopeAny.onScroll);
|
||||
scope.$on('$destroy', () => {
|
||||
elem.off('scroll', scopeAny.onScroll);;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/* @ngInject */
|
||||
export function lowercase() {
|
||||
return {
|
||||
require: 'ngModel',
|
||||
link: function(scope, element, attrs, modelCtrl) {
|
||||
var lowercase = function(inputValue) {
|
||||
if (inputValue === undefined) inputValue = '';
|
||||
var lowercased = inputValue.toLowerCase();
|
||||
if (lowercased !== inputValue) {
|
||||
modelCtrl.$setViewValue(lowercased);
|
||||
modelCtrl.$render();
|
||||
}
|
||||
return lowercased;
|
||||
};
|
||||
modelCtrl.$parsers.push(lowercase);
|
||||
lowercase(scope[attrs.ngModel]);
|
||||
}
|
||||
};
|
||||
}
|
||||
24
app/assets/javascripts/directives/functional/lowercase.ts
Normal file
24
app/assets/javascripts/directives/functional/lowercase.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* @ngInject */
|
||||
export function lowercase() {
|
||||
return {
|
||||
require: 'ngModel',
|
||||
link: function (
|
||||
scope: ng.IScope,
|
||||
_: JQLite,
|
||||
attrs: any,
|
||||
ctrl: any
|
||||
) {
|
||||
const lowercase = (inputValue: string) => {
|
||||
if (inputValue === undefined) inputValue = '';
|
||||
const lowercased = inputValue.toLowerCase();
|
||||
if (lowercased !== inputValue) {
|
||||
ctrl.$setViewValue(lowercased);
|
||||
ctrl.$render();
|
||||
}
|
||||
return lowercased;
|
||||
};
|
||||
ctrl.$parsers.push(lowercase);
|
||||
lowercase((scope as any)[attrs.ngModel]);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/* @ngInject */
|
||||
export function selectOnClick($window) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function(scope, element, attrs) {
|
||||
element.on('focus', function() {
|
||||
if (!$window.getSelection().toString()) {
|
||||
/** Required for mobile Safari */
|
||||
this.setSelectionRange(0, this.value.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/* @ngInject */
|
||||
export function selectOnFocus($window: ng.IWindowService) {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function (scope: ng.IScope, element: JQLite) {
|
||||
element.on('focus', () => {
|
||||
if (!$window.getSelection()!.toString()) {
|
||||
const input = element[0] as HTMLInputElement;
|
||||
/** Allow text to populate */
|
||||
setTimeout(() => {
|
||||
input.setSelectionRange(0, input.value.length);
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
/* @ngInject */
|
||||
export function snEnter() {
|
||||
return function(scope, element, attrs) {
|
||||
element.bind('keydown keypress', function(event) {
|
||||
return function (
|
||||
scope: ng.IScope,
|
||||
element: JQLite,
|
||||
attrs: any
|
||||
) {
|
||||
element.bind('keydown keypress', function (event) {
|
||||
if (event.which === 13) {
|
||||
scope.$apply(function() {
|
||||
scope.$apply(function () {
|
||||
scope.$eval(attrs.snEnter, { event: event });
|
||||
});
|
||||
|
||||
|
|
@ -1,629 +0,0 @@
|
|||
import { isDesktopApplication, isNullOrUndefined } from '@/utils';
|
||||
import { PrivilegesManager } from '@/services/privilegesManager';
|
||||
import template from '%/directives/account-menu.pug';
|
||||
import { protocolManager } from 'snjs';
|
||||
import { PureCtrl } from '@Controllers';
|
||||
import {
|
||||
STRING_ACCOUNT_MENU_UNCHECK_MERGE,
|
||||
STRING_SIGN_OUT_CONFIRMATION,
|
||||
STRING_ERROR_DECRYPTING_IMPORT,
|
||||
STRING_E2E_ENABLED,
|
||||
STRING_LOCAL_ENC_ENABLED,
|
||||
STRING_ENC_NOT_ENABLED,
|
||||
STRING_IMPORT_SUCCESS,
|
||||
STRING_REMOVE_PASSCODE_CONFIRMATION,
|
||||
STRING_REMOVE_PASSCODE_OFFLINE_ADDENDUM,
|
||||
STRING_NON_MATCHING_PASSCODES,
|
||||
STRING_NON_MATCHING_PASSWORDS,
|
||||
STRING_INVALID_IMPORT_FILE,
|
||||
STRING_GENERATING_LOGIN_KEYS,
|
||||
STRING_GENERATING_REGISTER_KEYS,
|
||||
StringImportError
|
||||
} from '@/strings';
|
||||
import { STRING_IMPORT_FAILED_NEWER_BACKUP } from '../../strings';
|
||||
|
||||
const ELEMENT_ID_IMPORT_PASSWORD_INPUT = 'import-password-request';
|
||||
|
||||
const ELEMENT_NAME_AUTH_EMAIL = 'email';
|
||||
const ELEMENT_NAME_AUTH_PASSWORD = 'password';
|
||||
const ELEMENT_NAME_AUTH_PASSWORD_CONF = 'password_conf';
|
||||
|
||||
class AccountMenuCtrl extends PureCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$scope,
|
||||
$rootScope,
|
||||
$timeout,
|
||||
alertManager,
|
||||
archiveManager,
|
||||
appVersion,
|
||||
authManager,
|
||||
modelManager,
|
||||
passcodeManager,
|
||||
privilegesManager,
|
||||
storageManager,
|
||||
syncManager,
|
||||
) {
|
||||
super($timeout);
|
||||
this.$scope = $scope;
|
||||
this.$rootScope = $rootScope;
|
||||
this.$timeout = $timeout;
|
||||
this.alertManager = alertManager;
|
||||
this.archiveManager = archiveManager;
|
||||
this.authManager = authManager;
|
||||
this.modelManager = modelManager;
|
||||
this.passcodeManager = passcodeManager;
|
||||
this.privilegesManager = privilegesManager;
|
||||
this.storageManager = storageManager;
|
||||
this.syncManager = syncManager;
|
||||
|
||||
this.state = {
|
||||
appVersion: 'v' + (window.electronAppVersion || appVersion),
|
||||
user: this.authManager.user,
|
||||
canAddPasscode: !this.authManager.isEphemeralSession(),
|
||||
passcodeAutoLockOptions: this.passcodeManager.getAutoLockIntervalOptions(),
|
||||
formData: {
|
||||
mergeLocal: true,
|
||||
ephemeral: false
|
||||
},
|
||||
mutable: {
|
||||
backupEncrypted: this.encryptedBackupsAvailable()
|
||||
}
|
||||
};
|
||||
|
||||
this.syncStatus = this.syncManager.syncStatus;
|
||||
this.syncManager.getServerURL().then((url) => {
|
||||
this.setState({
|
||||
server: url,
|
||||
formData: { ...this.state.formData, url: url }
|
||||
});
|
||||
});
|
||||
this.authManager.checkForSecurityUpdate().then((available) => {
|
||||
this.setState({
|
||||
securityUpdateAvailable: available
|
||||
});
|
||||
});
|
||||
this.reloadAutoLockInterval();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.initProps({
|
||||
closeFunction: this.closeFunction
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$timeout(() => {
|
||||
this.props.closeFunction()();
|
||||
});
|
||||
}
|
||||
|
||||
encryptedBackupsAvailable() {
|
||||
return !isNullOrUndefined(this.authManager.user) || this.passcodeManager.hasPasscode();
|
||||
}
|
||||
|
||||
submitMfaForm() {
|
||||
const params = {
|
||||
[this.state.formData.mfa.payload.mfa_key]: this.state.formData.userMfaCode
|
||||
};
|
||||
this.login(params);
|
||||
}
|
||||
|
||||
blurAuthFields() {
|
||||
const names = [
|
||||
ELEMENT_NAME_AUTH_EMAIL,
|
||||
ELEMENT_NAME_AUTH_PASSWORD,
|
||||
ELEMENT_NAME_AUTH_PASSWORD_CONF
|
||||
];
|
||||
for(const name of names) {
|
||||
const element = document.getElementsByName(name)[0];
|
||||
if(element) {
|
||||
element.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submitAuthForm() {
|
||||
if (!this.state.formData.email || !this.state.formData.user_password) {
|
||||
return;
|
||||
}
|
||||
this.blurAuthFields();
|
||||
if (this.state.formData.showLogin) {
|
||||
this.login();
|
||||
} else {
|
||||
this.register();
|
||||
}
|
||||
}
|
||||
|
||||
async setFormDataState(formData) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
...formData
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async login(extraParams) {
|
||||
/** Prevent a timed sync from occuring while signing in. */
|
||||
this.syncManager.lockSyncing();
|
||||
await this.setFormDataState({
|
||||
status: STRING_GENERATING_LOGIN_KEYS,
|
||||
authenticating: true
|
||||
});
|
||||
const response = await this.authManager.login(
|
||||
this.state.formData.url,
|
||||
this.state.formData.email,
|
||||
this.state.formData.user_password,
|
||||
this.state.formData.ephemeral,
|
||||
this.state.formData.strictSignin,
|
||||
extraParams
|
||||
);
|
||||
const hasError = !response || response.error;
|
||||
if (!hasError) {
|
||||
this.setFormDataState({
|
||||
user_password: null
|
||||
});
|
||||
await this.onAuthSuccess();
|
||||
this.syncManager.unlockSyncing();
|
||||
this.syncManager.sync({ performIntegrityCheck: true });
|
||||
return;
|
||||
}
|
||||
this.syncManager.unlockSyncing();
|
||||
await this.setFormDataState({
|
||||
status: null
|
||||
});
|
||||
const error = response
|
||||
? response.error
|
||||
: { message: "An unknown error occured." };
|
||||
|
||||
if (error.tag === 'mfa-required' || error.tag === 'mfa-invalid') {
|
||||
await this.setFormDataState({
|
||||
showLogin: false,
|
||||
mfa: error
|
||||
});
|
||||
} else {
|
||||
await this.setFormDataState({
|
||||
showLogin: true,
|
||||
mfa: null
|
||||
});
|
||||
if (error.message) {
|
||||
this.alertManager.alert({
|
||||
text: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.setFormDataState({
|
||||
authenticating: false,
|
||||
});
|
||||
}
|
||||
|
||||
async register() {
|
||||
const confirmation = this.state.formData.password_conf;
|
||||
if (confirmation !== this.state.formData.user_password) {
|
||||
this.alertManager.alert({
|
||||
text: STRING_NON_MATCHING_PASSWORDS
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.setFormDataState({
|
||||
confirmPassword: false,
|
||||
status: STRING_GENERATING_REGISTER_KEYS,
|
||||
authenticating: true
|
||||
});
|
||||
const response = await this.authManager.register(
|
||||
this.state.formData.url,
|
||||
this.state.formData.email,
|
||||
this.state.formData.user_password,
|
||||
this.state.formData.ephemeral
|
||||
);
|
||||
if (!response || response.error) {
|
||||
await this.setFormDataState({
|
||||
status: null
|
||||
});
|
||||
const error = response
|
||||
? response.error
|
||||
: { message: "An unknown error occured." };
|
||||
await this.setFormDataState({
|
||||
authenticating: false
|
||||
});
|
||||
this.alertManager.alert({
|
||||
text: error.message
|
||||
});
|
||||
} else {
|
||||
await this.onAuthSuccess();
|
||||
this.syncManager.sync();
|
||||
}
|
||||
}
|
||||
|
||||
mergeLocalChanged() {
|
||||
if (!this.state.formData.mergeLocal) {
|
||||
this.alertManager.confirm({
|
||||
text: STRING_ACCOUNT_MENU_UNCHECK_MERGE,
|
||||
destructive: true,
|
||||
onCancel: () => {
|
||||
this.setFormDataState({
|
||||
mergeLocal: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async onAuthSuccess() {
|
||||
if (this.state.formData.mergeLocal) {
|
||||
this.$rootScope.$broadcast('major-data-change');
|
||||
await this.clearDatabaseAndRewriteAllItems({ alternateUuids: true });
|
||||
} else {
|
||||
this.modelManager.removeAllItemsFromMemory();
|
||||
await this.storageManager.clearAllModels();
|
||||
}
|
||||
await this.setFormDataState({
|
||||
authenticating: false
|
||||
});
|
||||
this.syncManager.refreshErroredItems();
|
||||
this.close();
|
||||
}
|
||||
|
||||
openPasswordWizard(type) {
|
||||
this.close();
|
||||
this.authManager.presentPasswordWizard(type);
|
||||
}
|
||||
|
||||
async openPrivilegesModal() {
|
||||
this.close();
|
||||
const run = () => {
|
||||
this.privilegesManager.presentPrivilegesManagementModal();
|
||||
};
|
||||
const needsPrivilege = await this.privilegesManager.actionRequiresPrivilege(
|
||||
PrivilegesManager.ActionManagePrivileges
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.privilegesManager.presentPrivilegesModal(
|
||||
PrivilegesManager.ActionManagePrivileges,
|
||||
() => {
|
||||
run();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows IndexedDB unencrypted logs to be deleted
|
||||
* `clearAllModels` will remove data from backing store,
|
||||
* but not from working memory See:
|
||||
* https://github.com/standardnotes/desktop/issues/131
|
||||
*/
|
||||
async clearDatabaseAndRewriteAllItems({ alternateUuids } = {}) {
|
||||
await this.storageManager.clearAllModels();
|
||||
await this.syncManager.markAllItemsDirtyAndSaveOffline(alternateUuids);
|
||||
}
|
||||
|
||||
destroyLocalData() {
|
||||
this.alertManager.confirm({
|
||||
text: STRING_SIGN_OUT_CONFIRMATION,
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
await this.authManager.signout(true);
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async submitImportPassword() {
|
||||
await this.performImport(
|
||||
this.state.importData.data,
|
||||
this.state.importData.password
|
||||
);
|
||||
}
|
||||
|
||||
async readFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
this.alertManager.alert({
|
||||
text: STRING_INVALID_IMPORT_FILE
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template
|
||||
*/
|
||||
async importFileSelected(files) {
|
||||
const run = async () => {
|
||||
const file = files[0];
|
||||
const data = await this.readFile(file);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const version = data.version || data.auth_params?.version || data?.keyParams?.version;
|
||||
if (version && !protocolManager.supportedVersions().includes(version)) {
|
||||
this.setState({ importData: null });
|
||||
this.alertManager.alert({ text: STRING_IMPORT_FAILED_NEWER_BACKUP });
|
||||
return;
|
||||
}
|
||||
if (data.auth_params) {
|
||||
await this.setState({
|
||||
importData: {
|
||||
...this.state.importData,
|
||||
requestPassword: true,
|
||||
data: data
|
||||
}
|
||||
});
|
||||
const element = document.getElementById(
|
||||
ELEMENT_ID_IMPORT_PASSWORD_INPUT
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
} else {
|
||||
await this.performImport(data, null);
|
||||
}
|
||||
};
|
||||
const needsPrivilege = await this.privilegesManager.actionRequiresPrivilege(
|
||||
PrivilegesManager.ActionManageBackups
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.privilegesManager.presentPrivilegesModal(
|
||||
PrivilegesManager.ActionManageBackups,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
async performImport(data, password) {
|
||||
await this.setState({
|
||||
importData: {
|
||||
...this.state.importData,
|
||||
loading: true
|
||||
}
|
||||
});
|
||||
const errorCount = await this.importJSONData(data, password);
|
||||
this.setState({
|
||||
importData: null
|
||||
});
|
||||
if (errorCount > 0) {
|
||||
const message = StringImportError({ errorCount: errorCount });
|
||||
this.alertManager.alert({
|
||||
text: message
|
||||
});
|
||||
} else {
|
||||
this.alertManager.alert({
|
||||
text: STRING_IMPORT_SUCCESS
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async importJSONData(data, password) {
|
||||
let errorCount = 0;
|
||||
if (data.auth_params) {
|
||||
const keys = await protocolManager.computeEncryptionKeysForUser(
|
||||
password,
|
||||
data.auth_params
|
||||
);
|
||||
try {
|
||||
const throws = false;
|
||||
await protocolManager.decryptMultipleItems(data.items, keys, throws);
|
||||
const items = [];
|
||||
for (const item of data.items) {
|
||||
item.enc_item_key = null;
|
||||
item.auth_hash = null;
|
||||
if (item.errorDecrypting) {
|
||||
errorCount++;
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
data.items = items;
|
||||
} catch (e) {
|
||||
this.alertManager.alert({
|
||||
text: STRING_ERROR_DECRYPTING_IMPORT
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const items = await this.modelManager.importItems(data.items);
|
||||
for (const item of items) {
|
||||
/**
|
||||
* Don't want to activate any components during import process in
|
||||
* case of exceptions breaking up the import proccess
|
||||
*/
|
||||
if (item.content_type === 'SN|Component') {
|
||||
item.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.syncManager.sync();
|
||||
return errorCount;
|
||||
}
|
||||
|
||||
async downloadDataArchive() {
|
||||
this.archiveManager.downloadBackup(this.state.mutable.backupEncrypted);
|
||||
this.close();
|
||||
}
|
||||
|
||||
notesAndTagsCount() {
|
||||
return this.modelManager.allItemsMatchingTypes([
|
||||
'Note',
|
||||
'Tag'
|
||||
]).length;
|
||||
}
|
||||
|
||||
encryptionStatusForNotes() {
|
||||
const length = this.notesAndTagsCount();
|
||||
return length + "/" + length + " notes and tags encrypted";
|
||||
}
|
||||
|
||||
encryptionEnabled() {
|
||||
return this.passcodeManager.hasPasscode() || !this.authManager.offline();
|
||||
}
|
||||
|
||||
encryptionSource() {
|
||||
if (!this.authManager.offline()) {
|
||||
return "Account keys";
|
||||
} else if (this.passcodeManager.hasPasscode()) {
|
||||
return "Local Passcode";
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
encryptionStatusString() {
|
||||
if (!this.authManager.offline()) {
|
||||
return STRING_E2E_ENABLED;
|
||||
} else if (this.passcodeManager.hasPasscode()) {
|
||||
return STRING_LOCAL_ENC_ENABLED;
|
||||
} else {
|
||||
return STRING_ENC_NOT_ENABLED;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadAutoLockInterval() {
|
||||
const interval = await this.passcodeManager.getAutoLockInterval();
|
||||
this.setState({
|
||||
selectedAutoLockInterval: interval
|
||||
});
|
||||
}
|
||||
|
||||
async selectAutoLockInterval(interval) {
|
||||
const run = async () => {
|
||||
await this.passcodeManager.setAutoLockInterval(interval);
|
||||
this.reloadAutoLockInterval();
|
||||
};
|
||||
const needsPrivilege = await this.privilegesManager.actionRequiresPrivilege(
|
||||
PrivilegesManager.ActionManagePasscode
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.privilegesManager.presentPrivilegesModal(
|
||||
PrivilegesManager.ActionManagePasscode,
|
||||
() => {
|
||||
run();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
hidePasswordForm() {
|
||||
this.setFormDataState({
|
||||
showLogin: false,
|
||||
showRegister: false,
|
||||
user_password: null,
|
||||
password_conf: null
|
||||
});
|
||||
}
|
||||
|
||||
hasPasscode() {
|
||||
return this.passcodeManager.hasPasscode();
|
||||
}
|
||||
|
||||
addPasscodeClicked() {
|
||||
this.setFormDataState({
|
||||
showPasscodeForm: true
|
||||
});
|
||||
}
|
||||
|
||||
submitPasscodeForm() {
|
||||
const passcode = this.state.formData.passcode;
|
||||
if (passcode !== this.state.formData.confirmPasscode) {
|
||||
this.alertManager.alert({
|
||||
text: STRING_NON_MATCHING_PASSCODES
|
||||
});
|
||||
return;
|
||||
}
|
||||
const func = this.state.formData.changingPasscode
|
||||
? this.passcodeManager.changePasscode.bind(this.passcodeManager)
|
||||
: this.passcodeManager.setPasscode.bind(this.passcodeManager);
|
||||
func(passcode, async () => {
|
||||
await this.setFormDataState({
|
||||
passcode: null,
|
||||
confirmPasscode: null,
|
||||
showPasscodeForm: false
|
||||
});
|
||||
if (await this.authManager.offline()) {
|
||||
this.$rootScope.$broadcast('major-data-change');
|
||||
this.clearDatabaseAndRewriteAllItems();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async changePasscodePressed() {
|
||||
const run = () => {
|
||||
this.state.formData.changingPasscode = true;
|
||||
this.addPasscodeClicked();
|
||||
};
|
||||
const needsPrivilege = await this.privilegesManager.actionRequiresPrivilege(
|
||||
PrivilegesManager.ActionManagePasscode
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.privilegesManager.presentPrivilegesModal(
|
||||
PrivilegesManager.ActionManagePasscode,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
async removePasscodePressed() {
|
||||
const run = () => {
|
||||
const signedIn = !this.authManager.offline();
|
||||
let message = STRING_REMOVE_PASSCODE_CONFIRMATION;
|
||||
if (!signedIn) {
|
||||
message += STRING_REMOVE_PASSCODE_OFFLINE_ADDENDUM;
|
||||
}
|
||||
this.alertManager.confirm({
|
||||
text: message,
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
this.passcodeManager.clearPasscode();
|
||||
if (this.authManager.offline()) {
|
||||
this.syncManager.markAllItemsDirtyAndSaveOffline();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
const needsPrivilege = await this.privilegesManager.actionRequiresPrivilege(
|
||||
PrivilegesManager.ActionManagePasscode
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.privilegesManager.presentPrivilegesModal(
|
||||
PrivilegesManager.ActionManagePasscode,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
isDesktopApplication() {
|
||||
return isDesktopApplication();
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountMenu {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = AccountMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
closeFunction: '&'
|
||||
};
|
||||
}
|
||||
}
|
||||
608
app/assets/javascripts/directives/views/accountMenu.ts
Normal file
608
app/assets/javascripts/directives/views/accountMenu.ts
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
import { WebDirective } from './../../types';
|
||||
import { isDesktopApplication, preventRefreshing } from '@/utils';
|
||||
import template from '%/directives/account-menu.pug';
|
||||
import { ProtectedAction, ContentType } from 'snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import {
|
||||
STRING_ACCOUNT_MENU_UNCHECK_MERGE,
|
||||
STRING_SIGN_OUT_CONFIRMATION,
|
||||
STRING_E2E_ENABLED,
|
||||
STRING_LOCAL_ENC_ENABLED,
|
||||
STRING_ENC_NOT_ENABLED,
|
||||
STRING_IMPORT_SUCCESS,
|
||||
STRING_REMOVE_PASSCODE_CONFIRMATION,
|
||||
STRING_REMOVE_PASSCODE_OFFLINE_ADDENDUM,
|
||||
STRING_NON_MATCHING_PASSCODES,
|
||||
STRING_NON_MATCHING_PASSWORDS,
|
||||
STRING_INVALID_IMPORT_FILE,
|
||||
STRING_GENERATING_LOGIN_KEYS,
|
||||
STRING_GENERATING_REGISTER_KEYS,
|
||||
StringImportError,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE,
|
||||
STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL,
|
||||
STRING_UNSUPPORTED_BACKUP_FILE_VERSION
|
||||
} from '@/strings';
|
||||
import { SyncOpStatus } from 'snjs/dist/@types/services/sync/sync_op_status';
|
||||
import { PasswordWizardType } from '@/types';
|
||||
import { BackupFile } from 'snjs/dist/@types/services/protocol_service';
|
||||
import { confirmDialog, alertDialog } from '@/services/alertService';
|
||||
import { autorun, IReactionDisposer } from 'mobx';
|
||||
|
||||
const ELEMENT_ID_IMPORT_PASSWORD_INPUT = 'import-password-request';
|
||||
|
||||
const ELEMENT_NAME_AUTH_EMAIL = 'email';
|
||||
const ELEMENT_NAME_AUTH_PASSWORD = 'password';
|
||||
const ELEMENT_NAME_AUTH_PASSWORD_CONF = 'password_conf';
|
||||
|
||||
type FormData = {
|
||||
email: string
|
||||
user_password: string
|
||||
password_conf: string
|
||||
confirmPassword: boolean
|
||||
showLogin: boolean
|
||||
showRegister: boolean
|
||||
showPasscodeForm: boolean
|
||||
strictSignin?: boolean
|
||||
ephemeral: boolean
|
||||
mergeLocal?: boolean
|
||||
url: string
|
||||
authenticating: boolean
|
||||
status: string
|
||||
passcode: string
|
||||
confirmPasscode: string
|
||||
changingPasscode: boolean
|
||||
}
|
||||
|
||||
type AccountMenuState = {
|
||||
formData: Partial<FormData>;
|
||||
appVersion: string;
|
||||
passcodeAutoLockOptions: any;
|
||||
user: any;
|
||||
mutable: any;
|
||||
importData: any;
|
||||
encryptionStatusString: string;
|
||||
server: string;
|
||||
encryptionEnabled: boolean;
|
||||
selectedAutoLockInterval: any;
|
||||
showBetaWarning: boolean;
|
||||
}
|
||||
|
||||
class AccountMenuCtrl extends PureViewCtrl<{}, AccountMenuState> {
|
||||
|
||||
public appVersion: string
|
||||
/** @template */
|
||||
syncStatus?: SyncOpStatus
|
||||
private closeFunction?: () => void
|
||||
private removeBetaWarningListener?: IReactionDisposer
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
appVersion: string,
|
||||
) {
|
||||
super($timeout);
|
||||
this.appVersion = appVersion;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState() {
|
||||
return {
|
||||
appVersion: 'v' + ((window as any).electronAppVersion || this.appVersion),
|
||||
passcodeAutoLockOptions: this.application!.getAutolockService().getAutoLockIntervalOptions(),
|
||||
user: this.application!.getUser(),
|
||||
formData: {
|
||||
mergeLocal: true,
|
||||
ephemeral: false,
|
||||
},
|
||||
mutable: {},
|
||||
showBetaWarning: false,
|
||||
} as AccountMenuState;
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state as AccountMenuState;
|
||||
}
|
||||
|
||||
async onAppKeyChange() {
|
||||
super.onAppKeyChange();
|
||||
this.setState(this.refreshedCredentialState());
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.setState(this.refreshedCredentialState());
|
||||
this.loadHost();
|
||||
this.reloadAutoLockInterval();
|
||||
this.refreshEncryptionStatus();
|
||||
}
|
||||
|
||||
refreshedCredentialState() {
|
||||
return {
|
||||
user: this.application!.getUser(),
|
||||
canAddPasscode: !this.application!.isEphemeralSession(),
|
||||
hasPasscode: this.application!.hasPasscode(),
|
||||
showPasscodeForm: false
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.syncStatus = this.application!.getSyncStatus();
|
||||
this.removeBetaWarningListener = autorun(() => {
|
||||
this.setState({
|
||||
showBetaWarning: this.appState.showBetaWarning
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.removeBetaWarningListener?.();
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
close() {
|
||||
this.$timeout(() => {
|
||||
this.closeFunction?.();
|
||||
});
|
||||
}
|
||||
|
||||
async loadHost() {
|
||||
const host = await this.application!.getHost();
|
||||
this.setState({
|
||||
server: host,
|
||||
formData: {
|
||||
...this.getState().formData,
|
||||
url: host
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onHostInputChange() {
|
||||
const url = this.getState().formData.url!;
|
||||
this.application!.setHost(url);
|
||||
}
|
||||
|
||||
refreshEncryptionStatus() {
|
||||
const hasUser = this.application!.hasAccount();
|
||||
const hasPasscode = this.application!.hasPasscode();
|
||||
const encryptionEnabled = hasUser || hasPasscode;
|
||||
|
||||
this.setState({
|
||||
encryptionStatusString: hasUser
|
||||
? STRING_E2E_ENABLED
|
||||
: hasPasscode
|
||||
? STRING_LOCAL_ENC_ENABLED
|
||||
: STRING_ENC_NOT_ENABLED,
|
||||
encryptionEnabled,
|
||||
mutable: {
|
||||
...this.getState().mutable,
|
||||
backupEncrypted: encryptionEnabled
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitMfaForm() {
|
||||
this.login();
|
||||
}
|
||||
|
||||
blurAuthFields() {
|
||||
const names = [
|
||||
ELEMENT_NAME_AUTH_EMAIL,
|
||||
ELEMENT_NAME_AUTH_PASSWORD,
|
||||
ELEMENT_NAME_AUTH_PASSWORD_CONF
|
||||
];
|
||||
for (const name of names) {
|
||||
const element = document.getElementsByName(name)[0];
|
||||
if (element) {
|
||||
element.blur();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submitAuthForm() {
|
||||
if (!this.getState().formData.email || !this.getState().formData.user_password) {
|
||||
return;
|
||||
}
|
||||
this.blurAuthFields();
|
||||
if (this.getState().formData.showLogin) {
|
||||
this.login();
|
||||
} else {
|
||||
this.register();
|
||||
}
|
||||
}
|
||||
|
||||
async setFormDataState(formData: Partial<FormData>) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.getState().formData,
|
||||
...formData
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async login() {
|
||||
await this.setFormDataState({
|
||||
status: STRING_GENERATING_LOGIN_KEYS,
|
||||
authenticating: true
|
||||
});
|
||||
const formData = this.getState().formData;
|
||||
const response = await this.application!.signIn(
|
||||
formData.email!,
|
||||
formData.user_password!,
|
||||
formData.strictSignin,
|
||||
formData.ephemeral,
|
||||
formData.mergeLocal
|
||||
);
|
||||
const error = response.error;
|
||||
if (!error) {
|
||||
await this.setFormDataState({
|
||||
authenticating: false,
|
||||
user_password: undefined
|
||||
});
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
await this.setFormDataState({
|
||||
showLogin: true,
|
||||
status: undefined,
|
||||
user_password: undefined
|
||||
});
|
||||
if (error.message) {
|
||||
this.application!.alertService!.alert(error.message);
|
||||
}
|
||||
await this.setFormDataState({
|
||||
authenticating: false
|
||||
});
|
||||
}
|
||||
|
||||
async register() {
|
||||
const confirmation = this.getState().formData.password_conf;
|
||||
if (confirmation !== this.getState().formData.user_password) {
|
||||
this.application!.alertService!.alert(
|
||||
STRING_NON_MATCHING_PASSWORDS
|
||||
);
|
||||
return;
|
||||
}
|
||||
await this.setFormDataState({
|
||||
confirmPassword: false,
|
||||
status: STRING_GENERATING_REGISTER_KEYS,
|
||||
authenticating: true
|
||||
});
|
||||
const response = await this.application!.register(
|
||||
this.getState().formData.email!,
|
||||
this.getState().formData.user_password!,
|
||||
this.getState().formData.ephemeral,
|
||||
this.getState().formData.mergeLocal
|
||||
);
|
||||
const error = response.error;
|
||||
if (error) {
|
||||
await this.setFormDataState({
|
||||
status: undefined
|
||||
});
|
||||
await this.setFormDataState({
|
||||
authenticating: false
|
||||
});
|
||||
this.application!.alertService!.alert(
|
||||
error.message
|
||||
);
|
||||
} else {
|
||||
await this.setFormDataState({ authenticating: false });
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
async mergeLocalChanged() {
|
||||
if (!this.getState().formData.mergeLocal) {
|
||||
this.setFormDataState({
|
||||
mergeLocal: !(await confirmDialog({
|
||||
text: STRING_ACCOUNT_MENU_UNCHECK_MERGE,
|
||||
confirmButtonStyle: 'danger'
|
||||
}))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
openPasswordWizard() {
|
||||
this.close();
|
||||
this.application!.presentPasswordWizard(PasswordWizardType.ChangePassword);
|
||||
}
|
||||
|
||||
async openPrivilegesModal() {
|
||||
const run = () => {
|
||||
this.application!.presentPrivilegesManagementModal();
|
||||
this.close();
|
||||
};
|
||||
const needsPrivilege = await this.application!.privilegesService!.actionRequiresPrivilege(
|
||||
ProtectedAction.ManagePrivileges
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.application!.presentPrivilegesModal(
|
||||
ProtectedAction.ManagePrivileges,
|
||||
() => {
|
||||
run();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
async destroyLocalData() {
|
||||
if (await confirmDialog({
|
||||
text: STRING_SIGN_OUT_CONFIRMATION,
|
||||
confirmButtonStyle: "danger"
|
||||
})) {
|
||||
this.application.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
async submitImportPassword() {
|
||||
await this.performImport(
|
||||
this.getState().importData.data,
|
||||
this.getState().importData.password
|
||||
);
|
||||
}
|
||||
|
||||
showRegister() {
|
||||
this.setFormDataState({
|
||||
showRegister: true
|
||||
});
|
||||
}
|
||||
|
||||
async readFile(file: File): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target!.result as string);
|
||||
resolve(data);
|
||||
} catch (e) {
|
||||
this.application!.alertService!.alert(
|
||||
STRING_INVALID_IMPORT_FILE
|
||||
);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @template
|
||||
*/
|
||||
async importFileSelected(files: File[]) {
|
||||
const run = async () => {
|
||||
const file = files[0];
|
||||
const data = await this.readFile(file);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
if (data.version || data.auth_params || data.keyParams) {
|
||||
const version = data.version || data.keyParams?.version || data.auth_params?.version;
|
||||
if (
|
||||
!this.application!.protocolService!.supportedVersions().includes(version)
|
||||
) {
|
||||
await this.setState({ importData: null });
|
||||
alertDialog({ text: STRING_UNSUPPORTED_BACKUP_FILE_VERSION });
|
||||
return;
|
||||
}
|
||||
if (data.keyParams || data.auth_params) {
|
||||
await this.setState({
|
||||
importData: {
|
||||
...this.getState().importData,
|
||||
requestPassword: true,
|
||||
data,
|
||||
}
|
||||
});
|
||||
const element = document.getElementById(
|
||||
ELEMENT_ID_IMPORT_PASSWORD_INPUT
|
||||
);
|
||||
if (element) {
|
||||
element.scrollIntoView(false);
|
||||
}
|
||||
} else {
|
||||
await this.performImport(data, undefined);
|
||||
}
|
||||
} else {
|
||||
await this.performImport(data, undefined);
|
||||
}
|
||||
};
|
||||
const needsPrivilege = await this.application!.privilegesService!.actionRequiresPrivilege(
|
||||
ProtectedAction.ManageBackups
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.application!.presentPrivilegesModal(
|
||||
ProtectedAction.ManageBackups,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
async performImport(data: BackupFile, password?: string) {
|
||||
await this.setState({
|
||||
importData: {
|
||||
...this.getState().importData,
|
||||
loading: true
|
||||
}
|
||||
});
|
||||
const result = await this.application!.importData(
|
||||
data,
|
||||
password
|
||||
);
|
||||
this.setState({
|
||||
importData: null
|
||||
});
|
||||
if ('error' in result) {
|
||||
this.application!.alertService!.alert(
|
||||
result.error
|
||||
);
|
||||
} else if (result.errorCount) {
|
||||
const message = StringImportError(result.errorCount);
|
||||
this.application!.alertService!.alert(
|
||||
message
|
||||
);
|
||||
} else {
|
||||
this.application!.alertService!.alert(
|
||||
STRING_IMPORT_SUCCESS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadDataArchive() {
|
||||
this.application!.getArchiveService().downloadBackup(this.getState().mutable.backupEncrypted);
|
||||
}
|
||||
|
||||
notesAndTagsCount() {
|
||||
return this.application!.getItems(
|
||||
[
|
||||
ContentType.Note,
|
||||
ContentType.Tag
|
||||
]
|
||||
).length;
|
||||
}
|
||||
|
||||
encryptionStatusForNotes() {
|
||||
const length = this.notesAndTagsCount();
|
||||
return length + "/" + length + " notes and tags encrypted";
|
||||
}
|
||||
|
||||
async reloadAutoLockInterval() {
|
||||
const interval = await this.application!.getAutolockService().getAutoLockInterval();
|
||||
this.setState({
|
||||
selectedAutoLockInterval: interval
|
||||
});
|
||||
}
|
||||
|
||||
async selectAutoLockInterval(interval: number) {
|
||||
const run = async () => {
|
||||
await this.application!.getAutolockService().setAutoLockInterval(interval);
|
||||
this.reloadAutoLockInterval();
|
||||
};
|
||||
const needsPrivilege = await this.application!.privilegesService!.actionRequiresPrivilege(
|
||||
ProtectedAction.ManagePasscode
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.application!.presentPrivilegesModal(
|
||||
ProtectedAction.ManagePasscode,
|
||||
() => {
|
||||
run();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
hidePasswordForm() {
|
||||
this.setFormDataState({
|
||||
showLogin: false,
|
||||
showRegister: false,
|
||||
user_password: undefined,
|
||||
password_conf: undefined
|
||||
});
|
||||
}
|
||||
|
||||
hasPasscode() {
|
||||
return this.application!.hasPasscode();
|
||||
}
|
||||
|
||||
addPasscodeClicked() {
|
||||
this.setFormDataState({
|
||||
showPasscodeForm: true
|
||||
});
|
||||
}
|
||||
|
||||
async submitPasscodeForm() {
|
||||
const passcode = this.getState().formData.passcode!;
|
||||
if (passcode !== this.getState().formData.confirmPasscode!) {
|
||||
this.application!.alertService!.alert(
|
||||
STRING_NON_MATCHING_PASSCODES
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_CHANGE, async () => {
|
||||
if (this.application!.hasPasscode()) {
|
||||
await this.application!.changePasscode(passcode);
|
||||
} else {
|
||||
await this.application!.setPasscode(passcode);
|
||||
}
|
||||
});
|
||||
this.setFormDataState({
|
||||
passcode: undefined,
|
||||
confirmPasscode: undefined,
|
||||
showPasscodeForm: false
|
||||
});
|
||||
this.refreshEncryptionStatus();
|
||||
}
|
||||
|
||||
async changePasscodePressed() {
|
||||
const run = () => {
|
||||
this.getState().formData.changingPasscode = true;
|
||||
this.addPasscodeClicked();
|
||||
};
|
||||
const needsPrivilege = await this.application!.privilegesService!.actionRequiresPrivilege(
|
||||
ProtectedAction.ManagePasscode
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.application!.presentPrivilegesModal(
|
||||
ProtectedAction.ManagePasscode,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
async removePasscodePressed() {
|
||||
const run = async () => {
|
||||
const signedIn = this.application!.hasAccount();
|
||||
let message = STRING_REMOVE_PASSCODE_CONFIRMATION;
|
||||
if (!signedIn) {
|
||||
message += STRING_REMOVE_PASSCODE_OFFLINE_ADDENDUM;
|
||||
}
|
||||
if (await confirmDialog({
|
||||
text: message,
|
||||
confirmButtonStyle: 'danger'
|
||||
})) {
|
||||
await preventRefreshing(STRING_CONFIRM_APP_QUIT_DURING_PASSCODE_REMOVAL, async () => {
|
||||
await this.application.getAutolockService().deleteAutolockPreference();
|
||||
await this.application!.removePasscode();
|
||||
await this.reloadAutoLockInterval();
|
||||
});
|
||||
this.refreshEncryptionStatus();
|
||||
}
|
||||
};
|
||||
const needsPrivilege = await this.application!.privilegesService!.actionRequiresPrivilege(
|
||||
ProtectedAction.ManagePasscode
|
||||
);
|
||||
if (needsPrivilege) {
|
||||
this.application!.presentPrivilegesModal(
|
||||
ProtectedAction.ManagePasscode,
|
||||
run
|
||||
);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
isDesktopApplication() {
|
||||
return isDesktopApplication();
|
||||
}
|
||||
}
|
||||
|
||||
export class AccountMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = AccountMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
closeFunction: '&',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import template from '%/directives/actions-menu.pug';
|
||||
import { PureCtrl } from '@Controllers';
|
||||
|
||||
class ActionsMenuCtrl extends PureCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$scope,
|
||||
$timeout,
|
||||
actionsManager,
|
||||
) {
|
||||
super($timeout);
|
||||
this.$timeout = $timeout;
|
||||
this.actionsManager = actionsManager;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.initProps({
|
||||
item: this.item
|
||||
});
|
||||
this.loadExtensions();
|
||||
};
|
||||
|
||||
async loadExtensions() {
|
||||
const extensions = this.actionsManager.extensions.sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
for (const extension of extensions) {
|
||||
extension.loading = true;
|
||||
await this.actionsManager.loadExtensionInContextOfItem(extension, this.props.item);
|
||||
extension.loading = false;
|
||||
}
|
||||
this.setState({
|
||||
extensions: extensions
|
||||
});
|
||||
}
|
||||
|
||||
async executeAction(action, extension) {
|
||||
if (action.verb === 'nested') {
|
||||
if (!action.subrows) {
|
||||
action.subrows = this.subRowsForAction(action, extension);
|
||||
} else {
|
||||
action.subrows = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
action.running = true;
|
||||
const result = await this.actionsManager.executeAction(
|
||||
action,
|
||||
extension,
|
||||
this.props.item
|
||||
);
|
||||
if (action.error) {
|
||||
return;
|
||||
}
|
||||
action.running = false;
|
||||
this.handleActionResult(action, result);
|
||||
await this.actionsManager.loadExtensionInContextOfItem(extension, this.props.item);
|
||||
this.setState({
|
||||
extensions: this.state.extensions
|
||||
});
|
||||
}
|
||||
|
||||
handleActionResult(action, result) {
|
||||
switch (action.verb) {
|
||||
case 'render': {
|
||||
const item = result.item;
|
||||
this.actionsManager.presentRevisionPreviewModal(
|
||||
item.uuid,
|
||||
item.content
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subRowsForAction(parentAction, extension) {
|
||||
if (!parentAction.subactions) {
|
||||
return null;
|
||||
}
|
||||
return parentAction.subactions.map((subaction) => {
|
||||
return {
|
||||
onClick: () => {
|
||||
this.executeAction(subaction, extension, parentAction);
|
||||
},
|
||||
label: subaction.label,
|
||||
subtitle: subaction.desc,
|
||||
spinnerClass: subaction.running ? 'info' : null
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionsMenu {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = ActionsMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
285
app/assets/javascripts/directives/views/actionsMenu.ts
Normal file
285
app/assets/javascripts/directives/views/actionsMenu.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/actions-menu.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { SNItem, Action, SNActionsExtension, UuidString } from 'snjs/dist/@types';
|
||||
import { ActionResponse } from 'snjs';
|
||||
import { ActionsExtensionMutator } from 'snjs/dist/@types/models/app/extension';
|
||||
import { autorun, IReactionDisposer } from 'mobx';
|
||||
|
||||
type ActionsMenuScope = {
|
||||
application: WebApplication
|
||||
item: SNItem
|
||||
}
|
||||
|
||||
type ActionSubRow = {
|
||||
onClick: () => void
|
||||
label: string
|
||||
subtitle: string
|
||||
spinnerClass?: string
|
||||
}
|
||||
|
||||
type ExtensionState = {
|
||||
loading: boolean
|
||||
error: boolean
|
||||
}
|
||||
|
||||
type ActionsMenuState = {
|
||||
extensions: SNActionsExtension[]
|
||||
extensionsState: Record<UuidString, ExtensionState>
|
||||
selectedActionId?: number
|
||||
menu: {
|
||||
uuid: UuidString,
|
||||
name: string,
|
||||
loading: boolean,
|
||||
error: boolean,
|
||||
hidden: boolean,
|
||||
actions: (Action & {
|
||||
subrows?: ActionSubRow[]
|
||||
})[]
|
||||
}[]
|
||||
}
|
||||
|
||||
class ActionsMenuCtrl extends PureViewCtrl<{}, ActionsMenuState> implements ActionsMenuScope {
|
||||
application!: WebApplication
|
||||
item!: SNItem
|
||||
private removeHiddenExtensionsListener?: IReactionDisposer;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.initProps({
|
||||
item: this.item
|
||||
});
|
||||
this.loadExtensions();
|
||||
this.removeHiddenExtensionsListener = autorun(() => {
|
||||
this.rebuildMenu({
|
||||
hiddenExtensions: this.appState.actionsMenu.hiddenExtensions
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
deinit() {
|
||||
this.removeHiddenExtensionsListener?.();
|
||||
}
|
||||
|
||||
/** @override */
|
||||
getInitialState() {
|
||||
const extensions = this.application.actionsManager!.getExtensions().sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
let extensionsState: Record<UuidString, ExtensionState> = {};
|
||||
extensions.map((extension) => {
|
||||
extensionsState[extension.uuid] = {
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
extensions,
|
||||
extensionsState,
|
||||
hiddenExtensions: {},
|
||||
menu: [],
|
||||
};
|
||||
}
|
||||
|
||||
rebuildMenu({
|
||||
extensions = this.state.extensions,
|
||||
extensionsState = this.state.extensionsState,
|
||||
selectedActionId = this.state.selectedActionId,
|
||||
hiddenExtensions = this.appState.actionsMenu.hiddenExtensions,
|
||||
} = {}) {
|
||||
return this.setState({
|
||||
extensions,
|
||||
extensionsState,
|
||||
selectedActionId,
|
||||
menu: extensions.map(extension => {
|
||||
const state = extensionsState[extension.uuid];
|
||||
const hidden = hiddenExtensions[extension.uuid];
|
||||
return {
|
||||
uuid: extension.uuid,
|
||||
name: extension.name,
|
||||
loading: state?.loading ?? false,
|
||||
error: state?.error ?? false,
|
||||
hidden: hidden ?? false,
|
||||
deprecation: extension.deprecation!,
|
||||
actions: extension.actionsWithContextForItem(this.item).map(action => {
|
||||
if (action.id === selectedActionId) {
|
||||
return {
|
||||
...action,
|
||||
subrows: this.subRowsForAction(action, extension)
|
||||
}
|
||||
} else {
|
||||
return action;
|
||||
}
|
||||
})
|
||||
};
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
async loadExtensions() {
|
||||
await Promise.all(this.state.extensions.map(async (extension: SNActionsExtension) => {
|
||||
this.setLoadingExtension(extension.uuid, true);
|
||||
const updatedExtension = await this.application.actionsManager!.loadExtensionInContextOfItem(
|
||||
extension,
|
||||
this.item
|
||||
);
|
||||
if (updatedExtension) {
|
||||
await this.updateExtension(updatedExtension!);
|
||||
} else {
|
||||
this.setErrorExtension(extension.uuid, true);
|
||||
}
|
||||
this.setLoadingExtension(extension.uuid, false);
|
||||
}));
|
||||
}
|
||||
|
||||
async executeAction(action: Action, extensionUuid: UuidString) {
|
||||
if (action.verb === 'nested') {
|
||||
this.rebuildMenu({
|
||||
selectedActionId: action.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
const extension = this.application.findItem(extensionUuid) as SNActionsExtension;
|
||||
await this.updateAction(action, extension, { running: true });
|
||||
const response = await this.application.actionsManager!.runAction(
|
||||
action,
|
||||
this.item,
|
||||
async () => {
|
||||
/** @todo */
|
||||
return '';
|
||||
}
|
||||
);
|
||||
if (response.error) {
|
||||
await this.updateAction(action, extension, { error: true });
|
||||
return;
|
||||
}
|
||||
await this.updateAction(action, extension, { running: false });
|
||||
this.handleActionResponse(action, response);
|
||||
await this.reloadExtension(extension);
|
||||
}
|
||||
|
||||
handleActionResponse(action: Action, result: ActionResponse) {
|
||||
switch (action.verb) {
|
||||
case 'render': {
|
||||
const item = result.item;
|
||||
this.application.presentRevisionPreviewModal(
|
||||
item.uuid,
|
||||
item.content
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private subRowsForAction(parentAction: Action, extension: Pick<SNActionsExtension, 'uuid'>): ActionSubRow[] | undefined {
|
||||
if (!parentAction.subactions) {
|
||||
return undefined;
|
||||
}
|
||||
return parentAction.subactions.map((subaction) => {
|
||||
return {
|
||||
id: subaction.id,
|
||||
onClick: () => {
|
||||
this.executeAction(subaction, extension.uuid);
|
||||
},
|
||||
label: subaction.label,
|
||||
subtitle: subaction.desc,
|
||||
spinnerClass: subaction.running ? 'info' : undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async updateAction(
|
||||
action: Action,
|
||||
extension: SNActionsExtension,
|
||||
params: {
|
||||
running?: boolean
|
||||
error?: boolean
|
||||
}
|
||||
) {
|
||||
const updatedExtension = await this.application.changeItem(extension.uuid, (mutator) => {
|
||||
const extensionMutator = mutator as ActionsExtensionMutator;
|
||||
extensionMutator.actions = extension!.actions.map((act) => {
|
||||
if (act && params && act.verb === action.verb && act.url === action.url) {
|
||||
return {
|
||||
...action,
|
||||
running: params?.running,
|
||||
error: params?.error,
|
||||
} as Action;
|
||||
}
|
||||
return act;
|
||||
});
|
||||
}) as SNActionsExtension;
|
||||
await this.updateExtension(updatedExtension);
|
||||
}
|
||||
|
||||
private async updateExtension(extension: SNActionsExtension) {
|
||||
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
|
||||
if (extension.uuid === ext.uuid) {
|
||||
return extension;
|
||||
}
|
||||
return ext;
|
||||
});
|
||||
await this.rebuildMenu({
|
||||
extensions
|
||||
});
|
||||
}
|
||||
|
||||
private async reloadExtension(extension: SNActionsExtension) {
|
||||
const extensionInContext = await this.application.actionsManager!.loadExtensionInContextOfItem(
|
||||
extension,
|
||||
this.item
|
||||
);
|
||||
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
|
||||
if (extension.uuid === ext.uuid) {
|
||||
return extensionInContext!;
|
||||
}
|
||||
return ext;
|
||||
});
|
||||
this.rebuildMenu({
|
||||
extensions
|
||||
});
|
||||
}
|
||||
|
||||
public toggleExtensionVisibility(extensionUuid: UuidString) {
|
||||
this.appState.actionsMenu.toggleExtensionVisibility(extensionUuid);
|
||||
}
|
||||
|
||||
private setLoadingExtension(extensionUuid: UuidString, value = false) {
|
||||
const { extensionsState } = this.state;
|
||||
extensionsState[extensionUuid].loading = value;
|
||||
this.rebuildMenu({
|
||||
extensionsState
|
||||
});
|
||||
}
|
||||
|
||||
private setErrorExtension(extensionUuid: UuidString, value = false) {
|
||||
const { extensionsState } = this.state;
|
||||
extensionsState[extensionUuid].error = value;
|
||||
this.rebuildMenu({
|
||||
extensionsState
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionsMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.replace = true;
|
||||
this.controller = ActionsMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import template from '%/directives/component-modal.pug';
|
||||
|
||||
export class ComponentModalCtrl {
|
||||
/* @ngInject */
|
||||
constructor($scope, $element) {
|
||||
this.$element = $element;
|
||||
this.$scope = $scope;
|
||||
}
|
||||
|
||||
dismiss(callback) {
|
||||
this.$element.remove();
|
||||
this.$scope.$destroy();
|
||||
if(this.onDismiss && this.onDismiss()) {
|
||||
this.onDismiss()(this.component);
|
||||
}
|
||||
callback && callback();
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentModal {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = ComponentModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
show: '=',
|
||||
component: '=',
|
||||
callback: '=',
|
||||
onDismiss: '&'
|
||||
};
|
||||
}
|
||||
}
|
||||
64
app/assets/javascripts/directives/views/componentModal.ts
Normal file
64
app/assets/javascripts/directives/views/componentModal.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent, LiveItem } from 'snjs';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/component-modal.pug';
|
||||
|
||||
export type ComponentModalScope = {
|
||||
componentUuid: string
|
||||
onDismiss: () => void
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export class ComponentModalCtrl implements ComponentModalScope {
|
||||
$element: JQLite
|
||||
componentUuid!: string
|
||||
onDismiss!: () => void
|
||||
application!: WebApplication
|
||||
liveComponent!: LiveItem<SNComponent>
|
||||
component!: SNComponent
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.liveComponent = new LiveItem(
|
||||
this.componentUuid,
|
||||
this.application,
|
||||
(component) => {
|
||||
this.component = component;
|
||||
}
|
||||
);
|
||||
this.application.componentGroup.activateComponent(this.component);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.application.componentGroup.deactivateComponent(this.component);
|
||||
this.liveComponent.deinit();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.onDismiss && this.onDismiss();
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = ComponentModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
componentUuid: '=',
|
||||
onDismiss: '&',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
269
app/assets/javascripts/directives/views/componentView.ts
Normal file
269
app/assets/javascripts/directives/views/componentView.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { RootScopeMessages } from './../../messages';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent, ComponentAction, LiveItem } from 'snjs';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/component-view.pug';
|
||||
import { isDesktopApplication } from '../../utils';
|
||||
/**
|
||||
* The maximum amount of time we'll wait for a component
|
||||
* to load before displaying error
|
||||
*/
|
||||
const MaxLoadThreshold = 4000;
|
||||
const VisibilityChangeKey = 'visibilitychange';
|
||||
|
||||
interface ComponentViewScope {
|
||||
componentUuid: string
|
||||
onLoad?: (component: SNComponent) => void
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
class ComponentViewCtrl implements ComponentViewScope {
|
||||
|
||||
/** @scope */
|
||||
onLoad?: (component: SNComponent) => void
|
||||
componentUuid!: string
|
||||
templateComponent!: SNComponent
|
||||
application!: WebApplication
|
||||
liveComponent!: LiveItem<SNComponent>
|
||||
|
||||
private $rootScope: ng.IRootScopeService
|
||||
private $timeout: ng.ITimeoutService
|
||||
private componentValid = true
|
||||
private cleanUpOn: () => void
|
||||
private unregisterComponentHandler!: () => void
|
||||
private unregisterDesktopObserver!: () => void
|
||||
private issueLoading = false
|
||||
public reloading = false
|
||||
private expired = false
|
||||
private loading = false
|
||||
private didAttemptReload = false
|
||||
public error: 'offline-restricted' | 'url-missing' | undefined
|
||||
private loadTimeout: any
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$scope: ng.IScope,
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
) {
|
||||
this.$rootScope = $rootScope;
|
||||
this.$timeout = $timeout;
|
||||
this.cleanUpOn = $scope.$on('ext-reload-complete', () => {
|
||||
this.reloadStatus(false);
|
||||
});
|
||||
/** To allow for registering events */
|
||||
this.onVisibilityChange = this.onVisibilityChange.bind(this);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
if(this.application.componentManager) {
|
||||
/** Component manager Can be destroyed already via locking */
|
||||
this.application.componentManager.onComponentIframeDestroyed(this.component.uuid);
|
||||
if(this.templateComponent) {
|
||||
this.application.componentManager.removeTemporaryTemplateComponent(this.templateComponent);
|
||||
}
|
||||
}
|
||||
if(this.liveComponent) {
|
||||
this.liveComponent.deinit();
|
||||
}
|
||||
this.cleanUpOn();
|
||||
(this.cleanUpOn as any) = undefined;
|
||||
this.unregisterComponentHandler();
|
||||
(this.unregisterComponentHandler as any) = undefined;
|
||||
this.unregisterDesktopObserver();
|
||||
(this.unregisterDesktopObserver as any) = undefined;
|
||||
(this.templateComponent as any) = undefined;
|
||||
(this.liveComponent as any) = undefined;
|
||||
(this.application as any) = undefined;
|
||||
(this.onVisibilityChange as any) = undefined;
|
||||
this.onLoad = undefined;
|
||||
document.removeEventListener(
|
||||
VisibilityChangeKey,
|
||||
this.onVisibilityChange
|
||||
);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
if(this.componentUuid) {
|
||||
this.liveComponent = new LiveItem(this.componentUuid, this.application);
|
||||
} else {
|
||||
this.application.componentManager.addTemporaryTemplateComponent(this.templateComponent);
|
||||
}
|
||||
this.registerComponentHandlers();
|
||||
this.registerPackageUpdateObserver();
|
||||
}
|
||||
|
||||
get component() {
|
||||
return this.templateComponent || this.liveComponent?.item;
|
||||
}
|
||||
|
||||
/** @template */
|
||||
public onIframeInit() {
|
||||
/** Perform in timeout required so that dynamic iframe id is set (based on ctrl values) */
|
||||
this.$timeout(() => {
|
||||
this.loadComponent();
|
||||
});
|
||||
}
|
||||
|
||||
private loadComponent() {
|
||||
if (!this.component) {
|
||||
throw Error('Component view is missing component');
|
||||
}
|
||||
if (!this.component.active && !this.component.isEditor()) {
|
||||
/** Editors don't need to be active to be displayed */
|
||||
throw Error('Component view component must be active');
|
||||
}
|
||||
const iframe = this.application.componentManager!.iframeForComponent(
|
||||
this.component.uuid
|
||||
);
|
||||
if (!iframe) {
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
if (this.loadTimeout) {
|
||||
this.$timeout.cancel(this.loadTimeout);
|
||||
}
|
||||
this.loadTimeout = this.$timeout(() => {
|
||||
this.handleIframeLoadTimeout();
|
||||
}, MaxLoadThreshold);
|
||||
iframe.onload = () => {
|
||||
this.reloadStatus();
|
||||
this.handleIframeLoad(iframe);
|
||||
};
|
||||
}
|
||||
|
||||
private registerPackageUpdateObserver() {
|
||||
this.unregisterDesktopObserver = this.application.getDesktopService()
|
||||
.registerUpdateObserver((component: SNComponent) => {
|
||||
if (component.uuid === this.component.uuid && component.active) {
|
||||
this.reloadIframe();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private registerComponentHandlers() {
|
||||
this.unregisterComponentHandler = this.application.componentManager!.registerHandler({
|
||||
identifier: 'component-view-' + Math.random(),
|
||||
areas: [this.component.area],
|
||||
actionHandler: (component, action, data) => {
|
||||
if (action === ComponentAction.SetSize) {
|
||||
this.application.componentManager!.handleSetSizeEvent(component, data);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private reloadIframe() {
|
||||
this.$timeout(() => {
|
||||
this.reloading = true;
|
||||
this.$timeout(() => {
|
||||
this.reloading = false;
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
private onVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return;
|
||||
}
|
||||
if (this.issueLoading) {
|
||||
this.reloadIframe();
|
||||
}
|
||||
}
|
||||
|
||||
public reloadStatus(doManualReload = true) {
|
||||
const component = this.component;
|
||||
const offlineRestricted = component.offlineOnly && !isDesktopApplication();
|
||||
const hasUrlError = function () {
|
||||
if (isDesktopApplication()) {
|
||||
return !component.local_url && !component.hasValidHostedUrl();
|
||||
} else {
|
||||
return !component.hasValidHostedUrl();
|
||||
}
|
||||
}();
|
||||
this.expired = component.valid_until && component.valid_until <= new Date();
|
||||
const readonlyState = this.application.componentManager!
|
||||
.getReadonlyStateForComponent(component);
|
||||
if (!readonlyState.lockReadonly) {
|
||||
this.application.componentManager!
|
||||
.setReadonlyStateForComponent(component, this.expired);
|
||||
}
|
||||
this.componentValid = !offlineRestricted && !hasUrlError;
|
||||
if (!this.componentValid) {
|
||||
this.loading = false;
|
||||
}
|
||||
if (offlineRestricted) {
|
||||
this.error = 'offline-restricted';
|
||||
} else if (hasUrlError) {
|
||||
this.error = 'url-missing';
|
||||
} else {
|
||||
this.error = undefined;
|
||||
}
|
||||
if (this.expired && doManualReload) {
|
||||
this.$rootScope.$broadcast(RootScopeMessages.ReloadExtendedData);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIframeLoadTimeout() {
|
||||
if (this.loading) {
|
||||
this.loading = false;
|
||||
this.issueLoading = true;
|
||||
if (!this.didAttemptReload) {
|
||||
this.didAttemptReload = true;
|
||||
this.reloadIframe();
|
||||
} else {
|
||||
document.addEventListener(
|
||||
VisibilityChangeKey,
|
||||
this.onVisibilityChange
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIframeLoad(iframe: HTMLIFrameElement) {
|
||||
let desktopError = false;
|
||||
if (isDesktopApplication()) {
|
||||
try {
|
||||
/** Accessing iframe.contentWindow.origin only allowed in desktop app. */
|
||||
if (!iframe.contentWindow!.origin || iframe.contentWindow!.origin === 'null') {
|
||||
desktopError = true;
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
this.$timeout.cancel(this.loadTimeout);
|
||||
await this.application.componentManager!.registerComponentWindow(
|
||||
this.component,
|
||||
iframe.contentWindow!
|
||||
);
|
||||
const avoidFlickerTimeout = 7;
|
||||
this.$timeout(() => {
|
||||
this.loading = false;
|
||||
// eslint-disable-next-line no-unneeded-ternary
|
||||
this.issueLoading = desktopError ? true : false;
|
||||
this.onLoad && this.onLoad(this.component!);
|
||||
}, avoidFlickerTimeout);
|
||||
}
|
||||
|
||||
/** @template */
|
||||
public getUrl() {
|
||||
const url = this.application.componentManager!.urlForComponent(this.component);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentView extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.scope = {
|
||||
componentUuid: '=',
|
||||
templateComponent: '=?',
|
||||
onLoad: '=?',
|
||||
application: '='
|
||||
};
|
||||
this.controller = ComponentViewCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import template from '%/directives/conflict-resolution-modal.pug';
|
||||
|
||||
class ConflictResolutionCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element,
|
||||
alertManager,
|
||||
archiveManager,
|
||||
modelManager,
|
||||
syncManager
|
||||
) {
|
||||
this.$element = $element;
|
||||
this.alertManager = alertManager;
|
||||
this.archiveManager = archiveManager;
|
||||
this.modelManager = modelManager;
|
||||
this.syncManager = syncManager;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.contentType = this.item1.content_type;
|
||||
this.item1Content = this.createContentString(this.item1);
|
||||
this.item2Content = this.createContentString(this.item2);
|
||||
};
|
||||
|
||||
createContentString(item) {
|
||||
const data = Object.assign({
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at
|
||||
}, item.content);
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
keepItem1() {
|
||||
this.alertManager.confirm({
|
||||
text: `Are you sure you want to delete the item on the right?`,
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
this.modelManager.setItemToBeDeleted(this.item2);
|
||||
this.syncManager.sync().then(() => {
|
||||
this.applyCallback();
|
||||
});
|
||||
this.dismiss();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
keepItem2() {
|
||||
this.alertManager.confirm({
|
||||
text: `Are you sure you want to delete the item on the left?`,
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
this.modelManager.setItemToBeDeleted(this.item1);
|
||||
this.syncManager.sync().then(() => {
|
||||
this.applyCallback();
|
||||
});
|
||||
this.dismiss();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
keepBoth() {
|
||||
this.applyCallback();
|
||||
this.dismiss();
|
||||
}
|
||||
|
||||
export() {
|
||||
this.archiveManager.downloadBackupOfItems(
|
||||
[this.item1, this.item2],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
applyCallback() {
|
||||
this.callback && this.callback();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.$element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictResolutionModal {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = ConflictResolutionCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item1: '=',
|
||||
item2: '=',
|
||||
callback: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { isDesktopApplication } from '@/utils';
|
||||
import template from '%/directives/editor-menu.pug';
|
||||
import { PureCtrl } from '@Controllers';
|
||||
|
||||
class EditorMenuCtrl extends PureCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout,
|
||||
componentManager,
|
||||
modelManager,
|
||||
syncManager,
|
||||
) {
|
||||
super($timeout);
|
||||
this.$timeout = $timeout;
|
||||
this.componentManager = componentManager;
|
||||
this.modelManager = modelManager;
|
||||
this.syncManager = syncManager;
|
||||
this.state = {
|
||||
isDesktop: isDesktopApplication()
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
const editors = this.componentManager.componentsForArea('editor-editor')
|
||||
.sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
const defaultEditor = editors.filter((e) => e.isDefaultEditor())[0];
|
||||
this.setState({
|
||||
editors: editors,
|
||||
defaultEditor: defaultEditor
|
||||
});
|
||||
};
|
||||
|
||||
selectComponent(component) {
|
||||
if(component) {
|
||||
if(component.content.conflict_of) {
|
||||
component.content.conflict_of = null;
|
||||
this.modelManager.setItemDirty(component, true);
|
||||
this.syncManager.sync();
|
||||
}
|
||||
}
|
||||
this.$timeout(() => {
|
||||
this.callback()(component);
|
||||
});
|
||||
}
|
||||
|
||||
toggleDefaultForEditor(editor) {
|
||||
if(this.state.defaultEditor === editor) {
|
||||
this.removeEditorDefault(editor);
|
||||
} else {
|
||||
this.makeEditorDefault(editor);
|
||||
}
|
||||
}
|
||||
|
||||
offlineAvailableForComponent(component) {
|
||||
return component.local_url && this.state.isDesktop;
|
||||
}
|
||||
|
||||
makeEditorDefault(component) {
|
||||
const currentDefault = this.componentManager
|
||||
.componentsForArea('editor-editor')
|
||||
.filter((e) => e.isDefaultEditor())[0];
|
||||
if(currentDefault) {
|
||||
currentDefault.setAppDataItem('defaultEditor', false);
|
||||
this.modelManager.setItemDirty(currentDefault);
|
||||
}
|
||||
component.setAppDataItem('defaultEditor', true);
|
||||
this.modelManager.setItemDirty(component);
|
||||
this.syncManager.sync();
|
||||
this.setState({
|
||||
defaultEditor: component
|
||||
});
|
||||
}
|
||||
|
||||
removeEditorDefault(component) {
|
||||
component.setAppDataItem('defaultEditor', false);
|
||||
this.modelManager.setItemDirty(component);
|
||||
this.syncManager.sync();
|
||||
this.setState({
|
||||
defaultEditor: null
|
||||
});
|
||||
}
|
||||
|
||||
shouldDisplayRunningLocallyLabel(component) {
|
||||
if(!component.runningLocally) {
|
||||
return false;
|
||||
}
|
||||
if(component === this.selectedEditor) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorMenu {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = EditorMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
callback: '&',
|
||||
selectedEditor: '=',
|
||||
currentItem: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
127
app/assets/javascripts/directives/views/editorMenu.ts
Normal file
127
app/assets/javascripts/directives/views/editorMenu.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { WebDirective } from './../../types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent, SNItem, ComponentArea } from 'snjs';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import template from '%/directives/editor-menu.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { ComponentMutator } from 'snjs/dist/@types/models';
|
||||
|
||||
interface EditorMenuScope {
|
||||
callback: (component: SNComponent) => void
|
||||
selectedEditorUuid: string
|
||||
currentItem: SNItem
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
class EditorMenuCtrl extends PureViewCtrl implements EditorMenuScope {
|
||||
|
||||
callback!: () => (component: SNComponent) => void
|
||||
selectedEditorUuid!: string
|
||||
currentItem!: SNItem
|
||||
application!: WebApplication
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
) {
|
||||
super($timeout);
|
||||
this.state = {
|
||||
isDesktop: isDesktopApplication()
|
||||
};
|
||||
}
|
||||
|
||||
public isEditorSelected(editor: SNComponent) {
|
||||
if(!this.selectedEditorUuid) {
|
||||
return false;
|
||||
}
|
||||
return this.selectedEditorUuid === editor.uuid;
|
||||
}
|
||||
|
||||
public isEditorDefault(editor: SNComponent) {
|
||||
return this.state.defaultEditor?.uuid === editor.uuid;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
const editors = this.application.componentManager!.componentsForArea(ComponentArea.Editor)
|
||||
.sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
});
|
||||
const defaultEditor = editors.filter((e) => e.isDefaultEditor())[0];
|
||||
this.setState({
|
||||
editors: editors,
|
||||
defaultEditor: defaultEditor
|
||||
});
|
||||
};
|
||||
|
||||
selectComponent(component: SNComponent) {
|
||||
if (component) {
|
||||
if (component.conflictOf) {
|
||||
this.application.changeAndSaveItem(component.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
})
|
||||
}
|
||||
}
|
||||
this.$timeout(() => {
|
||||
this.callback()(component);
|
||||
});
|
||||
}
|
||||
|
||||
toggleDefaultForEditor(editor: SNComponent) {
|
||||
if (this.state.defaultEditor === editor) {
|
||||
this.removeEditorDefault(editor);
|
||||
} else {
|
||||
this.makeEditorDefault(editor);
|
||||
}
|
||||
}
|
||||
|
||||
offlineAvailableForComponent(component: SNComponent) {
|
||||
return component.local_url && this.state.isDesktop;
|
||||
}
|
||||
|
||||
makeEditorDefault(component: SNComponent) {
|
||||
const currentDefault = this.application.componentManager!
|
||||
.componentsForArea(ComponentArea.Editor)
|
||||
.filter((e) => e.isDefaultEditor())[0];
|
||||
if (currentDefault) {
|
||||
this.application.changeItem(currentDefault.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.defaultEditor = false;
|
||||
})
|
||||
}
|
||||
this.application.changeAndSaveItem(component.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.defaultEditor = true;
|
||||
});
|
||||
this.setState({
|
||||
defaultEditor: component
|
||||
});
|
||||
}
|
||||
|
||||
removeEditorDefault(component: SNComponent) {
|
||||
this.application.changeAndSaveItem(component.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.defaultEditor = false;
|
||||
});
|
||||
this.setState({
|
||||
defaultEditor: null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class EditorMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = EditorMenuCtrl;
|
||||
this.controllerAs = 'self';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
callback: '&',
|
||||
selectedEditorUuid: '=',
|
||||
currentItem: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
191
app/assets/javascripts/directives/views/historyMenu.ts
Normal file
191
app/assets/javascripts/directives/views/historyMenu.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { WebDirective } from '../../types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import template from '%/directives/history-menu.pug';
|
||||
import { SNItem, ItemHistoryEntry } from 'snjs/dist/@types';
|
||||
import { PureViewCtrl } from '@/views';
|
||||
import { ItemSessionHistory } from 'snjs/dist/@types/services/history/session/item_session_history';
|
||||
import { RevisionListEntry, SingleRevision } from 'snjs/dist/@types/services/api/responses';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
|
||||
type HistoryState = {
|
||||
fetchingRemoteHistory: boolean
|
||||
}
|
||||
|
||||
interface HistoryScope {
|
||||
application: WebApplication
|
||||
item: SNItem
|
||||
}
|
||||
|
||||
class HistoryMenuCtrl extends PureViewCtrl<{}, HistoryState> implements HistoryScope {
|
||||
|
||||
diskEnabled = false
|
||||
autoOptimize = false
|
||||
application!: WebApplication
|
||||
item!: SNItem
|
||||
sessionHistory?: ItemSessionHistory
|
||||
remoteHistory?: RevisionListEntry[]
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
this.state = {
|
||||
fetchingRemoteHistory: false
|
||||
};
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.reloadSessionHistory();
|
||||
this.fetchRemoteHistory();
|
||||
this.diskEnabled = this.application.historyManager!.isDiskEnabled();
|
||||
this.autoOptimize = this.application.historyManager!.isAutoOptimizeEnabled();
|
||||
}
|
||||
|
||||
reloadSessionHistory() {
|
||||
this.sessionHistory = this.application.historyManager!.sessionHistoryForItem(this.item);
|
||||
}
|
||||
|
||||
get isfetchingRemoteHistory() {
|
||||
return this.state.fetchingRemoteHistory;
|
||||
}
|
||||
|
||||
set fetchingRemoteHistory(value: boolean) {
|
||||
this.setState({
|
||||
fetchingRemoteHistory: value
|
||||
});
|
||||
}
|
||||
|
||||
async fetchRemoteHistory() {
|
||||
this.fetchingRemoteHistory = true;
|
||||
this.remoteHistory = await this.application.historyManager!.remoteHistoryForItem(this.item)
|
||||
.finally(() => {
|
||||
this.fetchingRemoteHistory = false;
|
||||
});
|
||||
}
|
||||
|
||||
async openSessionRevision(revision: ItemHistoryEntry) {
|
||||
this.application.presentRevisionPreviewModal(
|
||||
revision.payload.uuid,
|
||||
revision.payload.content
|
||||
);
|
||||
}
|
||||
|
||||
async openRemoteRevision(revision: RevisionListEntry) {
|
||||
this.fetchingRemoteHistory = true;
|
||||
const remoteRevision = await this.application.historyManager!.fetchRemoteRevision(this.item.uuid, revision);
|
||||
this.fetchingRemoteHistory = false;
|
||||
if (!remoteRevision) {
|
||||
this.application.alertService!.alert("The remote revision could not be loaded. Please try again later.");
|
||||
return;
|
||||
}
|
||||
this.application.presentRevisionPreviewModal(
|
||||
remoteRevision.payload.uuid,
|
||||
remoteRevision.payload.content
|
||||
);
|
||||
}
|
||||
|
||||
classForSessionRevision(revision: ItemHistoryEntry) {
|
||||
const vector = revision.operationVector();
|
||||
if (vector === 0) {
|
||||
return 'default';
|
||||
} else if (vector === 1) {
|
||||
return 'success';
|
||||
} else if (vector === -1) {
|
||||
return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
clearItemSessionHistory() {
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to delete the local session history for this note?",
|
||||
confirmButtonStyle: "danger"
|
||||
}).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.application.historyManager!.clearHistoryForItem(this.item).then(() => {
|
||||
this.$timeout(() => {
|
||||
this.reloadSessionHistory();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
clearAllSessionHistory() {
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to delete the local session history for all notes?",
|
||||
confirmButtonStyle: "danger"
|
||||
}).then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
this.application.historyManager!.clearAllHistory().then(() => {
|
||||
this.$timeout(() => {
|
||||
this.reloadSessionHistory();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get sessionHistoryEntries() {
|
||||
return this.sessionHistory?.entries;
|
||||
}
|
||||
|
||||
get remoteHistoryEntries() {
|
||||
return this.remoteHistory;
|
||||
}
|
||||
|
||||
toggleSessionHistoryDiskSaving() {
|
||||
const run = () => {
|
||||
this.application.historyManager!.toggleDiskSaving().then(() => {
|
||||
this.$timeout(() => {
|
||||
this.diskEnabled = this.application.historyManager!.isDiskEnabled();
|
||||
});
|
||||
});
|
||||
};
|
||||
if (!this.application.historyManager!.isDiskEnabled()) {
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to save history to disk? This will decrease general " +
|
||||
"performance, especially as you type. You are advised to disable this feature " +
|
||||
"if you experience any lagging.",
|
||||
confirmButtonStyle: "danger"
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
toggleSessionHistoryAutoOptimize() {
|
||||
this.application.historyManager!.toggleAutoOptimize().then(() => {
|
||||
this.$timeout(() => {
|
||||
this.autoOptimize = this.application.historyManager!.autoOptimize;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
previewRemoteHistoryTitle(revision: SingleRevision) {
|
||||
const createdAt = revision.created_at!;
|
||||
return new Date(createdAt).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoryMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = HistoryMenuCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ export { AccountMenu } from './accountMenu';
|
|||
export { ActionsMenu } from './actionsMenu';
|
||||
export { ComponentModal } from './componentModal';
|
||||
export { ComponentView } from './componentView';
|
||||
export { ConflictResolutionModal } from './conflictResolutionModal';
|
||||
export { EditorMenu } from './editorMenu';
|
||||
export { InputModal } from './inputModal';
|
||||
export { MenuRow } from './menuRow';
|
||||
|
|
@ -12,5 +11,5 @@ export { PermissionsModal } from './permissionsModal';
|
|||
export { PrivilegesAuthModal } from './privilegesAuthModal';
|
||||
export { PrivilegesManagementModal } from './privilegesManagementModal';
|
||||
export { RevisionPreviewModal } from './revisionPreviewModal';
|
||||
export { SessionHistoryMenu } from './sessionHistoryMenu';
|
||||
export { HistoryMenu } from './historyMenu';
|
||||
export { SyncResolutionMenu } from './syncResolutionMenu';
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import template from '%/directives/input-modal.pug';
|
||||
|
||||
class InputModalCtrl {
|
||||
|
||||
/* @ngInject */
|
||||
constructor($scope, $element) {
|
||||
this.$element = $element;
|
||||
this.formData = {};
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.$element.remove();
|
||||
this.$scope.$destroy();
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.callback()(this.formData.input);
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export class InputModal {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = InputModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '=',
|
||||
title: '=',
|
||||
message: '=',
|
||||
placeholder: '=',
|
||||
callback: '&'
|
||||
};
|
||||
}
|
||||
}
|
||||
53
app/assets/javascripts/directives/views/inputModal.ts
Normal file
53
app/assets/javascripts/directives/views/inputModal.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/input-modal.pug';
|
||||
|
||||
export interface InputModalScope extends Partial<ng.IScope> {
|
||||
type: string
|
||||
title: string
|
||||
message: string
|
||||
callback: (value: string) => void
|
||||
}
|
||||
|
||||
class InputModalCtrl implements InputModalScope {
|
||||
|
||||
$element: JQLite
|
||||
type!: string
|
||||
title!: string
|
||||
message!: string
|
||||
callback!: (value: string) => void
|
||||
formData = { input: '' }
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.callback(this.formData.input);
|
||||
this.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
export class InputModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = InputModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '=',
|
||||
title: '=',
|
||||
message: '=',
|
||||
callback: '&'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,13 @@
|
|||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/menu-row.pug';
|
||||
|
||||
class MenuRowCtrl {
|
||||
|
||||
onClick($event) {
|
||||
disabled!: boolean
|
||||
action!: () => void
|
||||
buttonAction!: () => void
|
||||
|
||||
onClick($event: Event) {
|
||||
if(this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -10,7 +15,7 @@ class MenuRowCtrl {
|
|||
this.action();
|
||||
}
|
||||
|
||||
clickAccessoryButton($event) {
|
||||
clickAccessoryButton($event: Event) {
|
||||
if(this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -19,8 +24,9 @@ class MenuRowCtrl {
|
|||
}
|
||||
}
|
||||
|
||||
export class MenuRow {
|
||||
export class MenuRow extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.transclude = true;
|
||||
this.template = template;
|
||||
|
|
@ -1,71 +1,141 @@
|
|||
import { PanelPuppet, WebDirective } from './../../types';
|
||||
import angular from 'angular';
|
||||
import template from '%/directives/panel-resizer.pug';
|
||||
import { debounce } from '@/utils';
|
||||
|
||||
const PANEL_SIDE_RIGHT = 'right';
|
||||
const PANEL_SIDE_LEFT = 'left';
|
||||
|
||||
const MOUSE_EVENT_MOVE = 'mousemove';
|
||||
const MOUSE_EVENT_DOWN = 'mousedown';
|
||||
const MOUSE_EVENT_UP = 'mouseup';
|
||||
|
||||
enum PanelSide {
|
||||
Right = 'right',
|
||||
Left = 'left'
|
||||
};
|
||||
enum MouseEventType {
|
||||
Move = 'mousemove',
|
||||
Down = 'mousedown',
|
||||
Up = 'mouseup'
|
||||
};
|
||||
enum CssClass {
|
||||
Hoverable = 'hoverable',
|
||||
AlwaysVisible = 'always-visible',
|
||||
Dragging = 'dragging',
|
||||
NoSelection = 'no-selection',
|
||||
Collapsed = 'collapsed',
|
||||
AnimateOpacity = 'animate-opacity',
|
||||
};
|
||||
const WINDOW_EVENT_RESIZE = 'resize';
|
||||
|
||||
const PANEL_CSS_CLASS_HOVERABLE = 'hoverable';
|
||||
const PANEL_CSS_CLASS_ALWAYS_VISIBLE = 'always-visible';
|
||||
const PANEL_CSS_CLASS_DRAGGING = 'dragging';
|
||||
const PANEL_CSS_CLASS_NO_SELECTION = 'no-selection';
|
||||
const PANEL_CSS_CLASS_COLLAPSED = 'collapsed';
|
||||
const PANEL_CSS_CLASS_ANIMATE_OPACITY = 'animate-opacity';
|
||||
type ResizeFinishCallback = (
|
||||
lastWidth: number,
|
||||
lastLeft: number,
|
||||
isMaxWidth: boolean,
|
||||
isCollapsed: boolean
|
||||
) => void
|
||||
|
||||
interface PanelResizerScope {
|
||||
alwaysVisible: boolean
|
||||
collapsable: boolean
|
||||
control: PanelPuppet
|
||||
defaultWidth: number
|
||||
hoverable: boolean
|
||||
index: number
|
||||
minWidth: number
|
||||
onResizeFinish: () => ResizeFinishCallback
|
||||
panelId: string
|
||||
property: PanelSide
|
||||
}
|
||||
|
||||
class PanelResizerCtrl implements PanelResizerScope {
|
||||
|
||||
/** @scope */
|
||||
alwaysVisible!: boolean
|
||||
collapsable!: boolean
|
||||
control!: PanelPuppet
|
||||
defaultWidth!: number
|
||||
hoverable!: boolean
|
||||
index!: number
|
||||
minWidth!: number
|
||||
onResizeFinish!: () => ResizeFinishCallback
|
||||
panelId!: string
|
||||
property!: PanelSide
|
||||
|
||||
$compile: ng.ICompileService
|
||||
$element: JQLite
|
||||
$timeout: ng.ITimeoutService
|
||||
panel!: HTMLElement
|
||||
resizerColumn!: HTMLElement
|
||||
currentMinWidth = 0
|
||||
pressed = false
|
||||
startWidth = 0
|
||||
lastDownX = 0
|
||||
collapsed = false
|
||||
lastWidth = 0
|
||||
startLeft = 0
|
||||
lastLeft = 0
|
||||
appFrame?: DOMRect
|
||||
widthBeforeLastDblClick = 0
|
||||
overlay?: JQLite
|
||||
|
||||
class PanelResizerCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$compile,
|
||||
$element,
|
||||
$scope,
|
||||
$timeout,
|
||||
$compile: ng.ICompileService,
|
||||
$element: JQLite,
|
||||
$timeout: ng.ITimeoutService,
|
||||
) {
|
||||
this.$compile = $compile;
|
||||
this.$element = $element;
|
||||
this.$scope = $scope;
|
||||
this.$timeout = $timeout;
|
||||
|
||||
/** To allow for registering events */
|
||||
this.handleResize = debounce(this.handleResize.bind(this), 250);
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.configureControl();
|
||||
this.configureDefaults();
|
||||
this.addDoubleClickHandler();
|
||||
this.reloadDefaultValues();
|
||||
this.addMouseDownListener();
|
||||
this.addMouseMoveListener();
|
||||
this.addMouseUpListener();
|
||||
this.configureControl();
|
||||
this.addDoubleClickHandler();
|
||||
this.resizerColumn.addEventListener(MouseEventType.Down, this.onMouseDown);
|
||||
document.addEventListener(MouseEventType.Move, this.onMouseMove);
|
||||
document.addEventListener(MouseEventType.Up, this.onMouseUp);
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
(this.onResizeFinish as any) = undefined;
|
||||
(this.control as any) = undefined;
|
||||
window.removeEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
|
||||
document.removeEventListener(MouseEventType.Move, this.onMouseMove);
|
||||
document.removeEventListener(MouseEventType.Up, this.onMouseUp);
|
||||
this.resizerColumn.removeEventListener(MouseEventType.Down, this.onMouseDown);
|
||||
(this.handleResize as any) = undefined;
|
||||
(this.onMouseMove as any) = undefined;
|
||||
(this.onMouseUp as any) = undefined;
|
||||
(this.onMouseDown as any) = undefined;
|
||||
}
|
||||
|
||||
configureControl() {
|
||||
this.control.setWidth = (value) => {
|
||||
this.setWidth(value, true);
|
||||
};
|
||||
|
||||
this.control.setLeft = (value) => {
|
||||
this.setLeft(value);
|
||||
};
|
||||
|
||||
this.control.flash = () => {
|
||||
this.flash();
|
||||
};
|
||||
|
||||
this.control.isCollapsed = () => {
|
||||
return this.isCollapsed();
|
||||
};
|
||||
this.control.ready = true;
|
||||
this.control.onReady!();
|
||||
}
|
||||
|
||||
configureDefaults() {
|
||||
this.panel = document.getElementById(this.panelId);
|
||||
this.panel = document.getElementById(this.panelId)!;
|
||||
if (!this.panel) {
|
||||
console.error('Panel not found for', this.panelId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizerColumn = this.$element[0];
|
||||
this.currentMinWidth = this.minWidth || this.resizerColumn.offsetWidth;
|
||||
this.pressed = false;
|
||||
|
|
@ -75,36 +145,34 @@ class PanelResizerCtrl {
|
|||
this.lastWidth = this.startWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.lastLeft = this.startLeft;
|
||||
this.appFrame = null;
|
||||
this.appFrame = undefined;
|
||||
this.widthBeforeLastDblClick = 0;
|
||||
|
||||
if (this.property === PANEL_SIDE_RIGHT) {
|
||||
if (this.property === PanelSide.Right) {
|
||||
this.configureRightPanel();
|
||||
}
|
||||
if (this.alwaysVisible) {
|
||||
this.resizerColumn.classList.add(PANEL_CSS_CLASS_ALWAYS_VISIBLE);
|
||||
this.resizerColumn.classList.add(CssClass.AlwaysVisible);
|
||||
}
|
||||
if (this.hoverable) {
|
||||
this.resizerColumn.classList.add(PANEL_CSS_CLASS_HOVERABLE);
|
||||
this.resizerColumn.classList.add(CssClass.Hoverable);
|
||||
}
|
||||
}
|
||||
|
||||
configureRightPanel() {
|
||||
const handleResize = debounce(event => {
|
||||
this.reloadDefaultValues();
|
||||
this.handleWidthEvent();
|
||||
this.$timeout(() => {
|
||||
this.finishSettingWidth();
|
||||
});
|
||||
}, 250);
|
||||
window.addEventListener(WINDOW_EVENT_RESIZE, handleResize);
|
||||
this.$scope.$on('$destroy', () => {
|
||||
window.removeEventListener(WINDOW_EVENT_RESIZE, handleResize);
|
||||
window.addEventListener(WINDOW_EVENT_RESIZE, this.handleResize);
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
this.reloadDefaultValues();
|
||||
this.handleWidthEvent();
|
||||
this.$timeout(() => {
|
||||
this.finishSettingWidth();
|
||||
});
|
||||
}
|
||||
|
||||
getParentRect() {
|
||||
return this.panel.parentNode.getBoundingClientRect();
|
||||
const node = this.panel!.parentNode! as HTMLElement;
|
||||
return node.getBoundingClientRect();
|
||||
}
|
||||
|
||||
reloadDefaultValues() {
|
||||
|
|
@ -112,7 +180,7 @@ class PanelResizerCtrl {
|
|||
? this.getParentRect().width
|
||||
: this.panel.scrollWidth;
|
||||
this.lastWidth = this.startWidth;
|
||||
this.appFrame = document.getElementById('app').getBoundingClientRect();
|
||||
this.appFrame = document.getElementById('app')!.getBoundingClientRect();
|
||||
}
|
||||
|
||||
addDoubleClickHandler() {
|
||||
|
|
@ -125,9 +193,7 @@ class PanelResizerCtrl {
|
|||
this.widthBeforeLastDblClick = this.lastWidth;
|
||||
this.setWidth(this.currentMinWidth);
|
||||
}
|
||||
|
||||
this.finishSettingWidth();
|
||||
|
||||
const newCollapseState = !preClickCollapseState;
|
||||
this.onResizeFinish()(
|
||||
this.lastWidth,
|
||||
|
|
@ -139,53 +205,65 @@ class PanelResizerCtrl {
|
|||
};
|
||||
}
|
||||
|
||||
addMouseDownListener() {
|
||||
this.resizerColumn.addEventListener(MOUSE_EVENT_DOWN, (event) => {
|
||||
this.addInvisibleOverlay();
|
||||
this.pressed = true;
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.panel.classList.add(PANEL_CSS_CLASS_NO_SELECTION);
|
||||
if (this.hoverable) {
|
||||
this.resizerColumn.classList.add(PANEL_CSS_CLASS_DRAGGING);
|
||||
}
|
||||
});
|
||||
onMouseDown(event: MouseEvent) {
|
||||
this.addInvisibleOverlay();
|
||||
this.pressed = true;
|
||||
this.lastDownX = event.clientX;
|
||||
this.startWidth = this.panel.scrollWidth;
|
||||
this.startLeft = this.panel.offsetLeft;
|
||||
this.panel.classList.add(CssClass.NoSelection);
|
||||
if (this.hoverable) {
|
||||
this.resizerColumn.classList.add(CssClass.Dragging);
|
||||
}
|
||||
}
|
||||
|
||||
addMouseMoveListener() {
|
||||
document.addEventListener(MOUSE_EVENT_MOVE, (event) => {
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.property && this.property === PANEL_SIDE_LEFT) {
|
||||
this.handleLeftEvent(event);
|
||||
} else {
|
||||
this.handleWidthEvent(event);
|
||||
}
|
||||
});
|
||||
onMouseUp() {
|
||||
this.removeInvisibleOverlay();
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
this.pressed = false;
|
||||
this.resizerColumn.classList.remove(CssClass.Dragging);
|
||||
this.panel.classList.remove(CssClass.NoSelection);
|
||||
const isMaxWidth = this.isAtMaxWidth();
|
||||
if (this.onResizeFinish) {
|
||||
this.onResizeFinish()(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
isMaxWidth,
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
}
|
||||
|
||||
handleWidthEvent(event) {
|
||||
onMouseMove(event: MouseEvent) {
|
||||
if (!this.pressed) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (this.property && this.property === PanelSide.Left) {
|
||||
this.handleLeftEvent(event);
|
||||
} else {
|
||||
this.handleWidthEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
handleWidthEvent(event?: MouseEvent) {
|
||||
let x;
|
||||
if (event) {
|
||||
x = event.clientX;
|
||||
x = event!.clientX;
|
||||
} else {
|
||||
/** Coming from resize event */
|
||||
x = 0;
|
||||
this.lastDownX = 0;
|
||||
}
|
||||
|
||||
const deltaX = x - this.lastDownX;
|
||||
const newWidth = this.startWidth + deltaX;
|
||||
this.setWidth(newWidth, false);
|
||||
if (this.onResize()) {
|
||||
this.onResize()(this.lastWidth, this.panel);
|
||||
}
|
||||
}
|
||||
|
||||
handleLeftEvent(event) {
|
||||
handleLeftEvent(event: MouseEvent) {
|
||||
const panelRect = this.panel.getBoundingClientRect();
|
||||
const x = event.clientX || panelRect.x;
|
||||
let deltaX = x - this.lastDownX;
|
||||
|
|
@ -205,34 +283,13 @@ class PanelResizerCtrl {
|
|||
if (newLeft + newWidth > parentRect.width) {
|
||||
newLeft = parentRect.width - newWidth;
|
||||
}
|
||||
this.setLeft(newLeft, false);
|
||||
this.setLeft(newLeft);
|
||||
this.setWidth(newWidth, false);
|
||||
}
|
||||
|
||||
addMouseUpListener() {
|
||||
document.addEventListener(MOUSE_EVENT_UP, event => {
|
||||
this.removeInvisibleOverlay();
|
||||
if (this.pressed) {
|
||||
this.pressed = false;
|
||||
this.resizerColumn.classList.remove(PANEL_CSS_CLASS_DRAGGING);
|
||||
this.panel.classList.remove(PANEL_CSS_CLASS_NO_SELECTION);
|
||||
const isMaxWidth = this.isAtMaxWidth();
|
||||
if (this.onResizeFinish) {
|
||||
this.onResizeFinish()(
|
||||
this.lastWidth,
|
||||
this.lastLeft,
|
||||
isMaxWidth,
|
||||
this.isCollapsed()
|
||||
);
|
||||
}
|
||||
this.finishSettingWidth();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isAtMaxWidth() {
|
||||
return (
|
||||
Math.round(this.lastWidth + this.lastLeft) ===
|
||||
Math.round(this.lastWidth + this.lastLeft) ===
|
||||
Math.round(this.getParentRect().width)
|
||||
);
|
||||
}
|
||||
|
|
@ -241,7 +298,7 @@ class PanelResizerCtrl {
|
|||
return this.lastWidth <= this.currentMinWidth;
|
||||
}
|
||||
|
||||
setWidth(width, finish) {
|
||||
setWidth(width: number, finish = false) {
|
||||
if (width < this.currentMinWidth) {
|
||||
width = this.currentMinWidth;
|
||||
}
|
||||
|
|
@ -250,7 +307,7 @@ class PanelResizerCtrl {
|
|||
width = parentRect.width;
|
||||
}
|
||||
|
||||
const maxWidth = this.appFrame.width - this.panel.getBoundingClientRect().x;
|
||||
const maxWidth = this.appFrame!.width - this.panel.getBoundingClientRect().x;
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
}
|
||||
|
|
@ -267,7 +324,7 @@ class PanelResizerCtrl {
|
|||
}
|
||||
}
|
||||
|
||||
setLeft(left) {
|
||||
setLeft(left: number) {
|
||||
this.panel.style.left = left + 'px';
|
||||
this.lastLeft = left;
|
||||
}
|
||||
|
|
@ -279,9 +336,9 @@ class PanelResizerCtrl {
|
|||
|
||||
this.collapsed = this.isCollapsed();
|
||||
if (this.collapsed) {
|
||||
this.resizerColumn.classList.add(PANEL_CSS_CLASS_COLLAPSED);
|
||||
this.resizerColumn.classList.add(CssClass.Collapsed);
|
||||
} else {
|
||||
this.resizerColumn.classList.remove(PANEL_CSS_CLASS_COLLAPSED);
|
||||
this.resizerColumn.classList.remove(CssClass.Collapsed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,28 +352,29 @@ class PanelResizerCtrl {
|
|||
if (this.overlay) {
|
||||
return;
|
||||
}
|
||||
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(this.$scope);
|
||||
this.overlay = this.$compile(`<div id='resizer-overlay'></div>`)(this as any);
|
||||
angular.element(document.body).prepend(this.overlay);
|
||||
}
|
||||
|
||||
removeInvisibleOverlay() {
|
||||
if (this.overlay) {
|
||||
this.overlay.remove();
|
||||
this.overlay = null;
|
||||
this.overlay = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
flash() {
|
||||
const FLASH_DURATION = 3000;
|
||||
this.resizerColumn.classList.add(PANEL_CSS_CLASS_ANIMATE_OPACITY);
|
||||
this.resizerColumn.classList.add(CssClass.AnimateOpacity);
|
||||
this.$timeout(() => {
|
||||
this.resizerColumn.classList.remove(PANEL_CSS_CLASS_ANIMATE_OPACITY);
|
||||
this.resizerColumn.classList.remove(CssClass.AnimateOpacity);
|
||||
}, FLASH_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelResizer {
|
||||
export class PanelResizer extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PanelResizerCtrl;
|
||||
|
|
@ -330,7 +388,6 @@ export class PanelResizer {
|
|||
hoverable: '=',
|
||||
index: '=',
|
||||
minWidth: '=',
|
||||
onResize: '&',
|
||||
onResizeFinish: '&',
|
||||
panelId: '=',
|
||||
property: '='
|
||||
|
|
@ -1,302 +0,0 @@
|
|||
import { protocolManager } from 'snjs';
|
||||
import template from '%/directives/password-wizard.pug';
|
||||
import { STRING_FAILED_PASSWORD_CHANGE } from '@/strings';
|
||||
|
||||
const DEFAULT_CONTINUE_TITLE = "Continue";
|
||||
const Steps = {
|
||||
IntroStep: 0,
|
||||
BackupStep: 1,
|
||||
SignoutStep: 2,
|
||||
PasswordStep: 3,
|
||||
SyncStep: 4,
|
||||
FinishStep: 5
|
||||
};
|
||||
|
||||
class PasswordWizardCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element,
|
||||
$scope,
|
||||
$timeout,
|
||||
alertManager,
|
||||
archiveManager,
|
||||
authManager,
|
||||
modelManager,
|
||||
syncManager,
|
||||
) {
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
this.$scope = $scope;
|
||||
this.alertManager = alertManager;
|
||||
this.archiveManager = archiveManager;
|
||||
this.authManager = authManager;
|
||||
this.modelManager = modelManager;
|
||||
this.syncManager = syncManager;
|
||||
this.registerWindowUnloadStopper();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.syncStatus = this.syncManager.syncStatus;
|
||||
this.formData = {};
|
||||
this.configureDefaults();
|
||||
}
|
||||
|
||||
configureDefaults() {
|
||||
if (this.type === 'change-pw') {
|
||||
this.title = "Change Password";
|
||||
this.changePassword = true;
|
||||
} else if (this.type === 'upgrade-security') {
|
||||
this.title = "Security Update";
|
||||
this.securityUpdate = true;
|
||||
}
|
||||
this.continueTitle = DEFAULT_CONTINUE_TITLE;
|
||||
this.step = Steps.IntroStep;
|
||||
}
|
||||
|
||||
/** Confirms with user before closing tab */
|
||||
registerWindowUnloadStopper() {
|
||||
window.onbeforeunload = (e) => {
|
||||
return true;
|
||||
};
|
||||
this.$scope.$on("$destroy", () => {
|
||||
window.onbeforeunload = null;
|
||||
});
|
||||
}
|
||||
|
||||
titleForStep(step) {
|
||||
switch (step) {
|
||||
case Steps.BackupStep:
|
||||
return "Download a backup of your data";
|
||||
case Steps.SignoutStep:
|
||||
return "Sign out of all your devices";
|
||||
case Steps.PasswordStep:
|
||||
return this.changePassword
|
||||
? "Password information"
|
||||
: "Enter your current password";
|
||||
case Steps.SyncStep:
|
||||
return "Encrypt and sync data with new keys";
|
||||
case Steps.FinishStep:
|
||||
return "Sign back in to your devices";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async nextStep() {
|
||||
if (this.lockContinue || this.isContinuing) {
|
||||
return;
|
||||
}
|
||||
this.isContinuing = true;
|
||||
if (this.step === Steps.FinishStep) {
|
||||
this.dismiss();
|
||||
return;
|
||||
}
|
||||
const next = () => {
|
||||
this.step++;
|
||||
this.initializeStep(this.step);
|
||||
this.isContinuing = false;
|
||||
};
|
||||
const preprocessor = this.preprocessorForStep(this.step);
|
||||
if (preprocessor) {
|
||||
await preprocessor().then((success) => {
|
||||
if(success) {
|
||||
next();
|
||||
} else {
|
||||
this.$timeout(() => {
|
||||
this.isContinuing = false;
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
this.isContinuing = false;
|
||||
});
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
preprocessorForStep(step) {
|
||||
if (step === Steps.PasswordStep) {
|
||||
return async () => {
|
||||
this.showSpinner = true;
|
||||
this.continueTitle = "Generating Keys...";
|
||||
const success = await this.validateCurrentPassword();
|
||||
this.showSpinner = false;
|
||||
this.continueTitle = DEFAULT_CONTINUE_TITLE;
|
||||
return success;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async initializeStep(step) {
|
||||
if (step === Steps.SyncStep) {
|
||||
await this.initializeSyncingStep();
|
||||
} else if (step === Steps.FinishStep) {
|
||||
this.continueTitle = "Finish";
|
||||
}
|
||||
}
|
||||
|
||||
async initializeSyncingStep() {
|
||||
this.lockContinue = true;
|
||||
this.formData.status = "Processing encryption keys...";
|
||||
this.formData.processing = true;
|
||||
|
||||
const passwordSuccess = await this.processPasswordChange();
|
||||
this.formData.statusError = !passwordSuccess;
|
||||
this.formData.processing = passwordSuccess;
|
||||
if(!passwordSuccess) {
|
||||
this.formData.status = "Unable to process your password. Please try again.";
|
||||
return;
|
||||
}
|
||||
this.formData.status = "Encrypting and syncing data with new keys...";
|
||||
|
||||
const syncSuccess = await this.resyncData();
|
||||
this.formData.statusError = !syncSuccess;
|
||||
this.formData.processing = !syncSuccess;
|
||||
if (syncSuccess) {
|
||||
this.lockContinue = false;
|
||||
if (this.changePassword) {
|
||||
this.formData.status = "Successfully changed password and synced all items.";
|
||||
} else if (this.securityUpdate) {
|
||||
this.formData.status = "Successfully performed security update and synced all items.";
|
||||
}
|
||||
} else {
|
||||
this.formData.status = STRING_FAILED_PASSWORD_CHANGE;
|
||||
}
|
||||
}
|
||||
|
||||
async validateCurrentPassword() {
|
||||
const currentPassword = this.formData.currentPassword;
|
||||
const newPass = this.securityUpdate ? currentPassword : this.formData.newPassword;
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
this.alertManager.alert({
|
||||
text: "Please enter your current password."
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (this.changePassword) {
|
||||
if (!newPass || newPass.length === 0) {
|
||||
this.alertManager.alert({
|
||||
text: "Please enter a new password."
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (newPass !== this.formData.newPasswordConfirmation) {
|
||||
this.alertManager.alert({
|
||||
text: "Your new password does not match its confirmation."
|
||||
});
|
||||
this.formData.status = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.authManager.user.email) {
|
||||
this.alertManager.alert({
|
||||
text: "We don't have your email stored. Please log out then log back in to fix this issue."
|
||||
});
|
||||
this.formData.status = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const minLength = this.authManager.getMinPasswordLength();
|
||||
if (!this.securityUpdate && newPass.length < minLength) {
|
||||
const message = `Your password must be at least ${minLength} characters in length. For your security, please choose a longer password or, ideally, a passphrase, and try again.`;
|
||||
this.alertManager.alert({
|
||||
text: message
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate current password */
|
||||
const authParams = await this.authManager.getAuthParams();
|
||||
const password = this.formData.currentPassword;
|
||||
const keys = await protocolManager.computeEncryptionKeysForUser(
|
||||
password,
|
||||
authParams
|
||||
);
|
||||
const success = keys.mk === (await this.authManager.keys()).mk;
|
||||
if (success) {
|
||||
this.currentServerPw = keys.pw;
|
||||
} else {
|
||||
this.alertManager.alert({
|
||||
text: "The current password you entered is not correct. Please try again."
|
||||
});
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async resyncData() {
|
||||
await this.modelManager.setAllItemsDirty();
|
||||
const response = await this.syncManager.sync();
|
||||
if (!response || response.error) {
|
||||
this.alertManager.alert({
|
||||
text: STRING_FAILED_PASSWORD_CHANGE
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async processPasswordChange() {
|
||||
const newUserPassword = this.securityUpdate
|
||||
? this.formData.currentPassword
|
||||
: this.formData.newPassword;
|
||||
|
||||
const currentServerPw = this.currentServerPw;
|
||||
const results = await protocolManager.generateInitialKeysAndAuthParamsForUser(
|
||||
this.authManager.user.email,
|
||||
newUserPassword
|
||||
);
|
||||
const newKeys = results.keys;
|
||||
const newAuthParams = results.authParams;
|
||||
/**
|
||||
* Perform a sync beforehand to pull in any last minutes changes before we change
|
||||
* the encryption key (and thus cant decrypt new changes).
|
||||
*/
|
||||
await this.syncManager.sync();
|
||||
const response = await this.authManager.changePassword(
|
||||
await this.syncManager.getServerURL(),
|
||||
this.authManager.user.email,
|
||||
currentServerPw,
|
||||
newKeys,
|
||||
newAuthParams
|
||||
);
|
||||
if (response.error) {
|
||||
this.alertManager.alert({
|
||||
text: response.error.message
|
||||
? response.error.message
|
||||
: "There was an error changing your password. Please try again."
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
downloadBackup(encrypted) {
|
||||
this.archiveManager.downloadBackup(encrypted);
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
if (this.lockContinue) {
|
||||
this.alertManager.alert({
|
||||
text: "Cannot close window until pending tasks are complete."
|
||||
});
|
||||
} else {
|
||||
this.$element.remove();
|
||||
this.$scope.$destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordWizard {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PasswordWizardCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
239
app/assets/javascripts/directives/views/passwordWizard.ts
Normal file
239
app/assets/javascripts/directives/views/passwordWizard.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { WebApplication } from '@/ui_models/application';
|
||||
import { PasswordWizardScope, PasswordWizardType, WebDirective } from './../../types';
|
||||
import template from '%/directives/password-wizard.pug';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
|
||||
const DEFAULT_CONTINUE_TITLE = "Continue";
|
||||
enum Steps {
|
||||
PasswordStep = 1,
|
||||
FinishStep = 2
|
||||
};
|
||||
|
||||
type FormData = {
|
||||
currentPassword?: string,
|
||||
newPassword?: string,
|
||||
newPasswordConfirmation?: string,
|
||||
status?: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
lockContinue: boolean
|
||||
formData: FormData,
|
||||
continueTitle: string,
|
||||
step: Steps,
|
||||
title: string,
|
||||
showSpinner: boolean
|
||||
processing: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
type: PasswordWizardType,
|
||||
changePassword: boolean,
|
||||
securityUpdate: boolean
|
||||
}
|
||||
|
||||
class PasswordWizardCtrl extends PureViewCtrl<Props, State> implements PasswordWizardScope {
|
||||
$element: JQLite
|
||||
application!: WebApplication
|
||||
type!: PasswordWizardType
|
||||
isContinuing = false
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element: JQLite,
|
||||
$timeout: ng.ITimeoutService,
|
||||
) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.registerWindowUnloadStopper();
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
super.$onInit();
|
||||
this.initProps({
|
||||
type: this.type,
|
||||
changePassword: this.type === PasswordWizardType.ChangePassword,
|
||||
securityUpdate: this.type === PasswordWizardType.AccountUpgrade
|
||||
});
|
||||
this.setState({
|
||||
formData: {},
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE,
|
||||
step: Steps.PasswordStep,
|
||||
title: this.props.changePassword ? 'Change Password' : 'Account Update'
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
super.$onDestroy();
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
|
||||
/** Confirms with user before closing tab */
|
||||
registerWindowUnloadStopper() {
|
||||
window.onbeforeunload = () => {
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
resetContinueState() {
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: DEFAULT_CONTINUE_TITLE
|
||||
});
|
||||
this.isContinuing = false;
|
||||
}
|
||||
|
||||
async nextStep() {
|
||||
if (this.state.lockContinue || this.isContinuing) {
|
||||
return;
|
||||
}
|
||||
if (this.state.step === Steps.FinishStep) {
|
||||
this.dismiss();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isContinuing = true;
|
||||
await this.setState({
|
||||
showSpinner: true,
|
||||
continueTitle: "Generating Keys..."
|
||||
});
|
||||
const valid = await this.validateCurrentPassword();
|
||||
if (!valid) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
const success = await this.processPasswordChange();
|
||||
if (!success) {
|
||||
this.resetContinueState();
|
||||
return;
|
||||
}
|
||||
this.isContinuing = false;
|
||||
this.setState({
|
||||
showSpinner: false,
|
||||
continueTitle: "Finish",
|
||||
step: Steps.FinishStep
|
||||
});
|
||||
}
|
||||
|
||||
async setFormDataState(formData: Partial<FormData>) {
|
||||
return this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
...formData
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async validateCurrentPassword() {
|
||||
const currentPassword = this.state.formData.currentPassword;
|
||||
const newPass = this.props.securityUpdate ? currentPassword : this.state.formData.newPassword;
|
||||
if (!currentPassword || currentPassword.length === 0) {
|
||||
this.application.alertService!.alert(
|
||||
"Please enter your current password."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (this.props.changePassword) {
|
||||
if (!newPass || newPass.length === 0) {
|
||||
this.application.alertService!.alert(
|
||||
"Please enter a new password."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (newPass !== this.state.formData.newPasswordConfirmation) {
|
||||
this.application.alertService!.alert(
|
||||
"Your new password does not match its confirmation."
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.application.getUser()?.email) {
|
||||
this.application.alertService!.alert(
|
||||
"We don't have your email stored. Please log out then log back in to fix this issue."
|
||||
);
|
||||
this.setFormDataState({
|
||||
status: undefined
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validate current password */
|
||||
const success = await this.application.validateAccountPassword(
|
||||
this.state.formData.currentPassword!
|
||||
);
|
||||
if (!success) {
|
||||
this.application.alertService!.alert(
|
||||
"The current password you entered is not correct. Please try again."
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
async processPasswordChange() {
|
||||
await this.setState({
|
||||
lockContinue: true,
|
||||
processing: true
|
||||
});
|
||||
await this.setFormDataState({
|
||||
status: "Processing encryption keys..."
|
||||
});
|
||||
const newPassword = this.props.securityUpdate
|
||||
? this.state.formData.currentPassword
|
||||
: this.state.formData.newPassword;
|
||||
const response = await this.application.changePassword(
|
||||
this.state.formData.currentPassword!,
|
||||
newPassword!
|
||||
);
|
||||
const success = !response.error;
|
||||
await this.setState({
|
||||
processing: false,
|
||||
lockContinue: false,
|
||||
});
|
||||
if (!success) {
|
||||
this.setFormDataState({
|
||||
status: "Unable to process your password. Please try again."
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
formData: {
|
||||
...this.state.formData,
|
||||
status: this.props.changePassword
|
||||
? "Successfully changed password."
|
||||
: "Successfully performed account update."
|
||||
}
|
||||
});
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
if (this.state.lockContinue) {
|
||||
this.application.alertService!.alert(
|
||||
"Cannot close window until pending tasks are complete."
|
||||
);
|
||||
} else {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordWizard extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PasswordWizardCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
type: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,21 @@
|
|||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/permissions-modal.pug';
|
||||
|
||||
class PermissionsModalCtrl {
|
||||
|
||||
$element: JQLite
|
||||
callback!: (success: boolean) => void
|
||||
|
||||
/* @ngInject */
|
||||
constructor($element) {
|
||||
constructor($element: JQLite) {
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.$element.remove();
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
|
||||
accept() {
|
||||
|
|
@ -21,8 +29,9 @@ class PermissionsModalCtrl {
|
|||
}
|
||||
}
|
||||
|
||||
export class PermissionsModal {
|
||||
export class PermissionsModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PermissionsModalCtrl;
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import template from '%/directives/privileges-auth-modal.pug';
|
||||
|
||||
class PrivilegesAuthModalCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element,
|
||||
$timeout,
|
||||
privilegesManager,
|
||||
) {
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
this.privilegesManager = privilegesManager;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.authParameters = {};
|
||||
this.sessionLengthOptions = this.privilegesManager.getSessionLengthOptions();
|
||||
this.privilegesManager.getSelectedSessionLength().then((length) => {
|
||||
this.$timeout(() => {
|
||||
this.selectedSessionLength = length;
|
||||
});
|
||||
});
|
||||
this.privilegesManager.netCredentialsForAction(this.action).then((credentials) => {
|
||||
this.$timeout(() => {
|
||||
this.requiredCredentials = credentials.sort();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectSessionLength(length) {
|
||||
this.selectedSessionLength = length;
|
||||
}
|
||||
|
||||
promptForCredential(credential) {
|
||||
return this.privilegesManager.displayInfoForCredential(credential).prompt;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dismiss();
|
||||
this.onCancel && this.onCancel();
|
||||
}
|
||||
|
||||
isCredentialInFailureState(credential) {
|
||||
if (!this.failedCredentials) {
|
||||
return false;
|
||||
}
|
||||
return this.failedCredentials.find((candidate) => {
|
||||
return candidate === credential;
|
||||
}) != null;
|
||||
}
|
||||
|
||||
validate() {
|
||||
const failed = [];
|
||||
for (const cred of this.requiredCredentials) {
|
||||
const value = this.authParameters[cred];
|
||||
if (!value || value.length === 0) {
|
||||
failed.push(cred);
|
||||
}
|
||||
}
|
||||
this.failedCredentials = failed;
|
||||
return failed.length === 0;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!this.validate()) {
|
||||
return;
|
||||
}
|
||||
const result = await this.privilegesManager.authenticateAction(
|
||||
this.action,
|
||||
this.authParameters
|
||||
);
|
||||
this.$timeout(() => {
|
||||
if (result.success) {
|
||||
this.privilegesManager.setSessionLength(this.selectedSessionLength);
|
||||
this.onSuccess();
|
||||
this.dismiss();
|
||||
} else {
|
||||
this.failedCredentials = result.failedCredentials;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.$element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivilegesAuthModal {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PrivilegesAuthModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
action: '=',
|
||||
onSuccess: '=',
|
||||
onCancel: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
128
app/assets/javascripts/directives/views/privilegesAuthModal.ts
Normal file
128
app/assets/javascripts/directives/views/privilegesAuthModal.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { WebDirective } from './../../types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { ProtectedAction, PrivilegeCredential, PrivilegeSessionLength } from 'snjs';
|
||||
import template from '%/directives/privileges-auth-modal.pug';
|
||||
|
||||
type PrivilegesAuthModalScope = {
|
||||
application: WebApplication
|
||||
action: ProtectedAction
|
||||
onSuccess: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
class PrivilegesAuthModalCtrl implements PrivilegesAuthModalScope {
|
||||
$element: JQLite
|
||||
$timeout: ng.ITimeoutService
|
||||
application!: WebApplication
|
||||
action!: ProtectedAction
|
||||
onSuccess!: () => void
|
||||
onCancel!: () => void
|
||||
authParameters: Partial<Record<PrivilegeCredential, string>> = {}
|
||||
sessionLengthOptions!: { value: PrivilegeSessionLength, label: string }[]
|
||||
selectedSessionLength!: PrivilegeSessionLength
|
||||
requiredCredentials!: PrivilegeCredential[]
|
||||
failedCredentials!: PrivilegeCredential[]
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element: JQLite,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.sessionLengthOptions = this.application!.privilegesService!
|
||||
.getSessionLengthOptions();
|
||||
this.application.privilegesService!.getSelectedSessionLength()
|
||||
.then((length) => {
|
||||
this.$timeout(() => {
|
||||
this.selectedSessionLength = length;
|
||||
});
|
||||
});
|
||||
this.application.privilegesService!.netCredentialsForAction(this.action)
|
||||
.then((credentials) => {
|
||||
this.$timeout(() => {
|
||||
this.requiredCredentials = credentials.sort();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
selectSessionLength(length: PrivilegeSessionLength) {
|
||||
this.selectedSessionLength = length;
|
||||
}
|
||||
|
||||
promptForCredential(credential: PrivilegeCredential) {
|
||||
return this.application.privilegesService!.displayInfoForCredential(credential).prompt;
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dismiss();
|
||||
this.onCancel && this.onCancel();
|
||||
}
|
||||
|
||||
isCredentialInFailureState(credential: PrivilegeCredential) {
|
||||
if (!this.failedCredentials) {
|
||||
return false;
|
||||
}
|
||||
return this.failedCredentials.find((candidate) => {
|
||||
return candidate === credential;
|
||||
}) != null;
|
||||
}
|
||||
|
||||
validate() {
|
||||
const failed = [];
|
||||
for (const cred of this.requiredCredentials) {
|
||||
const value = this.authParameters[cred];
|
||||
if (!value || value.length === 0) {
|
||||
failed.push(cred);
|
||||
}
|
||||
}
|
||||
this.failedCredentials = failed;
|
||||
return failed.length === 0;
|
||||
}
|
||||
|
||||
async submit() {
|
||||
if (!this.validate()) {
|
||||
return;
|
||||
}
|
||||
const result = await this.application.privilegesService!.authenticateAction(
|
||||
this.action,
|
||||
this.authParameters
|
||||
);
|
||||
this.$timeout(() => {
|
||||
if (result.success) {
|
||||
this.application.privilegesService!.setSessionLength(this.selectedSessionLength);
|
||||
this.onSuccess();
|
||||
this.dismiss();
|
||||
} else {
|
||||
this.failedCredentials = result.failedCredentials;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivilegesAuthModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PrivilegesAuthModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
action: '=',
|
||||
onSuccess: '=',
|
||||
onCancel: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import { PrivilegesManager } from '@/services/privilegesManager';
|
||||
import template from '%/directives/privileges-management-modal.pug';
|
||||
|
||||
class PrivilegesManagementModalCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout,
|
||||
$element,
|
||||
privilegesManager,
|
||||
authManager,
|
||||
passcodeManager,
|
||||
) {
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
this.privilegesManager = privilegesManager;
|
||||
this.hasPasscode = passcodeManager.hasPasscode();
|
||||
this.hasAccount = !authManager.offline();
|
||||
this.reloadPrivileges();
|
||||
}
|
||||
|
||||
displayInfoForCredential(credential) {
|
||||
const info = this.privilegesManager.displayInfoForCredential(credential);
|
||||
if (credential === PrivilegesManager.CredentialLocalPasscode) {
|
||||
info.availability = this.hasPasscode;
|
||||
} else if (credential === PrivilegesManager.CredentialAccountPassword) {
|
||||
info.availability = this.hasAccount;
|
||||
} else {
|
||||
info.availability = true;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
displayInfoForAction(action) {
|
||||
return this.privilegesManager.displayInfoForAction(action).label;
|
||||
}
|
||||
|
||||
isCredentialRequiredForAction(action, credential) {
|
||||
if (!this.privileges) {
|
||||
return false;
|
||||
}
|
||||
return this.privileges.isCredentialRequiredForAction(action, credential);
|
||||
}
|
||||
|
||||
async clearSession() {
|
||||
await this.privilegesManager.clearSession();
|
||||
this.reloadPrivileges();
|
||||
}
|
||||
|
||||
async reloadPrivileges() {
|
||||
this.availableActions = this.privilegesManager.getAvailableActions();
|
||||
this.availableCredentials = this.privilegesManager.getAvailableCredentials();
|
||||
const sessionEndDate = await this.privilegesManager.getSessionExpirey();
|
||||
this.sessionExpirey = sessionEndDate.toLocaleString();
|
||||
this.sessionExpired = new Date() >= sessionEndDate;
|
||||
this.credentialDisplayInfo = {};
|
||||
for (const cred of this.availableCredentials) {
|
||||
this.credentialDisplayInfo[cred] = this.displayInfoForCredential(cred);
|
||||
}
|
||||
const privs = await this.privilegesManager.getPrivileges();
|
||||
this.$timeout(() => {
|
||||
this.privileges = privs;
|
||||
});
|
||||
}
|
||||
|
||||
checkboxValueChanged(action, credential) {
|
||||
this.privileges.toggleCredentialForAction(action, credential);
|
||||
this.privilegesManager.savePrivileges();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dismiss();
|
||||
this.onCancel && this.onCancel();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.$element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivilegesManagementModal {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PrivilegesManagementModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { WebDirective } from './../../types';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import template from '%/directives/privileges-management-modal.pug';
|
||||
import { PrivilegeCredential, ProtectedAction, SNPrivileges, PrivilegeSessionLength } from 'snjs';
|
||||
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
|
||||
import { PrivilegeMutator } from 'snjs/dist/@types/models';
|
||||
|
||||
type DisplayInfo = {
|
||||
label: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
class PrivilegesManagementModalCtrl extends PureViewCtrl {
|
||||
|
||||
hasPasscode = false
|
||||
hasAccount = false
|
||||
$element: JQLite
|
||||
application!: WebApplication
|
||||
privileges!: SNPrivileges
|
||||
availableActions!: ProtectedAction[]
|
||||
availableCredentials!: PrivilegeCredential[]
|
||||
sessionExpirey!: string
|
||||
sessionExpired = true
|
||||
credentialDisplayInfo: Partial<Record<PrivilegeCredential, DisplayInfo>> = {}
|
||||
onCancel!: () => void
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout: ng.ITimeoutService,
|
||||
$element: JQLite
|
||||
) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
}
|
||||
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.hasPasscode = this.application.hasPasscode();
|
||||
this.hasAccount = !this.application.noAccount();
|
||||
this.reloadPrivileges();
|
||||
}
|
||||
|
||||
displayInfoForCredential(credential: PrivilegeCredential) {
|
||||
const info: any = this.application.privilegesService!.displayInfoForCredential(credential);
|
||||
if (credential === PrivilegeCredential.LocalPasscode) {
|
||||
info.availability = this.hasPasscode;
|
||||
} else if (credential === PrivilegeCredential.AccountPassword) {
|
||||
info.availability = this.hasAccount;
|
||||
} else {
|
||||
info.availability = true;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
displayInfoForAction(action: ProtectedAction) {
|
||||
return this.application.privilegesService!.displayInfoForAction(action).label;
|
||||
}
|
||||
|
||||
isCredentialRequiredForAction(action: ProtectedAction, credential: PrivilegeCredential) {
|
||||
if (!this.privileges) {
|
||||
return false;
|
||||
}
|
||||
return this.privileges.isCredentialRequiredForAction(action, credential);
|
||||
}
|
||||
|
||||
async clearSession() {
|
||||
await this.application.privilegesService!.clearSession();
|
||||
this.reloadPrivileges();
|
||||
}
|
||||
|
||||
async reloadPrivileges() {
|
||||
this.availableActions = this.application.privilegesService!.getAvailableActions();
|
||||
this.availableCredentials = this.application.privilegesService!.getAvailableCredentials();
|
||||
const sessionEndDate = await this.application.privilegesService!.getSessionExpirey();
|
||||
this.sessionExpirey = sessionEndDate.toLocaleString();
|
||||
this.sessionExpired = new Date() >= sessionEndDate;
|
||||
for (const cred of this.availableCredentials) {
|
||||
this.credentialDisplayInfo[cred] = this.displayInfoForCredential(cred);
|
||||
}
|
||||
const privs = await this.application.privilegesService!.getPrivileges();
|
||||
this.$timeout(() => {
|
||||
this.privileges = privs;
|
||||
});
|
||||
}
|
||||
|
||||
checkboxValueChanged(action: ProtectedAction, credential: PrivilegeCredential) {
|
||||
this.application.changeAndSaveItem(this.privileges.uuid, (m) => {
|
||||
const mutator = m as PrivilegeMutator;
|
||||
mutator.toggleCredentialForAction(action, credential);
|
||||
})
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.dismiss();
|
||||
this.onCancel && this.onCancel();
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivilegesManagementModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = PrivilegesManagementModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { protocolManager, SNComponent, SFItem, SFModelManager } from 'snjs';
|
||||
import template from '%/directives/revision-preview-modal.pug';
|
||||
|
||||
class RevisionPreviewModalCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element,
|
||||
$scope,
|
||||
$timeout,
|
||||
alertManager,
|
||||
componentManager,
|
||||
modelManager,
|
||||
syncManager,
|
||||
) {
|
||||
this.$element = $element;
|
||||
this.$scope = $scope;
|
||||
this.$timeout = $timeout;
|
||||
this.alertManager = alertManager;
|
||||
this.componentManager = componentManager;
|
||||
this.modelManager = modelManager;
|
||||
this.syncManager = syncManager;
|
||||
this.createNote();
|
||||
this.configureEditor();
|
||||
$scope.$on('$destroy', () => {
|
||||
if (this.identifier) {
|
||||
this.componentManager.deregisterHandler(this.identifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createNote() {
|
||||
this.note = new SFItem({
|
||||
content: this.content,
|
||||
content_type: "Note"
|
||||
});
|
||||
}
|
||||
|
||||
configureEditor() {
|
||||
/**
|
||||
* Set UUID so editoForNote can find proper editor, but then generate new uuid
|
||||
* for note as not to save changes to original, if editor makes changes.
|
||||
*/
|
||||
this.note.uuid = this.uuid;
|
||||
const editorForNote = this.componentManager.editorForNote(this.note);
|
||||
this.note.uuid = protocolManager.crypto.generateUUIDSync();
|
||||
if (editorForNote) {
|
||||
/**
|
||||
* Create temporary copy, as a lot of componentManager is uuid based, so might
|
||||
* interfere with active editor. Be sure to copy only the content, as the top level
|
||||
* editor object has non-copyable properties like .window, which cannot be transfered
|
||||
*/
|
||||
const editorCopy = new SNComponent({
|
||||
content: editorForNote.content
|
||||
});
|
||||
editorCopy.readonly = true;
|
||||
editorCopy.lockReadonly = true;
|
||||
this.identifier = editorCopy.uuid;
|
||||
this.componentManager.registerHandler({
|
||||
identifier: this.identifier,
|
||||
areas: ['editor-editor'],
|
||||
contextRequestHandler: (component) => {
|
||||
if (component === this.editor) {
|
||||
return this.note;
|
||||
}
|
||||
},
|
||||
componentForSessionKeyHandler: (key) => {
|
||||
if (key === this.editor.sessionKey) {
|
||||
return this.editor;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.editor = editorCopy;
|
||||
}
|
||||
}
|
||||
|
||||
restore(asCopy) {
|
||||
const run = () => {
|
||||
let item;
|
||||
if (asCopy) {
|
||||
const contentCopy = Object.assign({}, this.content);
|
||||
if (contentCopy.title) {
|
||||
contentCopy.title += " (copy)";
|
||||
}
|
||||
item = this.modelManager.createItem({
|
||||
content_type: 'Note',
|
||||
content: contentCopy
|
||||
});
|
||||
this.modelManager.addItem(item);
|
||||
} else {
|
||||
const uuid = this.uuid;
|
||||
item = this.modelManager.findItem(uuid);
|
||||
item.content = Object.assign({}, this.content);
|
||||
this.modelManager.mapResponseItemsToLocalModels(
|
||||
[item],
|
||||
SFModelManager.MappingSourceRemoteActionRetrieved
|
||||
);
|
||||
}
|
||||
this.modelManager.setItemDirty(item);
|
||||
this.syncManager.sync();
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
if (!asCopy) {
|
||||
this.alertManager.confirm({
|
||||
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
destructive: true,
|
||||
onConfirm: run
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.$element.remove();
|
||||
this.$scope.$destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export class RevisionPreviewModal {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = RevisionPreviewModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
uuid: '=',
|
||||
content: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
145
app/assets/javascripts/directives/views/revisionPreviewModal.ts
Normal file
145
app/assets/javascripts/directives/views/revisionPreviewModal.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { PureViewCtrl } from './../../views/abstract/pure_view_ctrl';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import {
|
||||
ContentType,
|
||||
PayloadSource,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
ComponentArea
|
||||
} from 'snjs';
|
||||
import template from '%/directives/revision-preview-modal.pug';
|
||||
import { PayloadContent } from 'snjs/dist/@types/protocol/payloads/generator';
|
||||
import { confirmDialog } from '@/services/alertService';
|
||||
|
||||
interface RevisionPreviewScope {
|
||||
uuid: string
|
||||
content: PayloadContent
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
class RevisionPreviewModalCtrl extends PureViewCtrl implements RevisionPreviewScope {
|
||||
|
||||
$element: JQLite
|
||||
$timeout: ng.ITimeoutService
|
||||
uuid!: string
|
||||
content!: PayloadContent
|
||||
application!: WebApplication
|
||||
unregisterComponent?: any
|
||||
note!: SNNote
|
||||
private originalNote!: SNNote;
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$element: JQLite,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
super($timeout);
|
||||
this.$element = $element;
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.configure();
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
if (this.unregisterComponent) {
|
||||
this.unregisterComponent();
|
||||
this.unregisterComponent = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
get componentManager() {
|
||||
return this.application.componentManager!;
|
||||
}
|
||||
|
||||
async configure() {
|
||||
this.note = await this.application.createTemplateItem(
|
||||
ContentType.Note,
|
||||
this.content
|
||||
) as SNNote;
|
||||
this.originalNote = this.application.findItem(this.uuid) as SNNote;
|
||||
const editorForNote = this.componentManager.editorForNote(this.originalNote);
|
||||
if (editorForNote) {
|
||||
/**
|
||||
* Create temporary copy, as a lot of componentManager is uuid based, so might
|
||||
* interfere with active editor. Be sure to copy only the content, as the top level
|
||||
* editor object has non-copyable properties like .window, which cannot be transfered
|
||||
*/
|
||||
const editorCopy = await this.application.createTemplateItem(
|
||||
ContentType.Component,
|
||||
editorForNote.safeContent
|
||||
) as SNComponent;
|
||||
this.componentManager.setReadonlyStateForComponent(editorCopy, true, true);
|
||||
this.unregisterComponent = this.componentManager.registerHandler({
|
||||
identifier: editorCopy.uuid,
|
||||
areas: [ComponentArea.Editor],
|
||||
contextRequestHandler: (componentUuid) => {
|
||||
if (componentUuid === this.state.editor?.uuid) {
|
||||
return this.note;
|
||||
}
|
||||
},
|
||||
componentForSessionKeyHandler: (key) => {
|
||||
if (key === this.componentManager.sessionKeyForComponent(this.state.editor!)) {
|
||||
return this.state.editor;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setState({editor: editorCopy});
|
||||
}
|
||||
}
|
||||
|
||||
restore(asCopy: boolean) {
|
||||
const run = async () => {
|
||||
if (asCopy) {
|
||||
await this.application.duplicateItem(this.originalNote, {
|
||||
...this.content,
|
||||
title: this.content.title ? this.content.title + ' (copy)' : undefined
|
||||
});
|
||||
} else {
|
||||
this.application.changeAndSaveItem(this.uuid, (mutator) => {
|
||||
mutator.setContent(this.content);
|
||||
}, true, PayloadSource.RemoteActionRetrieved);
|
||||
}
|
||||
this.dismiss();
|
||||
};
|
||||
|
||||
if (!asCopy) {
|
||||
confirmDialog({
|
||||
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
|
||||
confirmButtonStyle: "danger"
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
const elem = this.$element;
|
||||
const scope = elem.scope();
|
||||
scope.$destroy();
|
||||
elem.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export class RevisionPreviewModal extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = RevisionPreviewModalCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
uuid: '=',
|
||||
content: '=',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import template from '%/directives/session-history-menu.pug';
|
||||
|
||||
class SessionHistoryMenuCtrl {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout,
|
||||
actionsManager,
|
||||
alertManager,
|
||||
sessionHistory,
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
this.alertManager = alertManager;
|
||||
this.actionsManager = actionsManager;
|
||||
this.sessionHistory = sessionHistory;
|
||||
this.diskEnabled = this.sessionHistory.diskEnabled;
|
||||
this.autoOptimize = this.sessionHistory.autoOptimize;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
this.reloadHistory();
|
||||
}
|
||||
|
||||
reloadHistory() {
|
||||
const history = this.sessionHistory.historyForItem(this.item);
|
||||
this.entries = history.entries.slice(0).sort((a, b) => {
|
||||
return a.item.updated_at < b.item.updated_at ? 1 : -1;
|
||||
});
|
||||
this.history = history;
|
||||
}
|
||||
|
||||
openRevision(revision) {
|
||||
this.actionsManager.presentRevisionPreviewModal(
|
||||
revision.item.uuid,
|
||||
revision.item.content
|
||||
);
|
||||
}
|
||||
|
||||
classForRevision(revision) {
|
||||
const vector = revision.operationVector();
|
||||
if (vector === 0) {
|
||||
return 'default';
|
||||
} else if (vector === 1) {
|
||||
return 'success';
|
||||
} else if (vector === -1) {
|
||||
return 'danger';
|
||||
}
|
||||
}
|
||||
|
||||
clearItemHistory() {
|
||||
this.alertManager.confirm({
|
||||
text: "Are you sure you want to delete the local session history for this note?",
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
this.sessionHistory.clearHistoryForItem(this.item).then(() => {
|
||||
this.$timeout(() => {
|
||||
this.reloadHistory();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearAllHistory() {
|
||||
this.alertManager.confirm({
|
||||
text: "Are you sure you want to delete the local session history for all notes?",
|
||||
destructive: true,
|
||||
onConfirm: () => {
|
||||
this.sessionHistory.clearAllHistory().then(() => {
|
||||
this.$timeout(() => {
|
||||
this.reloadHistory();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleDiskSaving() {
|
||||
const run = () => {
|
||||
this.sessionHistory.toggleDiskSaving().then(() => {
|
||||
this.$timeout(() => {
|
||||
this.diskEnabled = this.sessionHistory.diskEnabled;
|
||||
});
|
||||
});
|
||||
};
|
||||
if (!this.sessionHistory.diskEnabled) {
|
||||
this.alertManager.confirm({
|
||||
text: `Are you sure you want to save history to disk? This will decrease general
|
||||
performance, especially as you type. You are advised to disable this feature
|
||||
if you experience any lagging.`,
|
||||
destructive: true,
|
||||
onConfirm: run
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
toggleAutoOptimize() {
|
||||
this.sessionHistory.toggleAutoOptimize().then(() => {
|
||||
this.$timeout(() => {
|
||||
this.autoOptimize = this.sessionHistory.autoOptimize;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SessionHistoryMenu {
|
||||
constructor() {
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = SessionHistoryMenuCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
item: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +1,30 @@
|
|||
import { WebApplication } from '@/ui_models/application';
|
||||
import { WebDirective } from './../../types';
|
||||
import template from '%/directives/sync-resolution-menu.pug';
|
||||
|
||||
class SyncResolutionMenuCtrl {
|
||||
|
||||
closeFunction!: () => void
|
||||
application!: WebApplication
|
||||
|
||||
$timeout: ng.ITimeoutService
|
||||
status: Partial<{
|
||||
backupFinished: boolean,
|
||||
resolving: boolean,
|
||||
attemptedResolution: boolean,
|
||||
success: boolean
|
||||
fail: boolean
|
||||
}> = {}
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$timeout,
|
||||
archiveManager,
|
||||
syncManager,
|
||||
$timeout: ng.ITimeoutService
|
||||
) {
|
||||
this.$timeout = $timeout;
|
||||
this.archiveManager = archiveManager;
|
||||
this.syncManager = syncManager;
|
||||
this.status = {};
|
||||
}
|
||||
|
||||
downloadBackup(encrypted) {
|
||||
this.archiveManager.downloadBackup(encrypted);
|
||||
downloadBackup(encrypted: boolean) {
|
||||
this.application.getArchiveService().downloadBackup(encrypted);
|
||||
this.status.backupFinished = true;
|
||||
}
|
||||
|
||||
|
|
@ -24,11 +34,11 @@ class SyncResolutionMenuCtrl {
|
|||
|
||||
async performSyncResolution() {
|
||||
this.status.resolving = true;
|
||||
await this.syncManager.resolveOutOfSync();
|
||||
await this.application.resolveOutOfSync();
|
||||
this.$timeout(() => {
|
||||
this.status.resolving = false;
|
||||
this.status.attemptedResolution = true;
|
||||
if (this.syncManager.isOutOfSync()) {
|
||||
if (this.application.isOutOfSync()) {
|
||||
this.status.fail = true;
|
||||
} else {
|
||||
this.status.success = true;
|
||||
|
|
@ -38,20 +48,22 @@ class SyncResolutionMenuCtrl {
|
|||
|
||||
close() {
|
||||
this.$timeout(() => {
|
||||
this.closeFunction()();
|
||||
this.closeFunction();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SyncResolutionMenu {
|
||||
export class SyncResolutionMenu extends WebDirective {
|
||||
constructor() {
|
||||
super();
|
||||
this.restrict = 'E';
|
||||
this.template = template;
|
||||
this.controller = SyncResolutionMenuCtrl;
|
||||
this.controllerAs = 'ctrl';
|
||||
this.bindToController = true;
|
||||
this.scope = {
|
||||
closeFunction: '&'
|
||||
closeFunction: '&',
|
||||
application: '='
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/* @ngInject */
|
||||
export function trusted($sce) {
|
||||
return function(url) {
|
||||
return $sce.trustAsResourceUrl(url);
|
||||
};
|
||||
}
|
||||
6
app/assets/javascripts/filters/trusted.ts
Normal file
6
app/assets/javascripts/filters/trusted.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/* @ngInject */
|
||||
export function trusted($sce: ng.ISCEService) {
|
||||
return function(url: string) {
|
||||
return $sce.trustAsResourceUrl(url);
|
||||
};
|
||||
}
|
||||
|
|
@ -12,11 +12,6 @@ import '../../../vendor/assets/javascripts/zip/inflate';
|
|||
import '../../../vendor/assets/javascripts/zip/zip';
|
||||
import '../../../vendor/assets/javascripts/zip/z-worker';
|
||||
|
||||
import { SFItem } from 'snjs';
|
||||
|
||||
// Set the app domain before starting the app
|
||||
SFItem.AppDomain = 'org.standardnotes.sn';
|
||||
|
||||
// entry point
|
||||
// eslint-disable-next-line import/first
|
||||
import './app';
|
||||
4
app/assets/javascripts/messages.ts
Normal file
4
app/assets/javascripts/messages.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export enum RootScopeMessages {
|
||||
ReloadExtendedData = 'reload-ext-data',
|
||||
NewUpdateAvailable = 'new-update-available'
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { SFItemHistoryEntry } from 'snjs';
|
||||
|
||||
export class NoteHistoryEntry extends SFItemHistoryEntry {
|
||||
|
||||
previewTitle() {
|
||||
return this.item.updated_at.toLocaleString();
|
||||
}
|
||||
|
||||
previewSubTitle() {
|
||||
if(!this.hasPreviousEntry) {
|
||||
return `${this.textCharDiffLength} characters loaded`;
|
||||
} else if(this.textCharDiffLength < 0) {
|
||||
return `${this.textCharDiffLength * -1} characters removed`;
|
||||
} else if(this.textCharDiffLength > 0) {
|
||||
return `${this.textCharDiffLength} characters added`;
|
||||
} else {
|
||||
return "Title or metadata changed";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { isDesktopApplication } from './utils';
|
||||
|
||||
/* @ngInject */
|
||||
export function configRoutes($locationProvider) {
|
||||
export function configRoutes($locationProvider: ng.ILocationProvider) {
|
||||
if (!isDesktopApplication()) {
|
||||
if (window.history && window.history.pushState) {
|
||||
$locationProvider.html5Mode({
|
||||
|
|
@ -1,297 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import angular from 'angular';
|
||||
import { Action, SFModelManager, SFItemParams, protocolManager } from 'snjs';
|
||||
|
||||
export class ActionsManager {
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$compile,
|
||||
$rootScope,
|
||||
$timeout,
|
||||
alertManager,
|
||||
authManager,
|
||||
httpManager,
|
||||
modelManager,
|
||||
syncManager,
|
||||
) {
|
||||
this.$compile = $compile;
|
||||
this.$rootScope = $rootScope;
|
||||
this.$timeout = $timeout;
|
||||
this.alertManager = alertManager;
|
||||
this.authManager = authManager;
|
||||
this.httpManager = httpManager;
|
||||
this.modelManager = modelManager;
|
||||
this.syncManager = syncManager;
|
||||
/* Used when decrypting old items with new keys. This array is only kept in memory. */
|
||||
this.previousPasswords = [];
|
||||
}
|
||||
|
||||
get extensions() {
|
||||
return this.modelManager.validItemsForContentType('Extension');
|
||||
}
|
||||
|
||||
extensionsInContextOfItem(item) {
|
||||
return this.extensions.filter((ext) => {
|
||||
return _.includes(ext.supported_types, item.content_type) ||
|
||||
ext.actionsWithContextForItem(item).length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an extension in the context of a certain item.
|
||||
* The server then has the chance to respond with actions that are
|
||||
* relevant just to this item. The response extension is not saved,
|
||||
* just displayed as a one-time thing.
|
||||
*/
|
||||
async loadExtensionInContextOfItem(extension, item) {
|
||||
const params = {
|
||||
content_type: item.content_type,
|
||||
item_uuid: item.uuid
|
||||
};
|
||||
const emptyFunc = () => { };
|
||||
return this.httpManager.getAbsolute(
|
||||
extension.url,
|
||||
params,
|
||||
emptyFunc,
|
||||
emptyFunc
|
||||
).then((response) => {
|
||||
this.updateExtensionFromRemoteResponse(extension, response);
|
||||
return extension;
|
||||
}).catch((response) => {
|
||||
console.error("Error loading extension", response);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
updateExtensionFromRemoteResponse(extension, response) {
|
||||
if (response.description) {
|
||||
extension.description = response.description;
|
||||
}
|
||||
if (response.supported_types) {
|
||||
extension.supported_types = response.supported_types;
|
||||
}
|
||||
if (response.actions) {
|
||||
extension.actions = response.actions.map((action) => {
|
||||
return new Action(action);
|
||||
});
|
||||
} else {
|
||||
extension.actions = [];
|
||||
}
|
||||
}
|
||||
|
||||
async executeAction(action, extension, item) {
|
||||
action.running = true;
|
||||
let result;
|
||||
switch (action.verb) {
|
||||
case 'get':
|
||||
result = await this.handleGetAction(action);
|
||||
break;
|
||||
case 'render':
|
||||
result = await this.handleRenderAction(action);
|
||||
break;
|
||||
case 'show':
|
||||
result = await this.handleShowAction(action);
|
||||
break;
|
||||
case 'post':
|
||||
result = await this.handlePostAction(action, item, extension);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
action.lastExecuted = new Date();
|
||||
action.running = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
async decryptResponse(response, keys) {
|
||||
const responseItem = response.item;
|
||||
await protocolManager.decryptItem(responseItem, keys);
|
||||
if (!responseItem.errorDecrypting) {
|
||||
return {
|
||||
response: response,
|
||||
item: responseItem
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.auth_params) {
|
||||
/**
|
||||
* In some cases revisions were missing auth params.
|
||||
* Instruct the user to email us to get this remedied.
|
||||
*/
|
||||
this.alertManager.alert({
|
||||
text: `We were unable to decrypt this revision using your current keys,
|
||||
and this revision is missing metadata that would allow us to try different
|
||||
keys to decrypt it. This can likely be fixed with some manual intervention.
|
||||
Please email hello@standardnotes.org for assistance.`
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
/* Try previous passwords */
|
||||
const triedPasswords = [];
|
||||
for (const passwordCandidate of this.previousPasswords) {
|
||||
if (triedPasswords.includes(passwordCandidate)) {
|
||||
continue;
|
||||
}
|
||||
triedPasswords.push(passwordCandidate);
|
||||
const keyResults = await protocolManager.computeEncryptionKeysForUser(
|
||||
passwordCandidate,
|
||||
response.auth_params
|
||||
);
|
||||
if (!keyResults) {
|
||||
continue;
|
||||
}
|
||||
const nestedResponse = await this.decryptResponse(
|
||||
response,
|
||||
keyResults
|
||||
);
|
||||
if (nestedResponse.item) {
|
||||
return nestedResponse;
|
||||
}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this.presentPasswordModal((password) => {
|
||||
this.previousPasswords.push(password);
|
||||
const result = this.decryptResponse(response, keys);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handlePostAction(action, item, extension) {
|
||||
const decrypted = action.access_type === 'decrypted';
|
||||
const itemParams = await this.outgoingParamsForItem(item, extension, decrypted);
|
||||
const params = {
|
||||
items: [itemParams]
|
||||
};
|
||||
/* Needed until SNJS detects null function */
|
||||
const emptyFunc = () => { };
|
||||
return this.httpManager.postAbsolute(
|
||||
action.url,
|
||||
params,
|
||||
emptyFunc,
|
||||
emptyFunc
|
||||
).then((response) => {
|
||||
action.error = false;
|
||||
return {response: response};
|
||||
}).catch((response) => {
|
||||
action.error = true;
|
||||
console.error("Action error response:", response);
|
||||
this.alertManager.alert({
|
||||
text: "An issue occurred while processing this action. Please try again."
|
||||
});
|
||||
return { response: response };
|
||||
});
|
||||
}
|
||||
|
||||
async handleShowAction(action) {
|
||||
const win = window.open(action.url, '_blank');
|
||||
if (win) {
|
||||
win.focus();
|
||||
}
|
||||
return { response: null };
|
||||
}
|
||||
|
||||
async handleGetAction(action) {
|
||||
/* Needed until SNJS detects null function */
|
||||
const emptyFunc = () => {};
|
||||
const onConfirm = async () => {
|
||||
return this.httpManager.getAbsolute(action.url, {}, emptyFunc, emptyFunc)
|
||||
.then(async (response) => {
|
||||
action.error = false;
|
||||
await this.decryptResponse(response, await this.authManager.keys());
|
||||
const items = await this.modelManager.mapResponseItemsToLocalModels(
|
||||
[response.item],
|
||||
SFModelManager.MappingSourceRemoteActionRetrieved
|
||||
);
|
||||
for (const mappedItem of items) {
|
||||
this.modelManager.setItemDirty(mappedItem, true);
|
||||
}
|
||||
this.syncManager.sync();
|
||||
return {
|
||||
response: response,
|
||||
item: response.item
|
||||
};
|
||||
}).catch((response) => {
|
||||
const error = (response && response.error)
|
||||
|| { message: "An issue occurred while processing this action. Please try again." };
|
||||
this.alertManager.alert({ text: error.message });
|
||||
action.error = true;
|
||||
return { error: error };
|
||||
});
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.alertManager.confirm({
|
||||
text: "Are you sure you want to replace the current note contents with this action's results?",
|
||||
onConfirm: () => {
|
||||
onConfirm().then(resolve);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleRenderAction(action) {
|
||||
/* Needed until SNJS detects null function */
|
||||
const emptyFunc = () => {};
|
||||
return this.httpManager.getAbsolute(
|
||||
action.url,
|
||||
{},
|
||||
emptyFunc,
|
||||
emptyFunc
|
||||
).then(async (response) => {
|
||||
action.error = false;
|
||||
const result = await this.decryptResponse(response, await this.authManager.keys());
|
||||
const item = this.modelManager.createItem(result.item);
|
||||
return {
|
||||
response: result.response,
|
||||
item: item
|
||||
};
|
||||
}).catch((response) => {
|
||||
const error = (response && response.error)
|
||||
|| { message: "An issue occurred while processing this action. Please try again." };
|
||||
this.alertManager.alert({ text: error.message });
|
||||
action.error = true;
|
||||
return { error: error };
|
||||
});
|
||||
}
|
||||
|
||||
async outgoingParamsForItem(item, extension, decrypted = false) {
|
||||
let keys = await this.authManager.keys();
|
||||
if (decrypted) {
|
||||
keys = null;
|
||||
}
|
||||
const itemParams = new SFItemParams(
|
||||
item,
|
||||
keys,
|
||||
await this.authManager.getAuthParams()
|
||||
);
|
||||
return itemParams.paramsForExtension();
|
||||
}
|
||||
|
||||
presentRevisionPreviewModal(uuid, content) {
|
||||
const scope = this.$rootScope.$new(true);
|
||||
scope.uuid = uuid;
|
||||
scope.content = content;
|
||||
const el = this.$compile(
|
||||
`<revision-preview-modal uuid='uuid' content='content'
|
||||
class='sk-modal'></revision-preview-modal>`
|
||||
)(scope);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
|
||||
presentPasswordModal(callback) {
|
||||
const scope = this.$rootScope.$new(true);
|
||||
scope.type = "password";
|
||||
scope.title = "Decryption Assistance";
|
||||
scope.message = `Unable to decrypt this item with your current keys.
|
||||
Please enter your account password at the time of this revision.`;
|
||||
scope.callback = callback;
|
||||
const el = this.$compile(
|
||||
`<input-modal type='type' message='message'
|
||||
title='title' callback='callback'></input-modal>`
|
||||
)(scope);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { SFAlertManager } from 'snjs';
|
||||
import { SKAlert } from 'sn-stylekit';
|
||||
|
||||
export class AlertManager extends SFAlertManager {
|
||||
/* @ngInject */
|
||||
constructor($timeout) {
|
||||
super();
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
async alert({
|
||||
title,
|
||||
text,
|
||||
closeButtonText = "OK",
|
||||
onClose} = {}
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const buttons = [
|
||||
{
|
||||
text: closeButtonText,
|
||||
style: "neutral",
|
||||
action: async () => {
|
||||
if(onClose) {
|
||||
this.$timeout(onClose);
|
||||
}
|
||||
resolve(true);
|
||||
}
|
||||
}
|
||||
];
|
||||
const alert = new SKAlert({title, text, buttons});
|
||||
alert.present();
|
||||
});
|
||||
}
|
||||
|
||||
async confirm({
|
||||
title,
|
||||
text,
|
||||
confirmButtonText = "Confirm",
|
||||
cancelButtonText = "Cancel",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
destructive = false
|
||||
} = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const buttons = [
|
||||
{
|
||||
text: cancelButtonText,
|
||||
style: "neutral",
|
||||
action: async () => {
|
||||
if(onCancel) {
|
||||
this.$timeout(onCancel);
|
||||
}
|
||||
reject(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
text: confirmButtonText,
|
||||
style: destructive ? "danger" : "info",
|
||||
action: async () => {
|
||||
if(onConfirm) {
|
||||
this.$timeout(onConfirm);
|
||||
}
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
];
|
||||
const alert = new SKAlert({title, text, buttons});
|
||||
alert.present();
|
||||
});
|
||||
}
|
||||
}
|
||||
101
app/assets/javascripts/services/alertService.ts
Normal file
101
app/assets/javascripts/services/alertService.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/* eslint-disable prefer-promise-reject-errors */
|
||||
import { SNAlertService, ButtonType } from 'snjs';
|
||||
import { SKAlert } from 'sn-stylekit';
|
||||
|
||||
/** @returns a promise resolving to true if the user confirmed, false if they canceled */
|
||||
export function confirmDialog({
|
||||
text,
|
||||
title,
|
||||
confirmButtonText = 'Confirm',
|
||||
cancelButtonText = 'Cancel',
|
||||
confirmButtonStyle = 'info',
|
||||
}: {
|
||||
text: string;
|
||||
title?: string;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
confirmButtonStyle?: 'danger' | 'info';
|
||||
}) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const alert = new SKAlert({
|
||||
title,
|
||||
text,
|
||||
buttons: [
|
||||
{
|
||||
text: cancelButtonText,
|
||||
style: 'neutral',
|
||||
action() {
|
||||
resolve(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: confirmButtonText,
|
||||
style: confirmButtonStyle,
|
||||
action() {
|
||||
resolve(true);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
alert.present();
|
||||
});
|
||||
}
|
||||
|
||||
export function alertDialog({
|
||||
title,
|
||||
text,
|
||||
closeButtonText = 'OK',
|
||||
}: {
|
||||
title?: string;
|
||||
text: string;
|
||||
closeButtonText?: string;
|
||||
}) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const alert = new SKAlert({
|
||||
title,
|
||||
text,
|
||||
buttons: [
|
||||
{
|
||||
text: closeButtonText,
|
||||
style: 'neutral',
|
||||
action: resolve,
|
||||
},
|
||||
],
|
||||
});
|
||||
alert.present();
|
||||
});
|
||||
}
|
||||
|
||||
export class AlertService implements SNAlertService {
|
||||
/**
|
||||
* @deprecated use the standalone `alertDialog` function instead
|
||||
*/
|
||||
alert(text: string, title?: string, closeButtonText?: string) {
|
||||
return alertDialog({ text, title, closeButtonText });
|
||||
}
|
||||
|
||||
confirm(
|
||||
text: string,
|
||||
title?: string,
|
||||
confirmButtonText?: string,
|
||||
confirmButtonType?: ButtonType,
|
||||
cancelButtonText?: string
|
||||
): Promise<boolean> {
|
||||
return confirmDialog({
|
||||
text,
|
||||
title,
|
||||
confirmButtonText,
|
||||
cancelButtonText,
|
||||
confirmButtonStyle:
|
||||
confirmButtonType === ButtonType.Danger ? 'danger' : 'info',
|
||||
});
|
||||
}
|
||||
|
||||
blockingDialog(text: string, title?: string) {
|
||||
const alert = new SKAlert({ text, title });
|
||||
alert.present();
|
||||
return () => {
|
||||
alert.dismiss();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import { PrivilegesManager } from '@/services/privilegesManager';
|
||||
|
||||
export class ArchiveManager {
|
||||
/* @ngInject */
|
||||
constructor(passcodeManager, authManager, modelManager, privilegesManager) {
|
||||
this.passcodeManager = passcodeManager;
|
||||
this.authManager = authManager;
|
||||
this.modelManager = modelManager;
|
||||
this.privilegesManager = privilegesManager;
|
||||
}
|
||||
|
||||
/*
|
||||
Public
|
||||
*/
|
||||
|
||||
async downloadBackup(encrypted) {
|
||||
return this.downloadBackupOfItems(this.modelManager.allItems, encrypted);
|
||||
}
|
||||
|
||||
async downloadBackupOfItems(items, encrypted) {
|
||||
const run = async () => {
|
||||
// download in Standard Notes format
|
||||
let keys, authParams;
|
||||
if(encrypted) {
|
||||
if(this.authManager.offline() && this.passcodeManager.hasPasscode()) {
|
||||
keys = this.passcodeManager.keys();
|
||||
authParams = this.passcodeManager.passcodeAuthParams();
|
||||
} else {
|
||||
keys = await this.authManager.keys();
|
||||
authParams = await this.authManager.getAuthParams();
|
||||
}
|
||||
const data = await this.__itemsData(items, keys, authParams);
|
||||
this.__downloadData(data,
|
||||
`Standard Notes Encrypted Backup and Import File - ${this.__formattedDate()}.txt`);
|
||||
} else {
|
||||
this.__downloadZippedItems(items);
|
||||
}
|
||||
};
|
||||
|
||||
if(await this.privilegesManager.actionRequiresPrivilege(PrivilegesManager.ActionManageBackups)) {
|
||||
this.privilegesManager.presentPrivilegesModal(PrivilegesManager.ActionManageBackups, () => {
|
||||
run();
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Private
|
||||
*/
|
||||
|
||||
__formattedDate() {
|
||||
var string = `${new Date()}`;
|
||||
// Match up to the first parenthesis, i.e do not include '(Central Standard Time)'
|
||||
var matches = string.match(/^(.*?) \(/);
|
||||
if(matches.length >= 2) {
|
||||
return matches[1];
|
||||
}
|
||||
return string;
|
||||
}
|
||||
|
||||
async __itemsData(items, keys, authParams) {
|
||||
const data = await this.modelManager.getJSONDataForItems(items, keys, authParams);
|
||||
const blobData = new Blob([data], {type: 'text/json'});
|
||||
return blobData;
|
||||
}
|
||||
|
||||
__loadZip(callback) {
|
||||
if(window.zip) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
var scriptTag = document.createElement('script');
|
||||
scriptTag.src = "/assets/zip/zip.js";
|
||||
scriptTag.async = false;
|
||||
var headTag = document.getElementsByTagName('head')[0];
|
||||
headTag.appendChild(scriptTag);
|
||||
scriptTag.onload = function() {
|
||||
zip.workerScriptsPath = "assets/zip/";
|
||||
callback();
|
||||
};
|
||||
}
|
||||
|
||||
__downloadZippedItems(items) {
|
||||
this.__loadZip(() => {
|
||||
zip.createWriter(new zip.BlobWriter("application/zip"), async (zipWriter) => {
|
||||
var index = 0;
|
||||
|
||||
const data = await this.modelManager.getJSONDataForItems(items);
|
||||
await new Promise((resolve) => {
|
||||
const blob = new Blob([data], {type: 'text/plain'});
|
||||
zipWriter.add(
|
||||
'Standard Notes Backup and Import File.txt',
|
||||
new zip.BlobReader(blob),
|
||||
resolve
|
||||
);
|
||||
});
|
||||
|
||||
const nextFile = () => {
|
||||
var item = items[index];
|
||||
var name, contents;
|
||||
|
||||
if(item.content_type === "Note") {
|
||||
name = item.content.title;
|
||||
contents = item.content.text;
|
||||
} else {
|
||||
name = item.content_type;
|
||||
contents = JSON.stringify(item.content, null, 2);
|
||||
}
|
||||
|
||||
if(!name) {
|
||||
name = "";
|
||||
}
|
||||
|
||||
const blob = new Blob([contents], {type: 'text/plain'});
|
||||
let filePrefix = name.replace(/\//g, "").replace(/\\+/g, "");
|
||||
const fileSuffix = `-${item.uuid.split("-")[0]}.txt`;
|
||||
// Standard max filename length is 255. Slice the note name down to allow filenameEnd
|
||||
filePrefix = filePrefix.slice(0, (255 - fileSuffix.length));
|
||||
const fileName = `Items/${item.content_type}/${filePrefix}${fileSuffix}`;
|
||||
zipWriter.add(fileName, new zip.BlobReader(blob), () => {
|
||||
index++;
|
||||
if(index < items.length) {
|
||||
nextFile();
|
||||
} else {
|
||||
zipWriter.close((blob) => {
|
||||
this.__downloadData(blob, `Standard Notes Backup - ${this.__formattedDate()}.zip`);
|
||||
zipWriter = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
nextFile();
|
||||
}, onerror);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
__hrefForData(data) {
|
||||
// If we are replacing a previously generated file we need to
|
||||
// manually revoke the object URL to avoid memory leaks.
|
||||
if (this.textFile !== null) {
|
||||
window.URL.revokeObjectURL(this.textFile);
|
||||
}
|
||||
|
||||
this.textFile = window.URL.createObjectURL(data);
|
||||
|
||||
// returns a URL you can use as a href
|
||||
return this.textFile;
|
||||
}
|
||||
|
||||
__downloadData(data, fileName) {
|
||||
var link = document.createElement('a');
|
||||
link.setAttribute('download', fileName);
|
||||
link.href = this.__hrefForData(data);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
171
app/assets/javascripts/services/archiveManager.ts
Normal file
171
app/assets/javascripts/services/archiveManager.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { WebApplication } from '@/ui_models/application';
|
||||
import { EncryptionIntent, ProtectedAction, SNItem, ContentType, SNNote } from 'snjs';
|
||||
|
||||
function zippableTxtName(name: string, suffix = ""): string {
|
||||
const sanitizedName = name
|
||||
.replace(/\//g, '')
|
||||
.replace(/\\+/g, '')
|
||||
.replace(/:/g, ' ')
|
||||
.replace(/\./g, ' ');
|
||||
const nameEnd = suffix + ".txt";
|
||||
const maxFileNameLength = 255;
|
||||
return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd;
|
||||
}
|
||||
|
||||
export class ArchiveManager {
|
||||
|
||||
private readonly application: WebApplication
|
||||
private textFile?: string
|
||||
|
||||
constructor(application: WebApplication) {
|
||||
this.application = application;
|
||||
}
|
||||
|
||||
public async downloadBackup(encrypted: boolean) {
|
||||
const items = this.application.allItems();
|
||||
|
||||
const run = async () => {
|
||||
// download in Standard Notes format
|
||||
const intent = encrypted
|
||||
? EncryptionIntent.FileEncrypted
|
||||
: EncryptionIntent.FileDecrypted;
|
||||
if (encrypted) {
|
||||
const data = await this.itemsData(items, intent);
|
||||
this.downloadData(
|
||||
data!,
|
||||
`Standard Notes Encrypted Backup and Import File - ${this.formattedDate()}.txt`
|
||||
);
|
||||
} else {
|
||||
/** download as zipped plain text files */
|
||||
this.downloadZippedItems(items);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
await this.application.privilegesService!
|
||||
.actionRequiresPrivilege(ProtectedAction.ManageBackups)
|
||||
) {
|
||||
this.application.presentPrivilegesModal(
|
||||
ProtectedAction.ManageBackups,
|
||||
() => {
|
||||
run();
|
||||
});
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
private formattedDate() {
|
||||
const string = `${new Date()}`;
|
||||
// Match up to the first parenthesis, i.e do not include '(Central Standard Time)'
|
||||
const matches = string.match(/^(.*?) \(/);
|
||||
if (matches && matches.length >= 2) {
|
||||
return matches[1];
|
||||
}
|
||||
return string;
|
||||
}
|
||||
|
||||
private async itemsData(items: SNItem[], intent: EncryptionIntent) {
|
||||
const data = await this.application.createBackupFile(items, intent);
|
||||
if (!data) {
|
||||
return undefined;
|
||||
}
|
||||
const blobData = new Blob([data], { type: 'text/json' });
|
||||
return blobData;
|
||||
}
|
||||
|
||||
private get zip() {
|
||||
return (window as any).zip;
|
||||
}
|
||||
|
||||
private async loadZip() {
|
||||
if (this.zip) {
|
||||
return;
|
||||
}
|
||||
const scriptTag = document.createElement('script');
|
||||
scriptTag.src = '/assets/zip/zip.js';
|
||||
scriptTag.async = false;
|
||||
const headTag = document.getElementsByTagName('head')[0];
|
||||
headTag.appendChild(scriptTag);
|
||||
return new Promise((resolve) => {
|
||||
scriptTag.onload = () => {
|
||||
this.zip.workerScriptsPath = 'assets/zip/';
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async downloadZippedItems(
|
||||
items: SNItem[]
|
||||
) {
|
||||
await this.loadZip();
|
||||
this.zip.createWriter(
|
||||
new this.zip.BlobWriter('application/zip'),
|
||||
async (zipWriter: any) => {
|
||||
|
||||
const data = await this.application.createBackupFile(items, EncryptionIntent.FileDecrypted);
|
||||
await new Promise((resolve) => {
|
||||
const blob = new Blob([data!], { type: 'text/plain' });
|
||||
const fileName = zippableTxtName(
|
||||
'Standard Notes Backup and Import File.txt'
|
||||
);
|
||||
zipWriter.add(fileName, new this.zip.BlobReader(blob), resolve);
|
||||
});
|
||||
|
||||
let index = 0;
|
||||
const nextFile = () => {
|
||||
const item = items[index];
|
||||
let name, contents;
|
||||
if (item.content_type === ContentType.Note) {
|
||||
const note = item as SNNote;
|
||||
name = note.title;
|
||||
contents = note.text;
|
||||
} else {
|
||||
name = item.content_type;
|
||||
contents = JSON.stringify(item.content, null, 2);
|
||||
}
|
||||
if (!name) {
|
||||
name = '';
|
||||
}
|
||||
const blob = new Blob([contents], { type: 'text/plain' });
|
||||
const fileName = `Items/${item.content_type}/` +
|
||||
zippableTxtName(name, `-${item.uuid.split('-')[0]}`);
|
||||
zipWriter.add(fileName, new this.zip.BlobReader(blob), () => {
|
||||
index++;
|
||||
if (index < items.length) {
|
||||
nextFile();
|
||||
} else {
|
||||
zipWriter.close((blob: any) => {
|
||||
this.downloadData(
|
||||
blob,
|
||||
`Standard Notes Backup - ${this.formattedDate()}.zip`
|
||||
);
|
||||
zipWriter = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
nextFile();
|
||||
}, onerror);
|
||||
}
|
||||
|
||||
private hrefForData(data: Blob) {
|
||||
// If we are replacing a previously generated file we need to
|
||||
// manually revoke the object URL to avoid memory leaks.
|
||||
if (this.textFile) {
|
||||
window.URL.revokeObjectURL(this.textFile);
|
||||
}
|
||||
this.textFile = window.URL.createObjectURL(data);
|
||||
// returns a URL you can use as a href
|
||||
return this.textFile;
|
||||
}
|
||||
|
||||
private downloadData(data: Blob, fileName: string) {
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('download', fileName);
|
||||
link.href = this.hrefForData(data);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import { StorageManager } from './storageManager';
|
||||
import { protocolManager, SFAuthManager } from 'snjs';
|
||||
|
||||
export class AuthManager extends SFAuthManager {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
modelManager,
|
||||
singletonManager,
|
||||
storageManager,
|
||||
dbManager,
|
||||
httpManager,
|
||||
$rootScope,
|
||||
$timeout,
|
||||
$compile
|
||||
) {
|
||||
super(storageManager, httpManager, null, $timeout);
|
||||
this.$rootScope = $rootScope;
|
||||
this.$compile = $compile;
|
||||
this.modelManager = modelManager;
|
||||
this.singletonManager = singletonManager;
|
||||
this.storageManager = storageManager;
|
||||
this.dbManager = dbManager;
|
||||
}
|
||||
|
||||
loadInitialData() {
|
||||
const userData = this.storageManager.getItemSync("user");
|
||||
if(userData) {
|
||||
this.user = JSON.parse(userData);
|
||||
} else {
|
||||
// legacy, check for uuid
|
||||
const idData = this.storageManager.getItemSync("uuid");
|
||||
if(idData) {
|
||||
this.user = {uuid: idData};
|
||||
}
|
||||
}
|
||||
this.checkForSecurityUpdate();
|
||||
}
|
||||
|
||||
offline() {
|
||||
return !this.user;
|
||||
}
|
||||
|
||||
isEphemeralSession() {
|
||||
if(this.ephemeral == null || this.ephemeral == undefined) {
|
||||
this.ephemeral = JSON.parse(this.storageManager.getItemSync("ephemeral", StorageManager.Fixed));
|
||||
}
|
||||
return this.ephemeral;
|
||||
}
|
||||
|
||||
setEphemeral(ephemeral) {
|
||||
this.ephemeral = ephemeral;
|
||||
if(ephemeral) {
|
||||
this.storageManager.setModelStorageMode(StorageManager.Ephemeral);
|
||||
this.storageManager.setItemsMode(StorageManager.Ephemeral);
|
||||
} else {
|
||||
this.storageManager.setModelStorageMode(StorageManager.Fixed);
|
||||
this.storageManager.setItemsMode(this.storageManager.bestStorageMode());
|
||||
this.storageManager.setItem("ephemeral", JSON.stringify(false), StorageManager.Fixed);
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthParamsForEmail(url, email, extraParams) {
|
||||
return super.getAuthParamsForEmail(url, email, extraParams);
|
||||
}
|
||||
|
||||
async login(url, email, password, ephemeral, strictSignin, extraParams) {
|
||||
return super.login(url, email, password, strictSignin, extraParams).then((response) => {
|
||||
if(!response.error) {
|
||||
this.setEphemeral(ephemeral);
|
||||
this.checkForSecurityUpdate();
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
async register(url, email, password, ephemeral) {
|
||||
return super.register(url, email, password).then((response) => {
|
||||
if(!response.error) {
|
||||
this.setEphemeral(ephemeral);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
async changePassword(url, email, current_server_pw, newKeys, newAuthParams) {
|
||||
return super.changePassword(url, email, current_server_pw, newKeys, newAuthParams).then((response) => {
|
||||
if(!response.error) {
|
||||
this.checkForSecurityUpdate();
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
async handleAuthResponse(response, email, url, authParams, keys) {
|
||||
try {
|
||||
await super.handleAuthResponse(response, email, url, authParams, keys);
|
||||
this.user = response.user;
|
||||
this.storageManager.setItem("user", JSON.stringify(response.user));
|
||||
} catch (e) {
|
||||
this.dbManager.displayOfflineAlert();
|
||||
}
|
||||
}
|
||||
|
||||
async verifyAccountPassword(password) {
|
||||
const authParams = await this.getAuthParams();
|
||||
const keys = await protocolManager.computeEncryptionKeysForUser(password, authParams);
|
||||
const success = keys.mk === (await this.keys()).mk;
|
||||
return success;
|
||||
}
|
||||
|
||||
async checkForSecurityUpdate() {
|
||||
if(this.offline()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const latest = protocolManager.version();
|
||||
const updateAvailable = await this.protocolVersion() !== latest;
|
||||
if(updateAvailable !== this.securityUpdateAvailable) {
|
||||
this.securityUpdateAvailable = updateAvailable;
|
||||
this.$rootScope.$broadcast("security-update-status-changed");
|
||||
}
|
||||
|
||||
return this.securityUpdateAvailable;
|
||||
}
|
||||
|
||||
presentPasswordWizard(type) {
|
||||
var scope = this.$rootScope.$new(true);
|
||||
scope.type = type;
|
||||
var el = this.$compile( "<password-wizard type='type'></password-wizard>" )(scope);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
|
||||
signOut() {
|
||||
super.signout();
|
||||
this.user = null;
|
||||
this._authParams = null;
|
||||
}
|
||||
}
|
||||
176
app/assets/javascripts/services/autolock_service.ts
Normal file
176
app/assets/javascripts/services/autolock_service.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { ApplicationService } from 'snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { AppStateEvent } from '@/ui_models/app_state';
|
||||
|
||||
const MILLISECONDS_PER_SECOND = 1000;
|
||||
const FOCUS_POLL_INTERVAL = 1 * MILLISECONDS_PER_SECOND;
|
||||
const LOCK_INTERVAL_NONE = 0;
|
||||
const LOCK_INTERVAL_IMMEDIATE = 1;
|
||||
const LOCK_INTERVAL_ONE_MINUTE = 60 * MILLISECONDS_PER_SECOND;
|
||||
const LOCK_INTERVAL_FIVE_MINUTES = 300 * MILLISECONDS_PER_SECOND;
|
||||
const LOCK_INTERVAL_ONE_HOUR = 3600 * MILLISECONDS_PER_SECOND;
|
||||
|
||||
const STORAGE_KEY_AUTOLOCK_INTERVAL = "AutoLockIntervalKey";
|
||||
|
||||
export class AutolockService extends ApplicationService {
|
||||
|
||||
private unsubState?: () => void;
|
||||
private pollFocusInterval: any
|
||||
private lastFocusState?: 'hidden' | 'visible'
|
||||
private lockAfterDate?: Date
|
||||
private lockTimeout?: any
|
||||
|
||||
onAppLaunch() {
|
||||
this.observeVisibility();
|
||||
return super.onAppLaunch();
|
||||
}
|
||||
|
||||
observeVisibility() {
|
||||
this.unsubState = (this.application as WebApplication).getAppState().addObserver(
|
||||
async (eventName) => {
|
||||
if (eventName === AppStateEvent.WindowDidBlur) {
|
||||
this.documentVisibilityChanged(false);
|
||||
} else if (eventName === AppStateEvent.WindowDidFocus) {
|
||||
this.documentVisibilityChanged(true);
|
||||
}
|
||||
}
|
||||
);
|
||||
if (!isDesktopApplication()) {
|
||||
this.beginWebFocusPolling();
|
||||
}
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.unsubState?.();
|
||||
this.cancelAutoLockTimer();
|
||||
if (this.pollFocusInterval) {
|
||||
clearInterval(this.pollFocusInterval);
|
||||
}
|
||||
}
|
||||
|
||||
private lockApplication() {
|
||||
if (!this.application.hasPasscode()) {
|
||||
throw Error('Attempting to lock application with no passcode');
|
||||
}
|
||||
this.application.lock();
|
||||
}
|
||||
|
||||
async setAutoLockInterval(interval: number) {
|
||||
return this.application!.setValue(
|
||||
STORAGE_KEY_AUTOLOCK_INTERVAL,
|
||||
interval
|
||||
);
|
||||
}
|
||||
|
||||
async getAutoLockInterval() {
|
||||
const interval = await this.application!.getValue(
|
||||
STORAGE_KEY_AUTOLOCK_INTERVAL
|
||||
);
|
||||
if (interval) {
|
||||
return interval;
|
||||
} else {
|
||||
return LOCK_INTERVAL_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAutolockPreference() {
|
||||
await this.application!.removeValue(
|
||||
STORAGE_KEY_AUTOLOCK_INTERVAL
|
||||
);
|
||||
this.cancelAutoLockTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify document is in focus every so often as visibilitychange event is
|
||||
* not triggered on a typical window blur event but rather on tab changes.
|
||||
*/
|
||||
beginWebFocusPolling() {
|
||||
this.pollFocusInterval = setInterval(() => {
|
||||
if (document.hidden) {
|
||||
/** Native event listeners will have fired */
|
||||
return;
|
||||
}
|
||||
const hasFocus = document.hasFocus();
|
||||
if (hasFocus && this.lastFocusState === 'hidden') {
|
||||
this.documentVisibilityChanged(true);
|
||||
} else if (!hasFocus && this.lastFocusState === 'visible') {
|
||||
this.documentVisibilityChanged(false);
|
||||
}
|
||||
/* Save this to compare against next time around */
|
||||
this.lastFocusState = hasFocus ? 'visible' : 'hidden';
|
||||
}, FOCUS_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
getAutoLockIntervalOptions() {
|
||||
return [
|
||||
{
|
||||
value: LOCK_INTERVAL_NONE,
|
||||
label: "Off"
|
||||
},
|
||||
{
|
||||
value: LOCK_INTERVAL_IMMEDIATE,
|
||||
label: "Immediately"
|
||||
},
|
||||
{
|
||||
value: LOCK_INTERVAL_ONE_MINUTE,
|
||||
label: "1m"
|
||||
},
|
||||
{
|
||||
value: LOCK_INTERVAL_FIVE_MINUTES,
|
||||
label: "5m"
|
||||
},
|
||||
{
|
||||
value: LOCK_INTERVAL_ONE_HOUR,
|
||||
label: "1h"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
async documentVisibilityChanged(visible: boolean) {
|
||||
if (visible) {
|
||||
const locked = await this.application.isLocked();
|
||||
if (
|
||||
!locked &&
|
||||
this.lockAfterDate &&
|
||||
new Date() > this.lockAfterDate
|
||||
) {
|
||||
this.lockApplication();
|
||||
}
|
||||
this.cancelAutoLockTimer();
|
||||
} else {
|
||||
this.beginAutoLockTimer();
|
||||
}
|
||||
}
|
||||
|
||||
async beginAutoLockTimer() {
|
||||
const interval = await this.getAutoLockInterval();
|
||||
if (interval === LOCK_INTERVAL_NONE) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* Use a timeout if possible, but if the computer is put to sleep, timeouts won't
|
||||
* work. Need to set a date as backup. this.lockAfterDate does not need to be
|
||||
* persisted, as living in memory is sufficient. If memory is cleared, then the
|
||||
* application will lock anyway.
|
||||
*/
|
||||
const addToNow = (seconds: number) => {
|
||||
const date = new Date();
|
||||
date.setSeconds(date.getSeconds() + seconds);
|
||||
return date;
|
||||
};
|
||||
this.lockAfterDate = addToNow(interval / MILLISECONDS_PER_SECOND);
|
||||
clearTimeout(this.lockTimeout);
|
||||
this.lockTimeout = setTimeout(() => {
|
||||
this.cancelAutoLockTimer();
|
||||
this.lockApplication();
|
||||
this.lockAfterDate = undefined;
|
||||
}, interval);
|
||||
}
|
||||
|
||||
cancelAutoLockTimer() {
|
||||
clearTimeout(this.lockTimeout);
|
||||
this.lockAfterDate = undefined;
|
||||
this.lockTimeout = undefined;
|
||||
}
|
||||
}
|
||||
51
app/assets/javascripts/services/bridge.ts
Normal file
51
app/assets/javascripts/services/bridge.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { PurePayload, Environment } from "snjs";
|
||||
|
||||
/** Platform-specific (i-e Electron/browser) behavior is handled by a Bridge object. */
|
||||
export interface Bridge {
|
||||
environment: Environment,
|
||||
|
||||
getKeychainValue(): Promise<unknown>;
|
||||
setKeychainValue(value: any): Promise<void>;
|
||||
clearKeychainValue(): Promise<void>;
|
||||
|
||||
extensionsServerHost?: string;
|
||||
syncComponents(payloads: PurePayload[]): void;
|
||||
onMajorDataChange(): void;
|
||||
onInitialDataLoad(): void;
|
||||
onSearch(text?: string): void;
|
||||
downloadBackup(): void;
|
||||
}
|
||||
|
||||
const KEYCHAIN_STORAGE_KEY = 'keychain';
|
||||
|
||||
export class BrowserBridge implements Bridge {
|
||||
environment = Environment.Web;
|
||||
|
||||
async getKeychainValue(): Promise<unknown> {
|
||||
const value = localStorage.getItem(KEYCHAIN_STORAGE_KEY);
|
||||
if (value) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
}
|
||||
|
||||
async setKeychainValue(value: any): Promise<void> {
|
||||
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value));
|
||||
}
|
||||
|
||||
async clearKeychainValue(): Promise<void> {
|
||||
localStorage.removeItem(KEYCHAIN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** No-ops */
|
||||
|
||||
syncComponents() {
|
||||
}
|
||||
onMajorDataChange() {
|
||||
}
|
||||
onInitialDataLoad() {
|
||||
}
|
||||
onSearch() {
|
||||
}
|
||||
downloadBackup() {
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import { SNComponentManager, SFAlertManager } from 'snjs';
|
||||
import { isDesktopApplication, getPlatformString } from '@/utils';
|
||||
|
||||
export class ComponentManager extends SNComponentManager {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
modelManager,
|
||||
syncManager,
|
||||
desktopManager,
|
||||
nativeExtManager,
|
||||
$rootScope,
|
||||
$timeout,
|
||||
$compile
|
||||
) {
|
||||
super({
|
||||
modelManager,
|
||||
syncManager,
|
||||
desktopManager,
|
||||
nativeExtManager,
|
||||
alertManager: new SFAlertManager(),
|
||||
$uiRunner: $rootScope.safeApply,
|
||||
$timeout: $timeout,
|
||||
environment: isDesktopApplication() ? "desktop" : "web",
|
||||
platform: getPlatformString()
|
||||
});
|
||||
|
||||
// this.loggingEnabled = true;
|
||||
|
||||
this.$compile = $compile;
|
||||
this.$rootScope = $rootScope;
|
||||
}
|
||||
|
||||
openModalComponent(component) {
|
||||
var scope = this.$rootScope.$new(true);
|
||||
scope.component = component;
|
||||
var el = this.$compile( "<component-modal component='component' class='sk-modal'></component-modal>" )(scope);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
|
||||
presentPermissionsDialog(dialog) {
|
||||
const scope = this.$rootScope.$new(true);
|
||||
scope.permissionsString = dialog.permissionsString;
|
||||
scope.component = dialog.component;
|
||||
scope.callback = dialog.callback;
|
||||
|
||||
var el = this.$compile( "<permissions-modal component='component' permissions-string='permissionsString' callback='callback' class='sk-modal'></permissions-modal>" )(scope);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
export class DBManager {
|
||||
/* @ngInject */
|
||||
constructor(alertManager) {
|
||||
this.locked = true;
|
||||
this.alertManager = alertManager;
|
||||
}
|
||||
|
||||
displayOfflineAlert() {
|
||||
var message = "There was an issue loading your offline database. This could happen for two reasons:";
|
||||
message += "\n\n1. You're in a private window in your browser. We can't save your data without access to the local database. Please use a non-private window.";
|
||||
message += "\n\n2. You have two windows of the app open at the same time. Please close any other app instances and reload the page.";
|
||||
this.alertManager.alert({text: message});
|
||||
}
|
||||
|
||||
setLocked(locked) {
|
||||
this.locked = locked;
|
||||
}
|
||||
|
||||
async openDatabase({onUpgradeNeeded} = {}) {
|
||||
if(this.locked) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = window.indexedDB.open("standardnotes", 1);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onerror = (event) => {
|
||||
if(event.target.errorCode) {
|
||||
this.alertManager.alert({text: "Offline database issue: " + event.target.errorCode});
|
||||
} else {
|
||||
this.displayOfflineAlert();
|
||||
}
|
||||
console.error("Offline database issue:", event);
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
request.onsuccess = (event) => {
|
||||
const db = event.target.result;
|
||||
db.onversionchange = function(event) {
|
||||
db.close();
|
||||
};
|
||||
db.onerror = function(errorEvent) {
|
||||
console.error("Database error: " + errorEvent.target.errorCode);
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onblocked = (event) => {
|
||||
console.error("Request blocked error:", event.target.errorCode);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
db.onversionchange = function(event) {
|
||||
db.close();
|
||||
};
|
||||
|
||||
// Create an objectStore for this database
|
||||
const objectStore = db.createObjectStore("items", { keyPath: "uuid" });
|
||||
objectStore.createIndex("uuid", "uuid", { unique: true });
|
||||
objectStore.transaction.oncomplete = function(event) {
|
||||
// Ready to store values in the newly created objectStore.
|
||||
if(db.version === 1 && onUpgradeNeeded) {
|
||||
onUpgradeNeeded();
|
||||
}
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getAllModels() {
|
||||
const db = await this.openDatabase();
|
||||
const objectStore = db.transaction("items").objectStore("items");
|
||||
const items = [];
|
||||
return new Promise(async (resolve, reject) => {
|
||||
objectStore.openCursor().onsuccess = (event) => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
items.push(cursor.value);
|
||||
cursor.continue();
|
||||
} else {
|
||||
resolve(items);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async saveModel(item) {
|
||||
this.saveModels([item]);
|
||||
}
|
||||
|
||||
async saveModels(items) {
|
||||
const showGenericError = (error) => {
|
||||
this.alertManager.alert({text: `Unable to save changes locally due to an unknown system issue. Issue Code: ${error.code} Issue Name: ${error.name}.`});
|
||||
};
|
||||
|
||||
return new Promise(async (resolve, reject) => {
|
||||
if(items.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const db = await this.openDatabase();
|
||||
const transaction = db.transaction("items", "readwrite");
|
||||
transaction.oncomplete = (event) => {};
|
||||
transaction.onerror = function(event) {
|
||||
console.error("Transaction error:", event.target.errorCode);
|
||||
showGenericError(event.target.error);
|
||||
};
|
||||
transaction.onblocked = function(event) {
|
||||
console.error("Transaction blocked error:", event.target.errorCode);
|
||||
showGenericError(event.target.error);
|
||||
};
|
||||
transaction.onabort = function(event) {
|
||||
console.error("Offline saving aborted:", event);
|
||||
const error = event.target.error;
|
||||
if(error.name == "QuotaExceededError") {
|
||||
this.alertManager.alert({text: "Unable to save changes locally because your device is out of space. Please free up some disk space and try again, otherwise, your data may end up in an inconsistent state."});
|
||||
} else {
|
||||
showGenericError(error);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const itemObjectStore = transaction.objectStore("items");
|
||||
|
||||
const putItem = async (item) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = itemObjectStore.put(item);
|
||||
request.onerror = (event) => {
|
||||
console.error("DB put error:", event.target.error);
|
||||
resolve();
|
||||
};
|
||||
request.onsuccess = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
for(const item of items) {
|
||||
await putItem(item);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async deleteModel(item) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const db = await this.openDatabase();
|
||||
const request = db.transaction("items", "readwrite").objectStore("items").delete(item.uuid);
|
||||
request.onsuccess = (event) => {
|
||||
resolve();
|
||||
};
|
||||
request.onerror = (event) => {
|
||||
reject();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async clearAllModels() {
|
||||
const deleteRequest = window.indexedDB.deleteDatabase("standardnotes");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
deleteRequest.onerror = function(event) {
|
||||
console.error("Error deleting database.");
|
||||
resolve();
|
||||
};
|
||||
|
||||
deleteRequest.onsuccess = function(event) {
|
||||
resolve();
|
||||
};
|
||||
|
||||
deleteRequest.onblocked = function(event) {
|
||||
console.error("Delete request blocked");
|
||||
this.alertManager.alert({text: "Your browser is blocking Standard Notes from deleting the local database. Make sure there are no other open windows of this app and try again. If the issue persists, please manually delete app data to sign out."});
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
// An interface used by the Desktop app to interact with SN
|
||||
import _ from 'lodash';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { SFItemParams, SFModelManager } from 'snjs';
|
||||
|
||||
const COMPONENT_DATA_KEY_INSTALL_ERROR = 'installError';
|
||||
const COMPONENT_CONTENT_KEY_PACKAGE_INFO = 'package_info';
|
||||
const COMPONENT_CONTENT_KEY_LOCAL_URL = 'local_url';
|
||||
|
||||
export class DesktopManager {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$rootScope,
|
||||
$timeout,
|
||||
modelManager,
|
||||
syncManager,
|
||||
authManager,
|
||||
passcodeManager,
|
||||
appState
|
||||
) {
|
||||
this.passcodeManager = passcodeManager;
|
||||
this.modelManager = modelManager;
|
||||
this.authManager = authManager;
|
||||
this.syncManager = syncManager;
|
||||
this.$rootScope = $rootScope;
|
||||
this.appState = appState;
|
||||
this.timeout = $timeout;
|
||||
this.updateObservers = [];
|
||||
this.componentActivationObservers = [];
|
||||
|
||||
this.isDesktop = isDesktopApplication();
|
||||
|
||||
$rootScope.$on("initial-data-loaded", () => {
|
||||
this.dataLoaded = true;
|
||||
if(this.dataLoadHandler) {
|
||||
this.dataLoadHandler();
|
||||
}
|
||||
});
|
||||
|
||||
$rootScope.$on("major-data-change", () => {
|
||||
if(this.majorDataChangeHandler) {
|
||||
this.majorDataChangeHandler();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
saveBackup() {
|
||||
this.majorDataChangeHandler && this.majorDataChangeHandler();
|
||||
}
|
||||
|
||||
getExtServerHost() {
|
||||
console.assert(
|
||||
this.extServerHost,
|
||||
'extServerHost is null'
|
||||
);
|
||||
return this.extServerHost;
|
||||
}
|
||||
|
||||
/*
|
||||
Sending a component in its raw state is really slow for the desktop app
|
||||
Keys are not passed into ItemParams, so the result is not encrypted
|
||||
*/
|
||||
async convertComponentForTransmission(component) {
|
||||
return new SFItemParams(component).paramsForExportFile(true);
|
||||
}
|
||||
|
||||
// All `components` should be installed
|
||||
syncComponentsInstallation(components) {
|
||||
if(!this.isDesktop) {
|
||||
return;
|
||||
}
|
||||
Promise.all(components.map((component) => {
|
||||
return this.convertComponentForTransmission(component);
|
||||
})).then((data) => {
|
||||
this.installationSyncHandler(data);
|
||||
});
|
||||
}
|
||||
|
||||
async installComponent(component) {
|
||||
this.installComponentHandler(
|
||||
await this.convertComponentForTransmission(component)
|
||||
);
|
||||
}
|
||||
|
||||
registerUpdateObserver(callback) {
|
||||
const observer = {
|
||||
callback: callback
|
||||
};
|
||||
this.updateObservers.push(observer);
|
||||
return observer;
|
||||
}
|
||||
|
||||
searchText(text) {
|
||||
if(!this.isDesktop) {
|
||||
return;
|
||||
}
|
||||
this.lastSearchedText = text;
|
||||
this.searchHandler && this.searchHandler(text);
|
||||
}
|
||||
|
||||
redoSearch() {
|
||||
if(this.lastSearchedText) {
|
||||
this.searchText(this.lastSearchedText);
|
||||
}
|
||||
}
|
||||
|
||||
deregisterUpdateObserver(observer) {
|
||||
_.pull(this.updateObservers, observer);
|
||||
}
|
||||
|
||||
// Pass null to cancel search
|
||||
desktop_setSearchHandler(handler) {
|
||||
this.searchHandler = handler;
|
||||
}
|
||||
|
||||
desktop_windowGainedFocus() {
|
||||
this.$rootScope.$broadcast("window-gained-focus");
|
||||
}
|
||||
|
||||
desktop_windowLostFocus() {
|
||||
this.$rootScope.$broadcast("window-lost-focus");
|
||||
}
|
||||
|
||||
desktop_onComponentInstallationComplete(componentData, error) {
|
||||
const component = this.modelManager.findItem(componentData.uuid);
|
||||
if(!component) {
|
||||
return;
|
||||
}
|
||||
if(error) {
|
||||
component.setAppDataItem(
|
||||
COMPONENT_DATA_KEY_INSTALL_ERROR,
|
||||
error
|
||||
);
|
||||
} else {
|
||||
const permissableKeys = [
|
||||
COMPONENT_CONTENT_KEY_PACKAGE_INFO,
|
||||
COMPONENT_CONTENT_KEY_LOCAL_URL
|
||||
];
|
||||
for(const key of permissableKeys) {
|
||||
component[key] = componentData.content[key];
|
||||
}
|
||||
this.modelManager.notifySyncObserversOfModels(
|
||||
[component],
|
||||
SFModelManager.MappingSourceDesktopInstalled
|
||||
);
|
||||
component.setAppDataItem(
|
||||
COMPONENT_DATA_KEY_INSTALL_ERROR,
|
||||
null
|
||||
);
|
||||
}
|
||||
this.modelManager.setItemDirty(component);
|
||||
this.syncManager.sync();
|
||||
this.timeout(() => {
|
||||
for(const observer of this.updateObservers) {
|
||||
observer.callback(component);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
desktop_registerComponentActivationObserver(callback) {
|
||||
const observer = {id: Math.random, callback: callback};
|
||||
this.componentActivationObservers.push(observer);
|
||||
return observer;
|
||||
}
|
||||
|
||||
desktop_deregisterComponentActivationObserver(observer) {
|
||||
_.pull(this.componentActivationObservers, observer);
|
||||
}
|
||||
|
||||
/* Notify observers that a component has been registered/activated */
|
||||
async notifyComponentActivation(component) {
|
||||
const serializedComponent = await this.convertComponentForTransmission(
|
||||
component
|
||||
);
|
||||
this.timeout(() => {
|
||||
for(const observer of this.componentActivationObservers) {
|
||||
observer.callback(serializedComponent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* Used to resolve "sn://" */
|
||||
desktop_setExtServerHost(host) {
|
||||
this.extServerHost = host;
|
||||
this.appState.desktopExtensionsReady();
|
||||
}
|
||||
|
||||
desktop_setComponentInstallationSyncHandler(handler) {
|
||||
this.installationSyncHandler = handler;
|
||||
}
|
||||
|
||||
desktop_setInstallComponentHandler(handler) {
|
||||
this.installComponentHandler = handler;
|
||||
}
|
||||
|
||||
desktop_setInitialDataLoadHandler(handler) {
|
||||
this.dataLoadHandler = handler;
|
||||
if(this.dataLoaded) {
|
||||
this.dataLoadHandler();
|
||||
}
|
||||
}
|
||||
|
||||
async desktop_requestBackupFile(callback) {
|
||||
let keys, authParams;
|
||||
if(this.authManager.offline() && this.passcodeManager.hasPasscode()) {
|
||||
keys = this.passcodeManager.keys();
|
||||
authParams = this.passcodeManager.passcodeAuthParams();
|
||||
} else {
|
||||
keys = await this.authManager.keys();
|
||||
authParams = await this.authManager.getAuthParams();
|
||||
}
|
||||
const nullOnEmpty = true;
|
||||
this.modelManager.getAllItemsJSONData(
|
||||
keys,
|
||||
authParams,
|
||||
nullOnEmpty
|
||||
).then((data) => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
|
||||
desktop_setMajorDataChangeHandler(handler) {
|
||||
this.majorDataChangeHandler = handler;
|
||||
}
|
||||
|
||||
desktop_didBeginBackup() {
|
||||
this.appState.beganBackupDownload();
|
||||
}
|
||||
|
||||
desktop_didFinishBackup(success) {
|
||||
this.appState.endedBackupDownload({
|
||||
success: success
|
||||
});
|
||||
}
|
||||
}
|
||||
205
app/assets/javascripts/services/desktopManager.ts
Normal file
205
app/assets/javascripts/services/desktopManager.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { SNComponent, PurePayload, ComponentMutator, AppDataField, ContentType } from 'snjs';
|
||||
/* eslint-disable camelcase */
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
// An interface used by the Desktop app to interact with SN
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { EncryptionIntent, ApplicationService, ApplicationEvent, removeFromArray } from 'snjs';
|
||||
import { Bridge } from './bridge';
|
||||
|
||||
type UpdateObserverCallback = (component: SNComponent) => void
|
||||
type ComponentActivationCallback = (payload: PurePayload) => void
|
||||
type ComponentActivationObserver = {
|
||||
id: string,
|
||||
callback: ComponentActivationCallback
|
||||
}
|
||||
|
||||
export class DesktopManager extends ApplicationService {
|
||||
|
||||
$rootScope: ng.IRootScopeService
|
||||
$timeout: ng.ITimeoutService
|
||||
componentActivationObservers: ComponentActivationObserver[] = []
|
||||
updateObservers: {
|
||||
callback: UpdateObserverCallback
|
||||
}[] = []
|
||||
isDesktop = isDesktopApplication();
|
||||
|
||||
dataLoaded = false
|
||||
lastSearchedText?: string
|
||||
private removeComponentObserver?: () => void;
|
||||
|
||||
constructor(
|
||||
$rootScope: ng.IRootScopeService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
application: WebApplication,
|
||||
private bridge: Bridge,
|
||||
) {
|
||||
super(application);
|
||||
this.$rootScope = $rootScope;
|
||||
this.$timeout = $timeout;
|
||||
}
|
||||
|
||||
get webApplication() {
|
||||
return this.application as WebApplication;
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.componentActivationObservers.length = 0;
|
||||
this.updateObservers.length = 0;
|
||||
this.removeComponentObserver?.();
|
||||
this.removeComponentObserver = undefined;
|
||||
super.deinit();
|
||||
}
|
||||
|
||||
async onAppEvent(eventName: ApplicationEvent) {
|
||||
super.onAppEvent(eventName);
|
||||
if (eventName === ApplicationEvent.LocalDataLoaded) {
|
||||
this.dataLoaded = true;
|
||||
this.bridge.onInitialDataLoad();
|
||||
} else if (eventName === ApplicationEvent.MajorDataChange) {
|
||||
this.bridge.onMajorDataChange();
|
||||
}
|
||||
}
|
||||
|
||||
saveBackup() {
|
||||
this.bridge.onMajorDataChange();
|
||||
}
|
||||
|
||||
getExtServerHost() {
|
||||
console.assert(
|
||||
this.bridge.extensionsServerHost,
|
||||
'extServerHost is null'
|
||||
);
|
||||
return this.bridge.extensionsServerHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sending a component in its raw state is really slow for the desktop app
|
||||
* Keys are not passed into ItemParams, so the result is not encrypted
|
||||
*/
|
||||
convertComponentForTransmission(component: SNComponent) {
|
||||
return this.application!.protocolService!.payloadByEncryptingPayload(
|
||||
component.payloadRepresentation(),
|
||||
EncryptionIntent.FileDecrypted
|
||||
);
|
||||
}
|
||||
|
||||
// All `components` should be installed
|
||||
syncComponentsInstallation(components: SNComponent[]) {
|
||||
if (!this.isDesktop) {
|
||||
return;
|
||||
}
|
||||
Promise.all(components.map((component) => {
|
||||
return this.convertComponentForTransmission(component);
|
||||
})).then((payloads) => {
|
||||
this.bridge.syncComponents(
|
||||
payloads.filter(payload =>
|
||||
!payload.errorDecrypting && !payload.waitingForKey
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
registerUpdateObserver(callback: UpdateObserverCallback) {
|
||||
const observer = {
|
||||
callback: callback
|
||||
};
|
||||
this.updateObservers.push(observer);
|
||||
return () => {
|
||||
removeFromArray(this.updateObservers, observer);
|
||||
};
|
||||
}
|
||||
|
||||
searchText(text?: string) {
|
||||
if (!this.isDesktop) {
|
||||
return;
|
||||
}
|
||||
this.lastSearchedText = text;
|
||||
this.bridge.onSearch(text);
|
||||
}
|
||||
|
||||
redoSearch() {
|
||||
if (this.lastSearchedText) {
|
||||
this.searchText(this.lastSearchedText);
|
||||
}
|
||||
}
|
||||
|
||||
desktop_windowGainedFocus() {
|
||||
this.$rootScope.$broadcast('window-gained-focus');
|
||||
}
|
||||
|
||||
desktop_windowLostFocus() {
|
||||
this.$rootScope.$broadcast('window-lost-focus');
|
||||
}
|
||||
|
||||
async desktop_onComponentInstallationComplete(
|
||||
componentData: any,
|
||||
error: any
|
||||
) {
|
||||
const component = this.application!.findItem(componentData.uuid);
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
const updatedComponent = await this.application!.changeAndSaveItem(
|
||||
component.uuid,
|
||||
(m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
if (error) {
|
||||
mutator.setAppDataItem(
|
||||
AppDataField.ComponentInstallError,
|
||||
error
|
||||
);
|
||||
} else {
|
||||
mutator.local_url = componentData.content.local_url;
|
||||
mutator.package_info = componentData.content.package_info;
|
||||
mutator.setAppDataItem(
|
||||
AppDataField.ComponentInstallError,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
})
|
||||
|
||||
this.$timeout(() => {
|
||||
for (const observer of this.updateObservers) {
|
||||
observer.callback(updatedComponent as SNComponent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
desktop_registerComponentActivationObserver(callback: ComponentActivationCallback) {
|
||||
const observer = { id: `${Math.random}`, callback: callback };
|
||||
this.componentActivationObservers.push(observer);
|
||||
return observer;
|
||||
}
|
||||
|
||||
desktop_deregisterComponentActivationObserver(observer: ComponentActivationObserver) {
|
||||
removeFromArray(this.componentActivationObservers, observer);
|
||||
}
|
||||
|
||||
/* Notify observers that a component has been registered/activated */
|
||||
async notifyComponentActivation(component: SNComponent) {
|
||||
const serializedComponent = await this.convertComponentForTransmission(
|
||||
component
|
||||
);
|
||||
this.$timeout(() => {
|
||||
for (const observer of this.componentActivationObservers) {
|
||||
observer.callback(serializedComponent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
desktop_requestBackupFile() {
|
||||
return this.application!.createBackupFile(
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
desktop_didBeginBackup() {
|
||||
this.webApplication.getAppState().beganBackupDownload();
|
||||
}
|
||||
|
||||
desktop_didFinishBackup(success: boolean) {
|
||||
this.webApplication.getAppState().endedBackupDownload(success);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { SFHttpManager } from 'snjs';
|
||||
|
||||
export class HttpManager extends SFHttpManager {
|
||||
/* @ngInject */
|
||||
constructor(storageManager, $timeout) {
|
||||
// calling callbacks in a $timeout allows UI to update
|
||||
super($timeout);
|
||||
|
||||
this.setJWTRequestHandler(async () => {
|
||||
return storageManager.getItem('jwt');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
export { ActionsManager } from './actionsManager';
|
||||
export { ArchiveManager } from './archiveManager';
|
||||
export { AuthManager } from './authManager';
|
||||
export { ComponentManager } from './componentManager';
|
||||
export { DBManager } from './dbManager';
|
||||
export { DesktopManager } from './desktopManager';
|
||||
export { HttpManager } from './httpManager';
|
||||
export { KeyboardManager } from './keyboardManager';
|
||||
export { MigrationManager } from './migrationManager';
|
||||
export { ModelManager } from './modelManager';
|
||||
export { NativeExtManager } from './nativeExtManager';
|
||||
export { PasscodeManager } from './passcodeManager';
|
||||
export { PrivilegesManager } from './privilegesManager';
|
||||
export { SessionHistory } from './sessionHistory';
|
||||
export { SingletonManager } from './singletonManager';
|
||||
export { StatusManager } from './statusManager';
|
||||
export { StorageManager } from './storageManager';
|
||||
export { SyncManager } from './syncManager';
|
||||
export { ThemeManager } from './themeManager';
|
||||
export { AlertManager } from './alertManager';
|
||||
export { PreferencesManager } from './preferencesManager';
|
||||
9
app/assets/javascripts/services/index.ts
Normal file
9
app/assets/javascripts/services/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { AlertService } from './alertService';
|
||||
export { ArchiveManager } from './archiveManager';
|
||||
export { DesktopManager } from './desktopManager';
|
||||
export { KeyboardManager } from './keyboardManager';
|
||||
export { AutolockService } from './autolock_service';
|
||||
export { NativeExtManager } from './nativeExtManager';
|
||||
export { PreferencesManager } from './preferencesManager';
|
||||
export { StatusManager } from './statusManager';
|
||||
export { ThemeManager } from './themeManager';
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
export class KeyboardManager {
|
||||
|
||||
constructor() {
|
||||
this.observers = [];
|
||||
|
||||
KeyboardManager.KeyTab = "Tab";
|
||||
KeyboardManager.KeyBackspace = "Backspace";
|
||||
KeyboardManager.KeyUp = "ArrowUp";
|
||||
KeyboardManager.KeyDown = "ArrowDown";
|
||||
|
||||
KeyboardManager.KeyModifierShift = "Shift";
|
||||
KeyboardManager.KeyModifierCtrl = "Control";
|
||||
// ⌘ key on Mac, ⊞ key on Windows
|
||||
KeyboardManager.KeyModifierMeta = "Meta";
|
||||
KeyboardManager.KeyModifierAlt = "Alt";
|
||||
|
||||
KeyboardManager.KeyEventDown = "KeyEventDown";
|
||||
KeyboardManager.KeyEventUp = "KeyEventUp";
|
||||
|
||||
KeyboardManager.AllModifiers = [
|
||||
KeyboardManager.KeyModifierShift,
|
||||
KeyboardManager.KeyModifierCtrl,
|
||||
KeyboardManager.KeyModifierMeta,
|
||||
KeyboardManager.KeyModifierAlt
|
||||
];
|
||||
|
||||
window.addEventListener('keydown', this.handleKeyDown.bind(this));
|
||||
window.addEventListener('keyup', this.handleKeyUp.bind(this));
|
||||
}
|
||||
|
||||
modifiersForEvent(event) {
|
||||
const eventModifiers = KeyboardManager.AllModifiers.filter((modifier) => {
|
||||
// For a modifier like ctrlKey, must check both event.ctrlKey and event.key.
|
||||
// That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true.
|
||||
const matches = (
|
||||
((event.ctrlKey || event.key == KeyboardManager.KeyModifierCtrl) && modifier === KeyboardManager.KeyModifierCtrl) ||
|
||||
((event.metaKey || event.key == KeyboardManager.KeyModifierMeta) && modifier === KeyboardManager.KeyModifierMeta) ||
|
||||
((event.altKey || event.key == KeyboardManager.KeyModifierAlt) && modifier === KeyboardManager.KeyModifierAlt) ||
|
||||
((event.shiftKey || event.key == KeyboardManager.KeyModifierShift) && modifier === KeyboardManager.KeyModifierShift)
|
||||
);
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
return eventModifiers;
|
||||
}
|
||||
|
||||
eventMatchesKeyAndModifiers(event, key, modifiers = []) {
|
||||
const eventModifiers = this.modifiersForEvent(event);
|
||||
|
||||
if(eventModifiers.length != modifiers.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for(const modifier of modifiers) {
|
||||
if(!eventModifiers.includes(modifier)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Modifers match, check key
|
||||
if(!key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In the browser, shift + f results in key 'f', but in Electron, shift + f results in 'F'
|
||||
// In our case we don't differentiate between the two.
|
||||
return key.toLowerCase() == event.key.toLowerCase();
|
||||
}
|
||||
|
||||
notifyObserver(event, keyEventType) {
|
||||
for(const observer of this.observers) {
|
||||
if(observer.element && event.target != observer.element) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(observer.elements && !observer.elements.includes(event.target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(observer.notElement && observer.notElement == event.target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(observer.notElementIds && observer.notElementIds.includes(event.target.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(this.eventMatchesKeyAndModifiers(event, observer.key, observer.modifiers)) {
|
||||
const callback = keyEventType == KeyboardManager.KeyEventDown ? observer.onKeyDown : observer.onKeyUp;
|
||||
if(callback) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(event) {
|
||||
this.notifyObserver(event, KeyboardManager.KeyEventDown);
|
||||
}
|
||||
|
||||
handleKeyUp(event) {
|
||||
this.notifyObserver(event, KeyboardManager.KeyEventUp);
|
||||
}
|
||||
|
||||
addKeyObserver({key, modifiers, onKeyDown, onKeyUp, element, elements, notElement, notElementIds}) {
|
||||
const observer = {key, modifiers, onKeyDown, onKeyUp, element, elements, notElement, notElementIds};
|
||||
this.observers.push(observer);
|
||||
return observer;
|
||||
}
|
||||
|
||||
removeKeyObserver(observer) {
|
||||
this.observers.splice(this.observers.indexOf(observer), 1);
|
||||
}
|
||||
}
|
||||
147
app/assets/javascripts/services/keyboardManager.ts
Normal file
147
app/assets/javascripts/services/keyboardManager.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { removeFromArray } from 'snjs';
|
||||
export enum KeyboardKey {
|
||||
Tab = "Tab",
|
||||
Backspace = "Backspace",
|
||||
Up = "ArrowUp",
|
||||
Down = "ArrowDown",
|
||||
};
|
||||
|
||||
export enum KeyboardModifier {
|
||||
Shift = "Shift",
|
||||
Ctrl = "Control",
|
||||
/** ⌘ key on Mac, ⊞ key on Windows */
|
||||
Meta = "Meta",
|
||||
Alt = "Alt",
|
||||
};
|
||||
|
||||
enum KeyboardKeyEvent {
|
||||
Down = "KeyEventDown",
|
||||
Up = "KeyEventUp"
|
||||
};
|
||||
|
||||
type KeyboardObserver = {
|
||||
key?: KeyboardKey | string
|
||||
modifiers?: KeyboardModifier[]
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onKeyUp?: (event: KeyboardEvent) => void
|
||||
element?: HTMLElement
|
||||
elements?: HTMLElement[]
|
||||
notElement?: HTMLElement
|
||||
notElementIds?: string[]
|
||||
}
|
||||
|
||||
export class KeyboardManager {
|
||||
|
||||
private observers: KeyboardObserver[] = []
|
||||
private handleKeyDown: any
|
||||
private handleKeyUp: any
|
||||
|
||||
constructor() {
|
||||
this.handleKeyDown = (event: KeyboardEvent) => {
|
||||
this.notifyObserver(event, KeyboardKeyEvent.Down);
|
||||
}
|
||||
this.handleKeyUp = (event: KeyboardEvent) => {
|
||||
this.notifyObserver(event, KeyboardKeyEvent.Up);
|
||||
}
|
||||
window.addEventListener('keydown', this.handleKeyDown);
|
||||
window.addEventListener('keyup', this.handleKeyUp);
|
||||
}
|
||||
|
||||
public deinit() {
|
||||
this.observers.length = 0;
|
||||
window.removeEventListener('keydown', this.handleKeyDown);
|
||||
window.removeEventListener('keyup', this.handleKeyUp);
|
||||
this.handleKeyDown = undefined;
|
||||
this.handleKeyUp = undefined;
|
||||
}
|
||||
|
||||
modifiersForEvent(event: KeyboardEvent) {
|
||||
const allModifiers = Object.values(KeyboardModifier);
|
||||
const eventModifiers = allModifiers.filter((modifier) => {
|
||||
// For a modifier like ctrlKey, must check both event.ctrlKey and event.key.
|
||||
// That's because on keyup, event.ctrlKey would be false, but event.key == Control would be true.
|
||||
const matches = (
|
||||
(
|
||||
(event.ctrlKey || event.key === KeyboardModifier.Ctrl)
|
||||
&& modifier === KeyboardModifier.Ctrl
|
||||
) ||
|
||||
(
|
||||
(event.metaKey || event.key === KeyboardModifier.Meta)
|
||||
&& modifier === KeyboardModifier.Meta
|
||||
) ||
|
||||
(
|
||||
(event.altKey || event.key === KeyboardModifier.Alt)
|
||||
&& modifier === KeyboardModifier.Alt
|
||||
) ||
|
||||
(
|
||||
(event.shiftKey || event.key === KeyboardModifier.Shift)
|
||||
&& modifier === KeyboardModifier.Shift
|
||||
)
|
||||
);
|
||||
|
||||
return matches;
|
||||
});
|
||||
|
||||
return eventModifiers;
|
||||
}
|
||||
|
||||
eventMatchesKeyAndModifiers(
|
||||
event: KeyboardEvent,
|
||||
key: KeyboardKey | string,
|
||||
modifiers: KeyboardModifier[] = []
|
||||
) {
|
||||
const eventModifiers = this.modifiersForEvent(event);
|
||||
if (eventModifiers.length !== modifiers.length) {
|
||||
return false;
|
||||
}
|
||||
for (const modifier of modifiers) {
|
||||
if (!eventModifiers.includes(modifier)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Modifers match, check key
|
||||
if (!key) {
|
||||
return true;
|
||||
}
|
||||
// In the browser, shift + f results in key 'f', but in Electron, shift + f results in 'F'
|
||||
// In our case we don't differentiate between the two.
|
||||
return key.toLowerCase() === event.key.toLowerCase();
|
||||
}
|
||||
|
||||
notifyObserver(event: KeyboardEvent, keyEvent: KeyboardKeyEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
for (const observer of this.observers) {
|
||||
if (observer.element && event.target !== observer.element) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (observer.elements && !observer.elements.includes(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (observer.notElement && observer.notElement === event.target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (observer.notElementIds && observer.notElementIds.includes(target.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.eventMatchesKeyAndModifiers(event, observer.key!, observer.modifiers)) {
|
||||
const callback = keyEvent === KeyboardKeyEvent.Down
|
||||
? observer.onKeyDown
|
||||
: observer.onKeyUp;
|
||||
if (callback) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addKeyObserver(observer: KeyboardObserver) {
|
||||
this.observers.push(observer);
|
||||
return () => {
|
||||
removeFromArray(this.observers, observer);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import { isDesktopApplication } from '@/utils';
|
||||
import { SFMigrationManager } from 'snjs';
|
||||
import { ComponentManager } from '@/services/componentManager';
|
||||
|
||||
export class MigrationManager extends SFMigrationManager {
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
modelManager,
|
||||
syncManager,
|
||||
componentManager,
|
||||
storageManager,
|
||||
statusManager,
|
||||
authManager,
|
||||
desktopManager
|
||||
) {
|
||||
super(modelManager, syncManager, storageManager, authManager);
|
||||
this.componentManager = componentManager;
|
||||
this.statusManager = statusManager;
|
||||
this.desktopManager = desktopManager;
|
||||
}
|
||||
|
||||
registeredMigrations() {
|
||||
return [
|
||||
this.editorToComponentMigration(),
|
||||
this.componentUrlToHostedUrl(),
|
||||
this.removeTagReferencesFromNotes()
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
Migrate SN|Editor to SN|Component. Editors are deprecated as of November 2017. Editors using old APIs must
|
||||
convert to using the new component API.
|
||||
*/
|
||||
|
||||
editorToComponentMigration() {
|
||||
return {
|
||||
name: "editor-to-component",
|
||||
content_type: "SN|Editor",
|
||||
handler: async (editors) => {
|
||||
// Convert editors to components
|
||||
for(var editor of editors) {
|
||||
// If there's already a component for this url, then skip this editor
|
||||
if(editor.url && !this.componentManager.componentForUrl(editor.url)) {
|
||||
var component = this.modelManager.createItem({
|
||||
content_type: "SN|Component",
|
||||
content: {
|
||||
url: editor.url,
|
||||
name: editor.name,
|
||||
area: "editor-editor"
|
||||
}
|
||||
});
|
||||
component.setAppDataItem("data", editor.data);
|
||||
this.modelManager.addItem(component);
|
||||
this.modelManager.setItemDirty(component, true);
|
||||
}
|
||||
}
|
||||
|
||||
for(const editor of editors) {
|
||||
this.modelManager.setItemToBeDeleted(editor);
|
||||
}
|
||||
|
||||
this.syncManager.sync();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
Migrate component.url fields to component.hosted_url. This involves rewriting any note data that relied on the
|
||||
component.url value to store clientData, such as the CodeEditor, which stores the programming language for the note
|
||||
in the note's clientData[component.url]. We want to rewrite any matching items to transfer that clientData into
|
||||
clientData[component.uuid].
|
||||
|
||||
April 3, 2019 note: it seems this migration is mis-named. The first part of the description doesn't match what the code is actually doing.
|
||||
It has nothing to do with url/hosted_url relationship and more to do with just mapping client data from the note's hosted_url to its uuid
|
||||
|
||||
Created: July 6, 2018
|
||||
*/
|
||||
componentUrlToHostedUrl() {
|
||||
return {
|
||||
name: "component-url-to-hosted-url",
|
||||
content_type: "SN|Component",
|
||||
handler: async (components) => {
|
||||
let hasChanges = false;
|
||||
const notes = this.modelManager.validItemsForContentType("Note");
|
||||
for(const note of notes) {
|
||||
for(const component of components) {
|
||||
const clientData = note.getDomainDataItem(component.hosted_url, ComponentManager.ClientDataDomain);
|
||||
if(clientData) {
|
||||
note.setDomainDataItem(component.uuid, clientData, ComponentManager.ClientDataDomain);
|
||||
note.setDomainDataItem(component.hosted_url, null, ComponentManager.ClientDataDomain);
|
||||
this.modelManager.setItemDirty(note, true);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(hasChanges) {
|
||||
this.syncManager.sync();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
Migrate notes which have relationships on tags to migrate those relationships to the tags themselves.
|
||||
That is, notes.content.references should not include any mention of tags.
|
||||
This will apply to notes created before the schema change. Now, only tags reference notes.
|
||||
Created: April 3, 2019
|
||||
*/
|
||||
removeTagReferencesFromNotes() {
|
||||
return {
|
||||
name: "remove-tag-references-from-notes",
|
||||
content_type: "Note",
|
||||
handler: async (notes) => {
|
||||
|
||||
const needsSync = false;
|
||||
let status = this.statusManager.addStatusFromString("Optimizing data...");
|
||||
let dirtyCount = 0;
|
||||
|
||||
for(const note of notes) {
|
||||
if(!note.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const references = note.content.references;
|
||||
// Remove any tag references, and transfer them to the tag if neccessary.
|
||||
const newReferences = [];
|
||||
|
||||
for(const reference of references) {
|
||||
if(reference.content_type != "Tag") {
|
||||
newReferences.push(reference);
|
||||
continue;
|
||||
}
|
||||
|
||||
// is Tag content_type, we will not be adding this to newReferences
|
||||
const tag = this.modelManager.findItem(reference.uuid);
|
||||
if(tag && !tag.hasRelationshipWithItem(note)) {
|
||||
tag.addItemAsRelationship(note);
|
||||
this.modelManager.setItemDirty(tag, true);
|
||||
dirtyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if(newReferences.length != references.length) {
|
||||
note.content.references = newReferences;
|
||||
this.modelManager.setItemDirty(note, true);
|
||||
dirtyCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if(dirtyCount > 0) {
|
||||
if(isDesktopApplication()) {
|
||||
this.desktopManager.saveBackup();
|
||||
}
|
||||
|
||||
status = this.statusManager.replaceStatusWithString(status, `${dirtyCount} items optimized.`);
|
||||
await this.syncManager.sync();
|
||||
|
||||
status = this.statusManager.replaceStatusWithString(status, `Optimization complete.`);
|
||||
setTimeout(() => {
|
||||
this.statusManager.removeStatus(status);
|
||||
}, 2000);
|
||||
} else {
|
||||
this.statusManager.removeStatus(status);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import { SFModelManager, SNSmartTag, SFPredicate } from 'snjs';
|
||||
|
||||
export class ModelManager extends SFModelManager {
|
||||
/* @ngInject */
|
||||
constructor(storageManager, $timeout) {
|
||||
super($timeout);
|
||||
this.notes = [];
|
||||
this.tags = [];
|
||||
this.components = [];
|
||||
|
||||
this.storageManager = storageManager;
|
||||
|
||||
this.buildSystemSmartTags();
|
||||
}
|
||||
|
||||
handleSignout() {
|
||||
super.handleSignout();
|
||||
this.notes.length = 0;
|
||||
this.tags.length = 0;
|
||||
this.components.length = 0;
|
||||
}
|
||||
|
||||
noteCount() {
|
||||
return this.notes.filter((n) => !n.dummy).length;
|
||||
}
|
||||
|
||||
removeAllItemsFromMemory() {
|
||||
for(var item of this.items) {
|
||||
item.deleted = true;
|
||||
}
|
||||
this.notifySyncObserversOfModels(this.items);
|
||||
this.handleSignout();
|
||||
}
|
||||
|
||||
findTag(title) {
|
||||
return _.find(this.tags, { title: title });
|
||||
}
|
||||
|
||||
findOrCreateTagByTitle(title) {
|
||||
let tag = this.findTag(title);
|
||||
if(!tag) {
|
||||
tag = this.createItem({content_type: "Tag", content: {title: title}});
|
||||
this.addItem(tag);
|
||||
this.setItemDirty(tag, true);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
addItems(items, globalOnly = false) {
|
||||
super.addItems(items, globalOnly);
|
||||
|
||||
items.forEach((item) => {
|
||||
// In some cases, you just want to add the item to this.items, and not to the individual arrays
|
||||
// This applies when you want to keep an item syncable, but not display it via the individual arrays
|
||||
if(!globalOnly) {
|
||||
if(item.content_type == "Tag") {
|
||||
if(!_.find(this.tags, {uuid: item.uuid})) {
|
||||
this.tags.splice(_.sortedIndexBy(this.tags, item, function(item){
|
||||
if (item.title) return item.title.toLowerCase();
|
||||
else return '';
|
||||
}), 0, item);
|
||||
}
|
||||
} else if(item.content_type == "Note") {
|
||||
if(!_.find(this.notes, {uuid: item.uuid})) {
|
||||
this.notes.unshift(item);
|
||||
}
|
||||
} else if(item.content_type == "SN|Component") {
|
||||
if(!_.find(this.components, {uuid: item.uuid})) {
|
||||
this.components.unshift(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
resortTag(tag) {
|
||||
_.pull(this.tags, tag);
|
||||
this.tags.splice(_.sortedIndexBy(this.tags, tag, function(tag){
|
||||
if (tag.title) return tag.title.toLowerCase();
|
||||
else return '';
|
||||
}), 0, tag);
|
||||
}
|
||||
|
||||
setItemToBeDeleted(item) {
|
||||
super.setItemToBeDeleted(item);
|
||||
|
||||
// remove from relevant array, but don't remove from all items.
|
||||
// This way, it's removed from the display, but still synced via get dirty items
|
||||
this.removeItemFromRespectiveArray(item);
|
||||
}
|
||||
|
||||
removeItemLocally(item, callback) {
|
||||
super.removeItemLocally(item, callback);
|
||||
this.removeItemFromRespectiveArray(item);
|
||||
this.storageManager.deleteModel(item).then(callback);
|
||||
}
|
||||
|
||||
removeItemFromRespectiveArray(item) {
|
||||
if(item.content_type == "Tag") {
|
||||
_.remove(this.tags, {uuid: item.uuid});
|
||||
} else if(item.content_type == "Note") {
|
||||
_.remove(this.notes, {uuid: item.uuid});
|
||||
} else if(item.content_type == "SN|Component") {
|
||||
_.remove(this.components, {uuid: item.uuid});
|
||||
}
|
||||
}
|
||||
|
||||
notesMatchingSmartTag(tag) {
|
||||
const contentTypePredicate = new SFPredicate("content_type", "=", "Note");
|
||||
const predicates = [contentTypePredicate, tag.content.predicate];
|
||||
if(!tag.content.isTrashTag) {
|
||||
const notTrashedPredicate = new SFPredicate("content.trashed", "=", false);
|
||||
predicates.push(notTrashedPredicate);
|
||||
}
|
||||
const results = this.itemsMatchingPredicates(predicates);
|
||||
return results;
|
||||
}
|
||||
|
||||
trashSmartTag() {
|
||||
return this.systemSmartTags.find((tag) => tag.content.isTrashTag);
|
||||
}
|
||||
|
||||
trashedItems() {
|
||||
return this.notesMatchingSmartTag(this.trashSmartTag());
|
||||
}
|
||||
|
||||
emptyTrash() {
|
||||
const notes = this.trashedItems();
|
||||
for(const note of notes) {
|
||||
this.setItemToBeDeleted(note);
|
||||
}
|
||||
}
|
||||
|
||||
buildSystemSmartTags() {
|
||||
this.systemSmartTags = SNSmartTag.systemSmartTags();
|
||||
}
|
||||
|
||||
getSmartTagWithId(id) {
|
||||
return this.getSmartTags().find((candidate) => candidate.uuid == id);
|
||||
}
|
||||
|
||||
getSmartTags() {
|
||||
const userTags = this.validItemsForContentType("SN|SmartTag").sort((a, b) => {
|
||||
return a.content.title < b.content.title ? -1 : 1;
|
||||
});
|
||||
return this.systemSmartTags.concat(userTags);
|
||||
}
|
||||
|
||||
/*
|
||||
Misc
|
||||
*/
|
||||
|
||||
humanReadableDisplayForContentType(contentType) {
|
||||
return {
|
||||
"Note" : "note",
|
||||
"Tag" : "tag",
|
||||
"SN|SmartTag": "smart tag",
|
||||
"Extension" : "action-based extension",
|
||||
"SN|Component" : "component",
|
||||
"SN|Editor" : "editor",
|
||||
"SN|Theme" : "theme",
|
||||
"SF|Extension" : "server extension",
|
||||
"SF|MFA" : "two-factor authentication setting",
|
||||
"SN|FileSafe|Credentials": "FileSafe credential",
|
||||
"SN|FileSafe|FileMetadata": "FileSafe file",
|
||||
"SN|FileSafe|Integration": "FileSafe integration"
|
||||
}[contentType];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
/* A class for handling installation of system extensions */
|
||||
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { SFPredicate } from 'snjs';
|
||||
|
||||
export class NativeExtManager {
|
||||
/* @ngInject */
|
||||
constructor(modelManager, syncManager, singletonManager) {
|
||||
this.modelManager = modelManager;
|
||||
this.syncManager = syncManager;
|
||||
this.singletonManager = singletonManager;
|
||||
|
||||
this.extManagerId = "org.standardnotes.extensions-manager";
|
||||
this.batchManagerId = "org.standardnotes.batch-manager";
|
||||
this.systemExtensions = [];
|
||||
|
||||
this.resolveExtensionsManager();
|
||||
this.resolveBatchManager();
|
||||
}
|
||||
|
||||
isSystemExtension(extension) {
|
||||
return this.systemExtensions.includes(extension.uuid);
|
||||
}
|
||||
|
||||
resolveExtensionsManager() {
|
||||
|
||||
const contentTypePredicate = new SFPredicate("content_type", "=", "SN|Component");
|
||||
const packagePredicate = new SFPredicate("package_info.identifier", "=", this.extManagerId);
|
||||
|
||||
this.singletonManager.registerSingleton([contentTypePredicate, packagePredicate], (resolvedSingleton) => {
|
||||
// Resolved Singleton
|
||||
this.systemExtensions.push(resolvedSingleton.uuid);
|
||||
|
||||
var needsSync = false;
|
||||
if(isDesktopApplication()) {
|
||||
if(!resolvedSingleton.local_url) {
|
||||
resolvedSingleton.local_url = window._extensions_manager_location;
|
||||
needsSync = true;
|
||||
}
|
||||
} else {
|
||||
if(!resolvedSingleton.hosted_url) {
|
||||
resolvedSingleton.hosted_url = window._extensions_manager_location;
|
||||
needsSync = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle addition of SN|ExtensionRepo permission
|
||||
const permission = resolvedSingleton.content.permissions.find((p) => p.name == "stream-items");
|
||||
if(!permission.content_types.includes("SN|ExtensionRepo")) {
|
||||
permission.content_types.push("SN|ExtensionRepo");
|
||||
needsSync = true;
|
||||
}
|
||||
|
||||
if(needsSync) {
|
||||
this.modelManager.setItemDirty(resolvedSingleton, true);
|
||||
this.syncManager.sync();
|
||||
}
|
||||
}, (valueCallback) => {
|
||||
// Safe to create. Create and return object.
|
||||
const url = window._extensions_manager_location;
|
||||
if(!url) {
|
||||
console.error("window._extensions_manager_location must be set.");
|
||||
return;
|
||||
}
|
||||
|
||||
const packageInfo = {
|
||||
name: "Extensions",
|
||||
identifier: this.extManagerId
|
||||
};
|
||||
|
||||
var item = {
|
||||
content_type: "SN|Component",
|
||||
content: {
|
||||
name: packageInfo.name,
|
||||
area: "rooms",
|
||||
package_info: packageInfo,
|
||||
permissions: [
|
||||
{
|
||||
name: "stream-items",
|
||||
content_types: [
|
||||
"SN|Component", "SN|Theme", "SF|Extension",
|
||||
"Extension", "SF|MFA", "SN|Editor", "SN|ExtensionRepo"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
if(isDesktopApplication()) {
|
||||
item.content.local_url = window._extensions_manager_location;
|
||||
} else {
|
||||
item.content.hosted_url = window._extensions_manager_location;
|
||||
}
|
||||
|
||||
var component = this.modelManager.createItem(item);
|
||||
this.modelManager.addItem(component);
|
||||
|
||||
this.modelManager.setItemDirty(component, true);
|
||||
this.syncManager.sync();
|
||||
|
||||
this.systemExtensions.push(component.uuid);
|
||||
|
||||
valueCallback(component);
|
||||
});
|
||||
}
|
||||
|
||||
resolveBatchManager() {
|
||||
|
||||
const contentTypePredicate = new SFPredicate("content_type", "=", "SN|Component");
|
||||
const packagePredicate = new SFPredicate("package_info.identifier", "=", this.batchManagerId);
|
||||
|
||||
this.singletonManager.registerSingleton([contentTypePredicate, packagePredicate], (resolvedSingleton) => {
|
||||
// Resolved Singleton
|
||||
this.systemExtensions.push(resolvedSingleton.uuid);
|
||||
|
||||
var needsSync = false;
|
||||
if(isDesktopApplication()) {
|
||||
if(!resolvedSingleton.local_url) {
|
||||
resolvedSingleton.local_url = window._batch_manager_location;
|
||||
needsSync = true;
|
||||
}
|
||||
} else {
|
||||
if(!resolvedSingleton.hosted_url) {
|
||||
resolvedSingleton.hosted_url = window._batch_manager_location;
|
||||
needsSync = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(needsSync) {
|
||||
this.modelManager.setItemDirty(resolvedSingleton, true);
|
||||
this.syncManager.sync();
|
||||
}
|
||||
}, (valueCallback) => {
|
||||
// Safe to create. Create and return object.
|
||||
const url = window._batch_manager_location;
|
||||
if(!url) {
|
||||
console.error("window._batch_manager_location must be set.");
|
||||
return;
|
||||
}
|
||||
|
||||
const packageInfo = {
|
||||
name: "Batch Manager",
|
||||
identifier: this.batchManagerId
|
||||
};
|
||||
|
||||
var item = {
|
||||
content_type: "SN|Component",
|
||||
content: {
|
||||
name: packageInfo.name,
|
||||
area: "modal",
|
||||
package_info: packageInfo,
|
||||
permissions: [
|
||||
{
|
||||
name: "stream-items",
|
||||
content_types: [
|
||||
"Note", "Tag", "SN|SmartTag",
|
||||
"SN|Component", "SN|Theme", "SN|UserPreferences",
|
||||
"SF|Extension", "Extension", "SF|MFA", "SN|Editor",
|
||||
"SN|FileSafe|Credentials", "SN|FileSafe|FileMetadata", "SN|FileSafe|Integration"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
if(isDesktopApplication()) {
|
||||
item.content.local_url = window._batch_manager_location;
|
||||
} else {
|
||||
item.content.hosted_url = window._batch_manager_location;
|
||||
}
|
||||
|
||||
var component = this.modelManager.createItem(item);
|
||||
this.modelManager.addItem(component);
|
||||
|
||||
this.modelManager.setItemDirty(component, true);
|
||||
this.syncManager.sync();
|
||||
|
||||
this.systemExtensions.push(component.uuid);
|
||||
|
||||
valueCallback(component);
|
||||
});
|
||||
}
|
||||
}
|
||||
206
app/assets/javascripts/services/nativeExtManager.ts
Normal file
206
app/assets/javascripts/services/nativeExtManager.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { isDesktopApplication } from '@/utils';
|
||||
import {
|
||||
SNPredicate,
|
||||
ContentType,
|
||||
SNComponent,
|
||||
ApplicationService,
|
||||
ComponentAction,
|
||||
FillItemContent,
|
||||
ComponentMutator,
|
||||
Copy,
|
||||
dictToArray
|
||||
} from 'snjs';
|
||||
import { PayloadContent } from 'snjs/dist/@types/protocol/payloads/generator';
|
||||
import { ComponentPermission } from 'snjs/dist/@types/models/app/component';
|
||||
|
||||
/** A class for handling installation of system extensions */
|
||||
export class NativeExtManager extends ApplicationService {
|
||||
extManagerId = 'org.standardnotes.extensions-manager';
|
||||
batchManagerId = 'org.standardnotes.batch-manager';
|
||||
|
||||
/** @override */
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.reload();
|
||||
}
|
||||
|
||||
get extManagerPred() {
|
||||
const extManagerId = 'org.standardnotes.extensions-manager';
|
||||
return SNPredicate.CompoundPredicate([
|
||||
new SNPredicate('content_type', '=', ContentType.Component),
|
||||
new SNPredicate('package_info.identifier', '=', extManagerId)
|
||||
]);
|
||||
}
|
||||
|
||||
get batchManagerPred() {
|
||||
const batchMgrId = 'org.standardnotes.batch-manager';
|
||||
return SNPredicate.CompoundPredicate([
|
||||
new SNPredicate('content_type', '=', ContentType.Component),
|
||||
new SNPredicate('package_info.identifier', '=', batchMgrId)
|
||||
]);
|
||||
}
|
||||
|
||||
get extMgrUrl() {
|
||||
return (window as any)._extensions_manager_location;
|
||||
}
|
||||
|
||||
get batchMgrUrl() {
|
||||
return (window as any)._batch_manager_location;
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.application!.singletonManager!.registerPredicate(this.extManagerPred);
|
||||
this.application!.singletonManager!.registerPredicate(this.batchManagerPred);
|
||||
this.resolveExtensionsManager();
|
||||
this.resolveBatchManager();
|
||||
}
|
||||
|
||||
async resolveExtensionsManager() {
|
||||
const extensionsManager = (await this.application!.singletonManager!.findOrCreateSingleton(
|
||||
this.extManagerPred,
|
||||
ContentType.Component,
|
||||
this.extensionsManagerTemplateContent()
|
||||
)) as SNComponent;
|
||||
let needsSync = false;
|
||||
if (isDesktopApplication()) {
|
||||
if (!extensionsManager.local_url) {
|
||||
await this.application!.changeItem(extensionsManager.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.local_url = this.extMgrUrl;
|
||||
});
|
||||
needsSync = true;
|
||||
}
|
||||
} else {
|
||||
if (!extensionsManager.hosted_url) {
|
||||
await this.application!.changeItem(extensionsManager.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.hosted_url = this.extMgrUrl;
|
||||
});
|
||||
needsSync = true;
|
||||
}
|
||||
}
|
||||
// Handle addition of SN|ExtensionRepo permission
|
||||
const permissions = Copy(extensionsManager!.permissions) as ComponentPermission[];
|
||||
const permission = permissions.find((p) => {
|
||||
return p.name === ComponentAction.StreamItems
|
||||
});
|
||||
if (permission && !permission.content_types!.includes(ContentType.ExtensionRepo)) {
|
||||
permission.content_types!.push(ContentType.ExtensionRepo);
|
||||
await this.application!.changeItem(extensionsManager.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.permissions = permissions;
|
||||
});
|
||||
needsSync = true;
|
||||
}
|
||||
if (needsSync) {
|
||||
this.application!.saveItem(extensionsManager.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
extensionsManagerTemplateContent() {
|
||||
const url = this.extMgrUrl;
|
||||
if (!url) {
|
||||
throw Error('this.extMgrUrl must be set.');
|
||||
}
|
||||
const packageInfo = {
|
||||
name: 'Extensions',
|
||||
identifier: this.extManagerId
|
||||
};
|
||||
const content = FillItemContent({
|
||||
name: packageInfo.name,
|
||||
area: 'rooms',
|
||||
package_info: packageInfo,
|
||||
permissions: [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: [
|
||||
ContentType.Component,
|
||||
ContentType.Theme,
|
||||
ContentType.ServerExtension,
|
||||
ContentType.ActionsExtension,
|
||||
ContentType.Mfa,
|
||||
ContentType.Editor,
|
||||
ContentType.ExtensionRepo
|
||||
]
|
||||
}
|
||||
]
|
||||
}) as PayloadContent;
|
||||
if (isDesktopApplication()) {
|
||||
content.local_url = this.extMgrUrl;
|
||||
} else {
|
||||
content.hosted_url = this.extMgrUrl;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async resolveBatchManager() {
|
||||
const batchManager = (await this.application!.singletonManager!.findOrCreateSingleton(
|
||||
this.batchManagerPred,
|
||||
ContentType.Component,
|
||||
this.batchManagerTemplateContent()
|
||||
)) as SNComponent;
|
||||
let needsSync = false;
|
||||
if (isDesktopApplication()) {
|
||||
if (!batchManager.local_url) {
|
||||
await this.application!.changeItem(batchManager.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.local_url = this.batchMgrUrl;
|
||||
});
|
||||
needsSync = true;
|
||||
}
|
||||
} else {
|
||||
if (!batchManager.hosted_url) {
|
||||
await this.application!.changeItem(batchManager.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.hosted_url = this.batchMgrUrl;
|
||||
});
|
||||
needsSync = true;
|
||||
}
|
||||
}
|
||||
// Handle addition of SN|ExtensionRepo permission
|
||||
const permissions = Copy(batchManager!.permissions) as ComponentPermission[];
|
||||
const permission = permissions.find((p) => {
|
||||
return p.name === ComponentAction.StreamItems
|
||||
});
|
||||
if (permission && !permission.content_types!.includes(ContentType.ExtensionRepo)) {
|
||||
permission.content_types!.push(ContentType.ExtensionRepo);
|
||||
await this.application!.changeItem(batchManager.uuid, (m) => {
|
||||
const mutator = m as ComponentMutator;
|
||||
mutator.permissions = permissions;
|
||||
});
|
||||
needsSync = true;
|
||||
}
|
||||
if (needsSync) {
|
||||
this.application!.saveItem(batchManager.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
batchManagerTemplateContent() {
|
||||
const url = this.batchMgrUrl;
|
||||
if (!url) {
|
||||
throw Error('window._batch_manager_location must be set.');
|
||||
}
|
||||
const packageInfo = {
|
||||
name: 'Batch Manager',
|
||||
identifier: this.batchManagerId
|
||||
};
|
||||
const allContentType = dictToArray(ContentType);
|
||||
const content = FillItemContent({
|
||||
name: packageInfo.name,
|
||||
area: 'modal',
|
||||
package_info: packageInfo,
|
||||
permissions: [
|
||||
{
|
||||
name: ComponentAction.StreamItems,
|
||||
content_types: allContentType
|
||||
}
|
||||
]
|
||||
});
|
||||
if (isDesktopApplication()) {
|
||||
content.local_url = this.batchMgrUrl;
|
||||
} else {
|
||||
content.hosted_url = this.batchMgrUrl;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import { isDesktopApplication } from '@/utils';
|
||||
import { StorageManager } from './storageManager';
|
||||
import { protocolManager } from 'snjs';
|
||||
|
||||
const MillisecondsPerSecond = 1000;
|
||||
|
||||
export class PasscodeManager {
|
||||
/* @ngInject */
|
||||
constructor($rootScope, authManager, storageManager, syncManager) {
|
||||
this.authManager = authManager;
|
||||
this.storageManager = storageManager;
|
||||
this.syncManager = syncManager;
|
||||
this.$rootScope = $rootScope;
|
||||
|
||||
this._hasPasscode = this.storageManager.getItemSync("offlineParams", StorageManager.Fixed) != null;
|
||||
this._locked = this._hasPasscode;
|
||||
|
||||
this.visibilityObservers = [];
|
||||
this.passcodeChangeObservers = [];
|
||||
|
||||
this.configureAutoLock();
|
||||
}
|
||||
|
||||
addPasscodeChangeObserver(callback) {
|
||||
this.passcodeChangeObservers.push(callback);
|
||||
}
|
||||
|
||||
lockApplication() {
|
||||
window.location.reload();
|
||||
this.cancelAutoLockTimer();
|
||||
}
|
||||
|
||||
isLocked() {
|
||||
return this._locked;
|
||||
}
|
||||
|
||||
hasPasscode() {
|
||||
return this._hasPasscode;
|
||||
}
|
||||
|
||||
keys() {
|
||||
return this._keys;
|
||||
}
|
||||
|
||||
addVisibilityObserver(callback) {
|
||||
this.visibilityObservers.push(callback);
|
||||
return callback;
|
||||
}
|
||||
|
||||
removeVisibilityObserver(callback) {
|
||||
_.pull(this.visibilityObservers, callback);
|
||||
}
|
||||
|
||||
notifiyVisibilityObservers(visible) {
|
||||
for(const callback of this.visibilityObservers) {
|
||||
callback(visible);
|
||||
}
|
||||
}
|
||||
|
||||
async setAutoLockInterval(interval) {
|
||||
return this.storageManager.setItem(PasscodeManager.AutoLockIntervalKey, JSON.stringify(interval), StorageManager.FixedEncrypted);
|
||||
}
|
||||
|
||||
async getAutoLockInterval() {
|
||||
const interval = await this.storageManager.getItem(PasscodeManager.AutoLockIntervalKey, StorageManager.FixedEncrypted);
|
||||
if(interval) {
|
||||
return JSON.parse(interval);
|
||||
} else {
|
||||
return PasscodeManager.AutoLockIntervalNone;
|
||||
}
|
||||
}
|
||||
|
||||
passcodeAuthParams() {
|
||||
var authParams = JSON.parse(this.storageManager.getItemSync("offlineParams", StorageManager.Fixed));
|
||||
if(authParams && !authParams.version) {
|
||||
var keys = this.keys();
|
||||
if(keys && keys.ak) {
|
||||
// If there's no version stored, and there's an ak, it has to be 002. Newer versions would have their version stored in authParams.
|
||||
authParams.version = "002";
|
||||
} else {
|
||||
authParams.version = "001";
|
||||
}
|
||||
}
|
||||
return authParams;
|
||||
}
|
||||
|
||||
async verifyPasscode(passcode) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
var params = this.passcodeAuthParams();
|
||||
const keys = await protocolManager.computeEncryptionKeysForUser(passcode, params);
|
||||
if(keys.pw !== params.hash) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unlock(passcode, callback) {
|
||||
var params = this.passcodeAuthParams();
|
||||
protocolManager.computeEncryptionKeysForUser(passcode, params).then((keys) => {
|
||||
if(keys.pw !== params.hash) {
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this._keys = keys;
|
||||
this._authParams = params;
|
||||
this.decryptLocalStorage(keys, params).then(() => {
|
||||
this._locked = false;
|
||||
callback(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setPasscode(passcode, callback) {
|
||||
var uuid = protocolManager.crypto.generateUUIDSync();
|
||||
|
||||
protocolManager.generateInitialKeysAndAuthParamsForUser(uuid, passcode).then((results) => {
|
||||
const keys = results.keys;
|
||||
const authParams = results.authParams;
|
||||
|
||||
authParams.hash = keys.pw;
|
||||
this._keys = keys;
|
||||
this._hasPasscode = true;
|
||||
this._authParams = authParams;
|
||||
|
||||
// Encrypting will initially clear localStorage
|
||||
this.encryptLocalStorage(keys, authParams);
|
||||
|
||||
// After it's cleared, it's safe to write to it
|
||||
this.storageManager.setItem("offlineParams", JSON.stringify(authParams), StorageManager.Fixed);
|
||||
callback(true);
|
||||
|
||||
this.notifyObserversOfPasscodeChange();
|
||||
});
|
||||
}
|
||||
|
||||
changePasscode(newPasscode, callback) {
|
||||
this.setPasscode(newPasscode, callback);
|
||||
}
|
||||
|
||||
clearPasscode() {
|
||||
this.storageManager.setItemsMode(this.authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.Fixed); // Transfer from Ephemeral
|
||||
this.storageManager.removeItem("offlineParams", StorageManager.Fixed);
|
||||
this._keys = null;
|
||||
this._hasPasscode = false;
|
||||
|
||||
this.notifyObserversOfPasscodeChange();
|
||||
}
|
||||
|
||||
notifyObserversOfPasscodeChange() {
|
||||
for(var observer of this.passcodeChangeObservers) {
|
||||
observer();
|
||||
}
|
||||
}
|
||||
|
||||
encryptLocalStorage(keys, authParams) {
|
||||
this.storageManager.setKeys(keys, authParams);
|
||||
// Switch to Ephemeral storage, wiping Fixed storage
|
||||
// Last argument is `force`, which we set to true because in the case of changing passcode
|
||||
this.storageManager.setItemsMode(this.authManager.isEphemeralSession() ? StorageManager.Ephemeral : StorageManager.FixedEncrypted, true);
|
||||
}
|
||||
|
||||
async decryptLocalStorage(keys, authParams) {
|
||||
this.storageManager.setKeys(keys, authParams);
|
||||
return this.storageManager.decryptStorage();
|
||||
}
|
||||
|
||||
configureAutoLock() {
|
||||
PasscodeManager.AutoLockPollFocusInterval = 1 * MillisecondsPerSecond;
|
||||
|
||||
PasscodeManager.AutoLockIntervalNone = 0;
|
||||
PasscodeManager.AutoLockIntervalImmediate = 1;
|
||||
PasscodeManager.AutoLockIntervalOneMinute = 60 * MillisecondsPerSecond;
|
||||
PasscodeManager.AutoLockIntervalFiveMinutes = 300 * MillisecondsPerSecond;
|
||||
PasscodeManager.AutoLockIntervalOneHour = 3600 * MillisecondsPerSecond;
|
||||
|
||||
PasscodeManager.AutoLockIntervalKey = "AutoLockIntervalKey";
|
||||
|
||||
if(isDesktopApplication()) {
|
||||
// desktop only
|
||||
this.$rootScope.$on("window-lost-focus", () => {
|
||||
this.documentVisibilityChanged(false);
|
||||
});
|
||||
this.$rootScope.$on("window-gained-focus", () => {
|
||||
this.documentVisibilityChanged(true);
|
||||
});
|
||||
} else {
|
||||
// tab visibility listener, web only
|
||||
document.addEventListener('visibilitychange', (e) => {
|
||||
const visible = document.visibilityState === "visible";
|
||||
this.documentVisibilityChanged(visible);
|
||||
});
|
||||
|
||||
// verify document is in focus every so often as visibilitychange event is not triggered
|
||||
// on a typical window blur event but rather on tab changes
|
||||
this.pollFocusTimeout = setInterval(() => {
|
||||
const hasFocus = document.hasFocus();
|
||||
|
||||
if(hasFocus && this.lastFocusState === "hidden") {
|
||||
this.documentVisibilityChanged(true);
|
||||
} else if(!hasFocus && this.lastFocusState === "visible") {
|
||||
this.documentVisibilityChanged(false);
|
||||
}
|
||||
|
||||
// save this to compare against next time around
|
||||
this.lastFocusState = hasFocus ? "visible" : "hidden";
|
||||
}, PasscodeManager.AutoLockPollFocusInterval);
|
||||
}
|
||||
}
|
||||
|
||||
getAutoLockIntervalOptions() {
|
||||
return [
|
||||
{
|
||||
value: PasscodeManager.AutoLockIntervalNone,
|
||||
label: "Off"
|
||||
},
|
||||
{
|
||||
value: PasscodeManager.AutoLockIntervalImmediate,
|
||||
label: "Immediately"
|
||||
},
|
||||
{
|
||||
value: PasscodeManager.AutoLockIntervalOneMinute,
|
||||
label: "1m"
|
||||
},
|
||||
{
|
||||
value: PasscodeManager.AutoLockIntervalFiveMinutes,
|
||||
label: "5m"
|
||||
},
|
||||
{
|
||||
value: PasscodeManager.AutoLockIntervalOneHour,
|
||||
label: "1h"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
documentVisibilityChanged(visible) {
|
||||
if(visible) {
|
||||
// check to see if lockAfterDate is not null, and if the application isn't locked.
|
||||
// if that's the case, it needs to be locked immediately.
|
||||
if(this.lockAfterDate && new Date() > this.lockAfterDate && !this.isLocked()) {
|
||||
this.lockApplication();
|
||||
} else {
|
||||
if(!this.isLocked()) {
|
||||
this.syncManager.sync();
|
||||
}
|
||||
}
|
||||
this.cancelAutoLockTimer();
|
||||
} else {
|
||||
this.beginAutoLockTimer();
|
||||
}
|
||||
|
||||
this.notifiyVisibilityObservers(visible);
|
||||
}
|
||||
|
||||
async beginAutoLockTimer() {
|
||||
var interval = await this.getAutoLockInterval();
|
||||
if(interval == PasscodeManager.AutoLockIntervalNone) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a timeout if possible, but if the computer is put to sleep, timeouts won't work.
|
||||
// Need to set a date as backup. this.lockAfterDate does not need to be persisted, as
|
||||
// living in memory seems sufficient. If memory is cleared, then the application will lock anyway.
|
||||
const addToNow = (seconds) => {
|
||||
const date = new Date();
|
||||
date.setSeconds(date.getSeconds() + seconds);
|
||||
return date;
|
||||
};
|
||||
|
||||
this.lockAfterDate = addToNow(interval / MillisecondsPerSecond);
|
||||
clearTimeout(this.lockTimeout);
|
||||
this.lockTimeout = setTimeout(() => {
|
||||
this.lockApplication();
|
||||
// We don't need to look at this anymore since we've succeeded with timeout lock
|
||||
this.lockAfterDate = null;
|
||||
}, interval);
|
||||
}
|
||||
|
||||
cancelAutoLockTimer() {
|
||||
clearTimeout(this.lockTimeout);
|
||||
this.lockAfterDate = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import { SFPredicate, SFItem } from 'snjs';
|
||||
|
||||
export const PREF_TAGS_PANEL_WIDTH = 'tagsPanelWidth';
|
||||
export const PREF_NOTES_PANEL_WIDTH = 'notesPanelWidth';
|
||||
export const PREF_EDITOR_WIDTH = 'editorWidth';
|
||||
export const PREF_EDITOR_LEFT = 'editorLeft';
|
||||
export const PREF_EDITOR_MONOSPACE_ENABLED = 'monospaceFont';
|
||||
export const PREF_EDITOR_SPELLCHECK = 'spellcheck';
|
||||
export const PREF_EDITOR_RESIZERS_ENABLED = 'marginResizersEnabled';
|
||||
export const PREF_SORT_NOTES_BY = 'sortBy';
|
||||
export const PREF_SORT_NOTES_REVERSE = 'sortReverse';
|
||||
export const PREF_NOTES_SHOW_ARCHIVED = 'showArchived';
|
||||
export const PREF_NOTES_HIDE_PINNED = 'hidePinned';
|
||||
export const PREF_NOTES_HIDE_NOTE_PREVIEW = 'hideNotePreview';
|
||||
export const PREF_NOTES_HIDE_DATE = 'hideDate';
|
||||
export const PREF_NOTES_HIDE_TAGS = 'hideTags';
|
||||
|
||||
export class PreferencesManager {
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
modelManager,
|
||||
singletonManager,
|
||||
appState,
|
||||
syncManager
|
||||
) {
|
||||
this.singletonManager = singletonManager;
|
||||
this.modelManager = modelManager;
|
||||
this.syncManager = syncManager;
|
||||
this.appState = appState;
|
||||
|
||||
this.modelManager.addItemSyncObserver(
|
||||
'user-prefs',
|
||||
'SN|UserPreferences',
|
||||
(allItems, validItems, deletedItems, source, sourceKey) => {
|
||||
this.preferencesDidChange();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
load() {
|
||||
const prefsContentType = 'SN|UserPreferences';
|
||||
const contentTypePredicate = new SFPredicate(
|
||||
'content_type',
|
||||
'=',
|
||||
prefsContentType
|
||||
);
|
||||
this.singletonManager.registerSingleton(
|
||||
[contentTypePredicate],
|
||||
(resolvedSingleton) => {
|
||||
this.userPreferences = resolvedSingleton;
|
||||
},
|
||||
(valueCallback) => {
|
||||
// Safe to create. Create and return object.
|
||||
const prefs = new SFItem({content_type: prefsContentType});
|
||||
this.modelManager.addItem(prefs);
|
||||
this.modelManager.setItemDirty(prefs);
|
||||
this.syncManager.sync();
|
||||
valueCallback(prefs);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
preferencesDidChange() {
|
||||
this.appState.setUserPreferences(this.userPreferences);
|
||||
}
|
||||
|
||||
syncUserPreferences() {
|
||||
if(this.userPreferences) {
|
||||
this.modelManager.setItemDirty(this.userPreferences);
|
||||
this.syncManager.sync();
|
||||
}
|
||||
}
|
||||
|
||||
getValue(key, defaultValue) {
|
||||
if(!this.userPreferences) { return defaultValue; }
|
||||
const value = this.userPreferences.getAppDataItem(key);
|
||||
return (value !== undefined && value != null) ? value : defaultValue;
|
||||
}
|
||||
|
||||
setUserPrefValue(key, value, sync) {
|
||||
this.userPreferences.setAppDataItem(key, value);
|
||||
if(sync) {
|
||||
this.syncUserPreferences();
|
||||
}
|
||||
}
|
||||
}
|
||||
99
app/assets/javascripts/services/preferencesManager.ts
Normal file
99
app/assets/javascripts/services/preferencesManager.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { WebApplication } from '@/ui_models/application';
|
||||
import {
|
||||
SNPredicate,
|
||||
ContentType,
|
||||
ApplicationService,
|
||||
SNUserPrefs,
|
||||
WebPrefKey,
|
||||
UserPrefsMutator,
|
||||
FillItemContent,
|
||||
ApplicationEvent,
|
||||
} from 'snjs';
|
||||
|
||||
export class PreferencesManager extends ApplicationService {
|
||||
private userPreferences!: SNUserPrefs;
|
||||
private loadingPrefs = false;
|
||||
private unubscribeStreamItems?: () => void;
|
||||
private needsSingletonReload = true;
|
||||
|
||||
/** @override */
|
||||
async onAppLaunch() {
|
||||
super.onAppLaunch();
|
||||
this.reloadSingleton();
|
||||
this.streamPreferences();
|
||||
}
|
||||
|
||||
async onAppEvent(event: ApplicationEvent) {
|
||||
super.onAppEvent(event);
|
||||
if (event === ApplicationEvent.CompletedFullSync) {
|
||||
this.reloadSingleton();
|
||||
}
|
||||
}
|
||||
|
||||
deinit() {
|
||||
this.unubscribeStreamItems?.();
|
||||
}
|
||||
|
||||
get webApplication() {
|
||||
return this.application as WebApplication;
|
||||
}
|
||||
|
||||
streamPreferences() {
|
||||
this.unubscribeStreamItems = this.application!.streamItems(
|
||||
ContentType.UserPrefs,
|
||||
() => {
|
||||
this.needsSingletonReload = true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async reloadSingleton() {
|
||||
if (this.loadingPrefs || !this.needsSingletonReload) {
|
||||
return;
|
||||
}
|
||||
this.loadingPrefs = true;
|
||||
const contentType = ContentType.UserPrefs;
|
||||
const predicate = new SNPredicate('content_type', '=', contentType);
|
||||
const previousRef = this.userPreferences;
|
||||
this.userPreferences = (await this.application!.singletonManager!.findOrCreateSingleton(
|
||||
predicate,
|
||||
contentType,
|
||||
FillItemContent({})
|
||||
)) as SNUserPrefs;
|
||||
this.loadingPrefs = false;
|
||||
this.needsSingletonReload = false;
|
||||
if (
|
||||
previousRef?.uuid !== this.userPreferences.uuid ||
|
||||
this.userPreferences.lastSyncBegan?.getTime() !==
|
||||
previousRef?.lastSyncBegan?.getTime()
|
||||
) {
|
||||
this.webApplication
|
||||
.getAppState()
|
||||
.setUserPreferences(this.userPreferences);
|
||||
}
|
||||
}
|
||||
|
||||
syncUserPreferences() {
|
||||
if (this.userPreferences) {
|
||||
this.application!.saveItem(this.userPreferences.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
getValue(key: WebPrefKey, defaultValue?: any) {
|
||||
if (!this.userPreferences) {
|
||||
return defaultValue;
|
||||
}
|
||||
const value = this.userPreferences.getPref(key);
|
||||
return value !== undefined && value !== null ? value : defaultValue;
|
||||
}
|
||||
|
||||
async setUserPrefValue(key: WebPrefKey, value: any, sync = false) {
|
||||
await this.application!.changeItem(this.userPreferences.uuid, (m) => {
|
||||
const mutator = m as UserPrefsMutator;
|
||||
mutator.setWebPref(key, value);
|
||||
});
|
||||
if (sync) {
|
||||
this.syncUserPreferences();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import { SFPrivilegesManager } from 'snjs';
|
||||
|
||||
export class PrivilegesManager extends SFPrivilegesManager {
|
||||
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
passcodeManager,
|
||||
authManager,
|
||||
syncManager,
|
||||
singletonManager,
|
||||
modelManager,
|
||||
storageManager,
|
||||
$rootScope,
|
||||
$compile
|
||||
) {
|
||||
super(modelManager, syncManager, singletonManager);
|
||||
|
||||
this.$rootScope = $rootScope;
|
||||
this.$compile = $compile;
|
||||
|
||||
this.setDelegate({
|
||||
isOffline: async () => {
|
||||
return authManager.offline();
|
||||
},
|
||||
hasLocalPasscode: async () => {
|
||||
return passcodeManager.hasPasscode();
|
||||
},
|
||||
saveToStorage: async (key, value) => {
|
||||
return storageManager.setItem(key, value, storageManager.bestStorageMode());
|
||||
},
|
||||
getFromStorage: async (key) => {
|
||||
return storageManager.getItem(key, storageManager.bestStorageMode());
|
||||
},
|
||||
verifyAccountPassword: async (password) => {
|
||||
return authManager.verifyAccountPassword(password);
|
||||
},
|
||||
verifyLocalPasscode: async (passcode) => {
|
||||
return passcodeManager.verifyPasscode(passcode);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async presentPrivilegesModal(action, onSuccess, onCancel) {
|
||||
if (this.authenticationInProgress()) {
|
||||
onCancel && onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const customSuccess = async () => {
|
||||
onSuccess && await onSuccess();
|
||||
this.currentAuthenticationElement = null;
|
||||
};
|
||||
const customCancel = async () => {
|
||||
onCancel && await onCancel();
|
||||
this.currentAuthenticationElement = null;
|
||||
};
|
||||
|
||||
const scope = this.$rootScope.$new(true);
|
||||
scope.action = action;
|
||||
scope.onSuccess = customSuccess;
|
||||
scope.onCancel = customCancel;
|
||||
const el = this.$compile(`
|
||||
<privileges-auth-modal action='action' on-success='onSuccess'
|
||||
on-cancel='onCancel' class='sk-modal'></privileges-auth-modal>
|
||||
`)(scope);
|
||||
angular.element(document.body).append(el);
|
||||
|
||||
this.currentAuthenticationElement = el;
|
||||
}
|
||||
|
||||
presentPrivilegesManagementModal() {
|
||||
var scope = this.$rootScope.$new(true);
|
||||
var el = this.$compile("<privileges-management-modal class='sk-modal'></privileges-management-modal>")(scope);
|
||||
angular.element(document.body).append(el);
|
||||
}
|
||||
|
||||
authenticationInProgress() {
|
||||
return this.currentAuthenticationElement != null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue