Merge branch '004' into develop

This commit is contained in:
Baptiste Grob
2020-07-24 12:18:09 +02:00
243 changed files with 43408 additions and 8500 deletions

View File

@@ -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);

View File

@@ -0,0 +1,123 @@
'use strict';
declare const __VERSION__: string
import angular from 'angular';
import { configRoutes } from './routes';
import { ApplicationGroup } from './ui_models/application_group';
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,
SessionHistoryMenu,
SyncResolutionMenu
} from './directives/views';
import { trusted } from './filters';
import { isDev } from './utils';
angular.module('app', ['ngSanitize']);
// Config
angular
.module('app')
.config(configRoutes)
.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('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('sessionHistoryMenu', () => new SessionHistoryMenu())
.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)
.application,
},
});
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -1,7 +0,0 @@
export { PureCtrl } from './abstract/pure_ctrl';
export { EditorPanel } from './editor';
export { Footer } from './footer';
export { NotesPanel } from './notes/notes';
export { TagsPanel } from './tags';
export { Root } from './root';
export { LockScreen } from './lockScreen';

View File

@@ -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: '&',
};
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,220 @@
import { SNAlertService } from "@node_modules/snjs/dist/@types";
const DB_NAME = 'standardnotes';
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 alertService?: SNAlertService
private db?: IDBDatabase
public deinit() {
this.alertService = undefined;
this.db = undefined;
}
public setAlertService(alertService: SNAlertService) {
this.alertService = alertService;
}
/**
* 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(DB_NAME, 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(DB_NAME);
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);
}
}

View File

@@ -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();
}
});
}
};
}

View 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();
}
});
}
};
}

View File

@@ -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;
}
});
}
};
}

View File

@@ -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);
}
};
}

View File

@@ -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;
}
}
};
}

View 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();
});
}
};
}

View File

@@ -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);

View File

@@ -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 });
});
});
}
};
}

View 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
});
});
});
}
};
}

View File

@@ -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';

View File

@@ -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);
}
});
}
};
}

View File

@@ -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);;
});
}
};
}

View File

@@ -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]);
}
};
}

View 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]);
}
};
}

View File

@@ -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);
}
});
}
};
}

View File

@@ -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 */
setImmediate(() => {
input.setSelectionRange(0, input.value.length);
})
}
});
}
};
}

View File

@@ -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 });
});

View File

@@ -0,0 +1,589 @@
import { WebDirective } from './../../types';
import { isDesktopApplication, isNullOrUndefined } 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
} 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 } from '@/services/alertService';
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
mfa: { payload: any }
userMfaCode?: string
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
}
class AccountMenuCtrl extends PureViewCtrl {
public appVersion: string
private syncStatus?: SyncOpStatus
private closeFunction?: () => void
/* @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!.getLockService().getAutoLockIntervalOptions(),
user: this.application!.getUser(),
formData: {
mergeLocal: true,
ephemeral: false
},
mutable: {}
} 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.loadBackupsAvailability();
}
refreshedCredentialState() {
return {
user: this.application!.getUser(),
canAddPasscode: !this.application!.isEphemeralSession(),
hasPasscode: this.application!.hasPasscode(),
showPasscodeForm: false
};
}
$onInit() {
super.$onInit();
this.initProps({
closeFunction: this.closeFunction
});
this.syncStatus = this.application!.getSyncStatus();
}
close() {
this.$timeout(() => {
this.props.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);
}
async loadBackupsAvailability() {
const hasUser = !isNullOrUndefined(this.application!.getUser());
const hasPasscode = this.application!.hasPasscode();
const encryptedAvailable = hasUser || hasPasscode;
function encryptionStatusString() {
if (hasUser) {
return STRING_E2E_ENABLED;
} else if (hasPasscode) {
return STRING_LOCAL_ENC_ENABLED;
} else {
return STRING_ENC_NOT_ENABLED;
}
}
this.setState({
encryptionStatusString: encryptionStatusString(),
encryptionEnabled: encryptedAvailable,
mutable: {
...this.getState().mutable,
backupEncrypted: encryptedAvailable
}
});
}
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.mfa && formData.mfa.payload.mfa_key,
formData.userMfaCode,
formData.mergeLocal
);
const hasError = !response || response.error;
if (!hasError) {
await this.setFormDataState({
authenticating: false,
user_password: undefined
});
this.close();
return;
}
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,
status: undefined
});
} else {
await this.setFormDataState({
showLogin: true,
mfa: undefined,
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
);
if (!response || response.error) {
await this.setFormDataState({
status: undefined
});
const error = response
? response.error
: { message: "An unknown error occured." };
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) {
if (await confirmDialog({
text: STRING_ACCOUNT_MENU_UNCHECK_MERGE,
confirmButtonStyle: 'danger'
})) {
this.setFormDataState({
mergeLocal: true
});
}
}
}
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
);
}
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.auth_params || data.keyParams) {
await this.setState({
importData: {
...this.getState().importData,
requestPassword: true,
data: data
}
});
const element = document.getElementById(
ELEMENT_ID_IMPORT_PASSWORD_INPUT
);
if (element) {
element.scrollIntoView(false);
}
} 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 errorCount = await this.importJSONData(data, password);
this.setState({
importData: null
});
if (errorCount > 0) {
const message = StringImportError(errorCount);
this.application!.alertService!.alert(
message
);
} else {
this.application!.alertService!.alert(
STRING_IMPORT_SUCCESS
);
}
}
async importJSONData(data: BackupFile, password?: string) {
const { errorCount } = await this.application!.importData(
data,
password
);
return errorCount;
}
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!.getLockService().getAutoLockInterval();
this.setState({
selectedAutoLockInterval: interval
});
}
async selectAutoLockInterval(interval: number) {
const run = async () => {
await this.application!.getLockService().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
});
}
submitPasscodeForm() {
const passcode = this.getState().formData.passcode!;
if (passcode !== this.getState().formData.confirmPasscode!) {
this.application!.alertService!.alert(
STRING_NON_MATCHING_PASSCODES
);
return;
}
(this.getState().formData.changingPasscode
? this.application!.changePasscode(passcode)
: this.application!.setPasscode(passcode)
).then(() => {
this.setFormDataState({
passcode: undefined,
confirmPasscode: undefined,
showPasscodeForm: false
});
});
}
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 = !isNullOrUndefined(await this.application!.getUser());
let message = STRING_REMOVE_PASSCODE_CONFIRMATION;
if (!signedIn) {
message += STRING_REMOVE_PASSCODE_OFFLINE_ADDENDUM;
}
if (await confirmDialog({
text: message,
confirmButtonStyle: 'danger'
})) {
this.application!.removePasscode();
}
};
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: '='
};
}
}

View File

@@ -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: '='
};
}
}

View File

@@ -0,0 +1,199 @@
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 } from 'snjs/dist/@types';
import { ActionResponse } from 'snjs/dist/@types/services/actions_service';
import { ActionsExtensionMutator } from 'snjs/dist/@types/models/app/extension';
type ActionsMenuScope = {
application: WebApplication
item: SNItem
}
type ActionSubRow = {
onClick: () => void
label: string
subtitle: string
spinnerClass: string | undefined
}
type UpdateActionParams = {
running?: boolean
error?: boolean
subrows?: ActionSubRow[]
}
type UpdateExtensionParams = {
hidden?: boolean
}
class ActionsMenuCtrl extends PureViewCtrl implements ActionsMenuScope {
application!: WebApplication
item!: SNItem
public loadingExtensions: boolean = true
/* @ngInject */
constructor(
$timeout: ng.ITimeoutService
) {
super($timeout);
this.state = {
extensions: []
};
}
$onInit() {
super.$onInit();
this.initProps({
item: this.item
});
this.loadExtensions();
};
async loadExtensions() {
const actionExtensions = this.application.actionsManager!.getExtensions().sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
});
const extensionsForItem = await Promise.all(actionExtensions.map((extension) => {
return this.application.actionsManager!.loadExtensionInContextOfItem(
extension,
this.props.item
);
}));
if (extensionsForItem.length == 0) {
this.loadingExtensions = false;
}
await this.setState({
extensions: extensionsForItem
});
}
async executeAction(action: Action, extension: SNActionsExtension) {
if (action.verb === 'nested') {
if (!action.subrows) {
const subrows = this.subRowsForAction(action, extension);
await this.updateAction(action, extension, { subrows });
}
return;
}
await this.updateAction(action, extension, { running: true });
const response = await this.application.actionsManager!.runAction(
action,
this.props.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: SNActionsExtension): ActionSubRow[] | undefined {
if (!parentAction.subactions) {
return undefined;
}
return parentAction.subactions.map((subaction) => {
return {
onClick: () => {
this.executeAction(subaction, extension);
},
label: subaction.label,
subtitle: subaction.desc,
spinnerClass: subaction.running ? 'info' : undefined
};
});
}
private async updateAction(
action: Action,
extension: SNActionsExtension,
params: UpdateActionParams
) {
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,
subrows: params?.subrows || act?.subrows,
};
}
return act;
});
}) as SNActionsExtension;
await this.updateExtension(updatedExtension);
}
private async updateExtension(
extension: SNActionsExtension,
params?: UpdateExtensionParams
) {
const updatedExtension = await this.application.changeItem(extension.uuid, (mutator) => {
const extensionMutator = mutator as ActionsExtensionMutator;
extensionMutator.hidden = params && params.hidden;
}) as SNActionsExtension;
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
if (extension.uuid === ext.uuid) {
return updatedExtension;
}
return ext;
});
await this.setState({
extensions: extensions
});
}
private async reloadExtension(extension: SNActionsExtension) {
const extensionInContext = await this.application.actionsManager!.loadExtensionInContextOfItem(
extension,
this.props.item
);
const extensions = this.state.extensions.map((ext: SNActionsExtension) => {
if (extension.uuid === ext.uuid) {
return extensionInContext;
}
return ext;
});
this.setState({
extensions: extensions
});
}
}
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: '='
};
}
}

View File

@@ -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: '&'
};
}
}

View 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: '='
};
}
}

View File

@@ -0,0 +1,250 @@
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
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() {
this.cleanUpOn();
(this.cleanUpOn as any) = undefined;
this.unregisterComponentHandler();
(this.unregisterComponentHandler as any) = undefined;
this.unregisterDesktopObserver();
(this.unregisterDesktopObserver as any) = undefined;
this.liveComponent.deinit();
(this.liveComponent as any) = undefined;
(this.application as any) = undefined;
(this.onVisibilityChange as any) = undefined;
this.onLoad = undefined;
document.removeEventListener(
VisibilityChangeKey,
this.onVisibilityChange
);
}
$onInit() {
this.liveComponent = new LiveItem(this.componentUuid, this.application);
this.registerComponentHandlers();
this.registerPackageUpdateObserver();
}
get component() {
return this.liveComponent?.item;
}
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 'Component view is missing component';
}
if (!this.component.active) {
throw 'Component view component must be active';
}
const iframe = this.application.componentManager!.iframeForComponent(
this.componentUuid
);
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('reload-ext-dat');
}
}
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: '=',
onLoad: '=?',
application: '='
};
this.controller = ComponentViewCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
}
}

View File

@@ -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: '='
};
}
}

View File

@@ -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: '='
};
}
}

View 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 '@node_modules/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: '='
};
}
}

View File

@@ -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';

View File

@@ -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: '&'
};
}
}

View 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: '&'
};
}
}

View File

@@ -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;

View File

@@ -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: '='

View File

@@ -0,0 +1,218 @@
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";
const Steps = {
PasswordStep: 1,
FinishStep: 2
};
class PasswordWizardCtrl extends PureViewCtrl 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;
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: any) {
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.state.formData.status = null;
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.state.formData.status = null;
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() {
this.setState({
lockContinue: true,
processing: true
});
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 || !response.error;
this.setFormDataState({
statusError: !success,
processing: success
});
if (!success) {
this.application.alertService!.alert(
response!.error.message
? response!.error.message
: "There was an error changing your password. Please try again."
);
this.setFormDataState({
status: "Unable to process your password. Please try again."
});
} else {
this.setState({
lockContinue: false,
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: '='
};
}
}

View File

@@ -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;

View File

@@ -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: '='
};
}
}

View 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: '='
};
}
}

View File

@@ -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 = {};
}
}

View File

@@ -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 '@node_modules/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: '='
};
}
}

View File

@@ -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: '='
};
}
}

View File

@@ -0,0 +1,148 @@
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 '@node_modules/snjs/dist/@types/protocol/payloads/generator';
interface RevisionPreviewScope {
uuid: string
content: PayloadContent
application: WebApplication
}
class RevisionPreviewModalCtrl implements RevisionPreviewScope {
$element: JQLite
$timeout: ng.ITimeoutService
uuid!: string
content!: PayloadContent
application!: WebApplication
unregisterComponent?: any
note!: SNNote
editor?: SNComponent
/* @ngInject */
constructor(
$element: JQLite,
$timeout: ng.ITimeoutService
) {
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;
const originalNote = this.application.findItem(this.uuid) as SNNote;
const editorForNote = this.componentManager.editorForNote(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.editor?.uuid) {
return this.note;
}
},
componentForSessionKeyHandler: (key) => {
if (key === this.componentManager.sessionKeyForComponent(this.editor!)) {
return this.editor;
}
}
});
this.editor = editorCopy;
}
}
restore(asCopy: boolean) {
const run = async () => {
if (asCopy) {
const contentCopy = Object.assign({}, this.content);
if (contentCopy.title) {
contentCopy.title += " (copy)";
}
await this.application.createManagedItem(
ContentType.Note,
contentCopy,
true
);
} else {
this.application.changeAndSaveItem(this.uuid, (mutator) => {
mutator.setContent(this.content);
}, true, PayloadSource.RemoteActionRetrieved);
}
this.dismiss();
};
if (!asCopy) {
this.application.alertService!.confirm(
"Are you sure you want to replace the current note's contents with what you see in this preview?",
undefined,
undefined,
undefined,
run,
undefined,
true,
);
} 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: '='
};
}
}

View File

@@ -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: '='
};
}
}

View File

@@ -0,0 +1,143 @@
import { WebDirective } from './../../types';
import { WebApplication } from '@/ui_models/application';
import template from '%/directives/session-history-menu.pug';
import { SNItem, ItemHistoryEntry, ItemHistory } from '@node_modules/snjs/dist/@types';
interface SessionHistoryScope {
application: WebApplication
item: SNItem
}
class SessionHistoryMenuCtrl implements SessionHistoryScope {
$timeout: ng.ITimeoutService
diskEnabled = false
autoOptimize = false
application!: WebApplication
item!: SNItem
entries!: ItemHistoryEntry[]
history!: ItemHistory
/* @ngInject */
constructor(
$timeout: ng.ITimeoutService
) {
this.$timeout = $timeout;
}
$onInit() {
this.reloadHistory();
this.diskEnabled = this.application.historyManager!.isDiskEnabled();
this.autoOptimize = this.application.historyManager!.isAutoOptimizeEnabled();
}
reloadHistory() {
const history = this.application.historyManager!.historyForItem(this.item);
this.entries = history.entries.slice(0).sort((a, b) => {
return a.payload.updated_at! < b.payload.updated_at! ? 1 : -1;
});
this.history = history;
}
openRevision(revision: ItemHistoryEntry) {
this.application.presentRevisionPreviewModal(
revision.payload.uuid,
revision.payload.content
);
}
classForRevision(revision: ItemHistoryEntry) {
const vector = revision.operationVector();
if (vector === 0) {
return 'default';
} else if (vector === 1) {
return 'success';
} else if (vector === -1) {
return 'danger';
}
}
clearItemHistory() {
this.application.alertService!.confirm(
"Are you sure you want to delete the local session history for this note?",
undefined,
undefined,
undefined,
() => {
this.application.historyManager!.clearHistoryForItem(this.item).then(() => {
this.$timeout(() => {
this.reloadHistory();
});
});
},
undefined,
true,
);
}
clearAllHistory() {
this.application.alertService!.confirm(
"Are you sure you want to delete the local session history for all notes?",
undefined,
undefined,
undefined,
() => {
this.application.historyManager!.clearAllHistory().then(() => {
this.$timeout(() => {
this.reloadHistory();
});
});
},
undefined,
true,
);
}
toggleDiskSaving() {
const run = () => {
this.application.historyManager!.toggleDiskSaving().then(() => {
this.$timeout(() => {
this.diskEnabled = this.application.historyManager!.isDiskEnabled();
});
});
};
if (!this.application.historyManager!.isDiskEnabled()) {
this.application.alertService!.confirm(
`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.`,
undefined,
undefined,
undefined,
run,
undefined,
true,
);
} else {
run();
}
}
toggleAutoOptimize() {
this.application.historyManager!.toggleAutoOptimize().then(() => {
this.$timeout(() => {
this.autoOptimize = this.application.historyManager!.autoOptimize;
});
});
}
}
export class SessionHistoryMenu extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = SessionHistoryMenuCtrl;
this.controllerAs = 'ctrl';
this.bindToController = true;
this.scope = {
item: '=',
application: '='
};
}
}

View File

@@ -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: '='
};
}
}

View File

@@ -1,6 +0,0 @@
/* @ngInject */
export function trusted($sce) {
return function(url) {
return $sce.trustAsResourceUrl(url);
};
}

View File

@@ -0,0 +1,6 @@
/* @ngInject */
export function trusted($sce: ng.ISCEService) {
return function(url: string) {
return $sce.trustAsResourceUrl(url);
};
}

View File

@@ -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';

View File

@@ -0,0 +1,121 @@
import { DeviceInterface, getGlobalScope, SNApplication } from 'snjs';
import { Database } from '@/database';
const KEYCHAIN_STORAGE_KEY = 'keychain';
export class WebDeviceInterface extends DeviceInterface {
private database: Database
constructor(namespace: string, timeout: any) {
super(
namespace,
timeout || setTimeout.bind(getGlobalScope()),
setInterval.bind(getGlobalScope())
);
this.database = new Database();
}
setApplication(application: SNApplication) {
this.database.setAlertService(application.alertService!);
}
deinit() {
super.deinit();
this.database.deinit();
}
async getRawStorageValue(key: string) {
return localStorage.getItem(key);
}
async getAllRawStorageKeyValues() {
const results = [];
for (const key of Object.keys(localStorage)) {
results.push({
key: key,
value: localStorage[key]
});
}
return results;
}
async setRawStorageValue(key: string, value: any) {
localStorage.setItem(key, value);
}
async removeRawStorageValue(key: string) {
localStorage.removeItem(key);
}
async removeAllRawStorageValues() {
localStorage.clear();
}
async openDatabase() {
this.database.unlock();
return new Promise((resolve, reject) => {
this.database.openDatabase(() => {
resolve({ isNewDatabase: true });
}).then(() => {
resolve({ isNewDatabase: false });
}).catch((error => {
reject(error);
}));
}) as Promise<{ isNewDatabase?: boolean } | undefined>;
}
private getDatabaseKeyPrefix() {
if (this.namespace) {
return `${this.namespace}-item-`;
} else {
return `item-`;
}
}
private keyForPayloadId(id: string) {
return `${this.getDatabaseKeyPrefix()}${id}`;
}
async getAllRawDatabasePayloads() {
return this.database.getAllPayloads();
}
async saveRawDatabasePayload(payload: any) {
return this.database.savePayload(payload);
}
async saveRawDatabasePayloads(payloads: any[]) {
return this.database.savePayloads(payloads);
}
async removeRawDatabasePayloadWithId(id: string) {
return this.database.deletePayload(id);
}
async removeAllRawDatabasePayloads() {
return this.database.clearAllPayloads();
}
async getKeychainValue() {
const value = localStorage.getItem(KEYCHAIN_STORAGE_KEY);
if (value) {
return JSON.parse(value);
}
}
async setKeychainValue(value: any) {
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value));
}
async clearKeychainValue() {
localStorage.removeItem(KEYCHAIN_STORAGE_KEY);
}
openUrl(url: string) {
const win = window.open(url, '_blank');
if (win) {
win.focus();
}
}
}

View File

@@ -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";
}
}
}

View File

@@ -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({

View File

@@ -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);
}
}

View File

@@ -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();
});
}
}

View File

@@ -0,0 +1,101 @@
/* eslint-disable prefer-promise-reject-errors */
import { SNAlertService, ButtonType, DismissBlockingDialog } 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) {
const alert = new SKAlert({ text });
alert.present();
return () => {
alert.dismiss();
};
}
}

View File

@@ -0,0 +1,176 @@
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;
const data = await this.itemsData(items, intent)
if (encrypted) {
this.downloadData(
data!,
`Standard Notes Encrypted Backup - ${this.formattedDate()}.txt`
);
} else {
const data = await this.application.createBackupFile(items, intent);
if (data) {
/** download as zipped plain text files */
this.downloadZippedItems(
items,
/** Add the backup file to the archive */
(zipWriter, zip) => new Promise((resolve) => {
const blob = new Blob([data], { type: 'text/plain' });
const fileName = zippableTxtName(
`Standard Notes Decrypted Backup - ${this.formattedDate()}`
);
zipWriter.add(fileName, new zip.BlobReader(blob), resolve);
})
);
}
}
};
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[],
onOpenZip: (zipWriter: any, zip: any) => Promise<void>
) {
await this.loadZip();
this.zip.createWriter(
new this.zip.BlobWriter('application/zip'),
async (zipWriter: any) => {
await onOpenZip(zipWriter, this.zip);
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 = 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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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();
};
});
}
}

View File

@@ -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
});
}
}

View File

@@ -0,0 +1,243 @@
import { SNComponent, PurePayload, ComponentMutator, AppDataField } 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';
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
dataLoadHandler?: () => void
majorDataChangeHandler?: () => void
extServerHost?: string
installationSyncHandler?: (payloads: PurePayload[]) => void
installComponentHandler?: (payload: PurePayload) => void
lastSearchedText?: string
searchHandler?: (text?: string) => void
constructor(
$rootScope: ng.IRootScopeService,
$timeout: ng.ITimeoutService,
application: WebApplication
) {
super(application);
this.$rootScope = $rootScope;
this.$timeout = $timeout;
}
get webApplication() {
return this.application as WebApplication;
}
deinit() {
this.componentActivationObservers.length = 0;
this.updateObservers.length = 0;
super.deinit();
}
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
if (eventName === ApplicationEvent.LocalDataLoaded) {
this.dataLoaded = true;
if (this.dataLoadHandler) {
this.dataLoadHandler();
}
} else if (eventName === ApplicationEvent.MajorDataChange) {
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: 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.installationSyncHandler!(payloads);
});
}
async installComponent(component: SNComponent) {
this.installComponentHandler!(
await this.convertComponentForTransmission(component)
);
}
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.searchHandler && this.searchHandler(text);
}
redoSearch() {
if (this.lastSearchedText) {
this.searchText(this.lastSearchedText);
}
}
// Pass null to cancel search
desktop_setSearchHandler(handler: (text?: string) => void) {
this.searchHandler = handler;
}
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);
}
});
}
/* Used to resolve 'sn://' */
desktop_setExtServerHost(host: string) {
this.extServerHost = host;
this.webApplication.getAppState().desktopExtensionsReady();
}
desktop_setComponentInstallationSyncHandler(handler: (payloads: PurePayload[]) => void) {
this.installationSyncHandler = handler;
}
desktop_setInstallComponentHandler(handler: (payload: PurePayload) => void) {
this.installComponentHandler = handler;
}
desktop_setInitialDataLoadHandler(handler: () => void) {
this.dataLoadHandler = handler;
if (this.dataLoaded) {
this.dataLoadHandler();
}
}
async desktop_requestBackupFile(callback: (data: any) => void) {
const data = await this.application!.createBackupFile(
undefined,
undefined,
true
);
callback(data);
}
desktop_setMajorDataChangeHandler(handler: () => void) {
this.majorDataChangeHandler = handler;
}
desktop_didBeginBackup() {
this.webApplication.getAppState().beganBackupDownload();
}
desktop_didFinishBackup(success: boolean) {
this.webApplication.getAppState().endedBackupDownload(success);
}
}

View File

@@ -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');
});
}
}

View File

@@ -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';

View File

@@ -0,0 +1,9 @@
export { AlertService } from './alertService';
export { ArchiveManager } from './archiveManager';
export { DesktopManager } from './desktopManager';
export { KeyboardManager } from './keyboardManager';
export { LockManager } from './lockManager';
export { NativeExtManager } from './nativeExtManager';
export { PreferencesManager } from './preferencesManager';
export { StatusManager } from './statusManager';
export { ThemeManager } from './themeManager';

View File

@@ -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);
}
}

View 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);
};
}
}

View File

@@ -0,0 +1,157 @@
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 LockManager {
private application: WebApplication
private unsubState: any
private pollFocusInterval: any
private lastFocusState?: 'hidden' | 'visible'
private lockAfterDate?: Date
private lockTimeout?: any
constructor(application: WebApplication) {
this.application = application;
setImmediate(() => {
this.observeVisibility();
});
}
observeVisibility() {
this.unsubState = this.application.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();
if (this.pollFocusInterval) {
clearInterval(this.pollFocusInterval);
}
}
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;
}
}
/**
* 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(() => {
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.application.lock();
}
this.cancelAutoLockTimer();
} else {
this.beginAutoLockTimer();
}
}
async beginAutoLockTimer() {
var 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);
this.lockTimeout = setTimeout(() => {
this.cancelAutoLockTimer();
this.application.lock();
this.lockAfterDate = undefined;
}, interval);
}
cancelAutoLockTimer() {
clearTimeout(this.lockTimeout);
this.lockAfterDate = undefined;
}
}

View File

@@ -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);
}
}
};
}
}

View File

@@ -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];
}
}

View File

@@ -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);
});
}
}

View File

@@ -0,0 +1,206 @@
import { isDesktopApplication } from '@/utils';
import {
SNPredicate,
ContentType,
SNComponent,
ApplicationService,
ComponentAction,
FillItemContent,
ComponentMutator,
Copy,
dictToArray
} from 'snjs';
import { PayloadContent } from '@node_modules/snjs/dist/@types/protocol/payloads/generator';
import { ComponentPermission } from '@node_modules/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;
}
}

View File

@@ -1,285 +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);
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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,83 @@
import { WebApplication } from '@/ui_models/application';
import {
SNPredicate,
ContentType,
ApplicationService,
SNUserPrefs,
WebPrefKey,
UserPrefsMutator,
FillItemContent
} from 'snjs';
export class PreferencesManager extends ApplicationService {
private userPreferences!: SNUserPrefs
private loadingPrefs = false;
/** @override */
async onAppLaunch() {
super.onAppLaunch();
this.reloadSingleton();
this.streamPreferences();
}
get webApplication() {
return this.application as WebApplication;
}
streamPreferences() {
this.application!.streamItems(
ContentType.UserPrefs,
() => {
this.reloadSingleton();
}
);
}
private async reloadSingleton() {
if(this.loadingPrefs) {
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;
const didChange = !previousRef || (
this.userPreferences.lastSyncBegan?.getTime() !== previousRef?.lastSyncBegan?.getTime()
)
if (didChange) {
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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -1,44 +0,0 @@
import { NoteHistoryEntry } from '@/models/noteHistoryEntry';
import { SFSessionHistoryManager , SFItemHistory } from 'snjs';
export class SessionHistory extends SFSessionHistoryManager {
/* @ngInject */
constructor(
modelManager,
storageManager,
authManager,
passcodeManager,
$timeout
) {
SFItemHistory.HistoryEntryClassMapping = {
"Note" : NoteHistoryEntry
};
// Session History can be encrypted with passcode keys. If it changes, we need to resave session
// history with the new keys.
passcodeManager.addPasscodeChangeObserver(() => {
this.saveToDisk();
});
var keyRequestHandler = async () => {
const offline = authManager.offline();
const auth_params = offline ? passcodeManager.passcodeAuthParams() : await authManager.getAuthParams();
const keys = offline ? passcodeManager.keys() : await authManager.keys();
return {
keys: keys,
offline: offline,
auth_params: auth_params
};
};
var contentTypes = ["Note"];
super(
modelManager,
storageManager,
keyRequestHandler,
contentTypes,
$timeout
);
}
}

View File

@@ -1,10 +0,0 @@
import { SFSingletonManager } from 'snjs';
export class SingletonManager extends SFSingletonManager {
// constructor needed for angularjs injection to work
// eslint-disable-next-line no-useless-constructor
/* @ngInject */
constructor(modelManager, syncManager) {
super(modelManager, syncManager);
}
}

View File

@@ -1,46 +1,43 @@
import _ from 'lodash';
import { removeFromArray } from 'snjs';
import { FooterStatus } from '@/types';
type StatusCallback = (string: string) => void
export class StatusManager {
constructor() {
this.statuses = [];
this.observers = [];
}
statusFromString(string) {
private statuses: FooterStatus[] = []
private observers: StatusCallback[] = []
statusFromString(string: string) {
return {string: string};
}
replaceStatusWithString(status, string) {
replaceStatusWithString(status: FooterStatus, string: string) {
this.removeStatus(status);
return this.addStatusFromString(string);
}
addStatusFromString(string) {
addStatusFromString(string: string) {
return this.addStatus(this.statusFromString(string));
}
addStatus(status) {
if(typeof status !== "object") {
console.error("Attempting to set non-object status", status);
return;
}
addStatus(status: FooterStatus) {
this.statuses.push(status);
this.notifyObservers();
return status;
}
removeStatus(status) {
_.pull(this.statuses, status);
removeStatus(status: FooterStatus) {
removeFromArray(this.statuses, status);
this.notifyObservers();
return null;
return undefined;
}
getStatusString() {
let result = "";
let result = '';
this.statuses.forEach((status, index) => {
if(index > 0) {
result += " ";
result += ' ';
}
result += status.string;
});
@@ -54,11 +51,10 @@ export class StatusManager {
}
}
addStatusObserver(callback) {
addStatusObserver(callback: StatusCallback) {
this.observers.push(callback);
}
removeStatusObserver(callback) {
_.pull(this.statuses, callback);
return () => {
removeFromArray(this.observers, callback);
}
}
}

View File

@@ -1,241 +0,0 @@
import { protocolManager, SNEncryptedStorage, SFStorageManager , SFItemParams } from 'snjs';
export class MemoryStorage {
constructor() {
this.memory = {};
}
getItem(key) {
return this.memory[key] || null;
}
getItemSync(key) {
return this.getItem(key);
}
get length() {
return Object.keys(this.memory).length;
}
setItem(key, value) {
this.memory[key] = value;
}
removeItem(key) {
delete this.memory[key];
}
clear() {
this.memory = {};
}
keys() {
return Object.keys(this.memory);
}
key(index) {
return Object.keys(this.memory)[index];
}
}
export class StorageManager extends SFStorageManager {
/* @ngInject */
constructor(dbManager, alertManager) {
super();
this.dbManager = dbManager;
this.alertManager = alertManager;
}
initialize(hasPasscode, ephemeral) {
if(hasPasscode) {
// We don't want to save anything in fixed storage except for actual item data (in IndexedDB)
this.storage = this.memoryStorage;
this.itemsStorageMode = StorageManager.FixedEncrypted;
} else if(ephemeral) {
// We don't want to save anything in fixed storage as well as IndexedDB
this.storage = this.memoryStorage;
this.itemsStorageMode = StorageManager.Ephemeral;
} else {
this.storage = localStorage;
this.itemsStorageMode = StorageManager.Fixed;
}
this.modelStorageMode = ephemeral ? StorageManager.Ephemeral : StorageManager.Fixed;
}
get memoryStorage() {
if(!this._memoryStorage) {
this._memoryStorage = new MemoryStorage();
}
return this._memoryStorage;
}
setItemsMode(mode, force) {
var newStorage = this.getVault(mode);
if(newStorage !== this.storage || mode !== this.itemsStorageMode || force) {
// transfer storages
var length = this.storage.length;
for(var i = 0; i < length; i++) {
var key = this.storage.key(i);
newStorage.setItem(key, this.storage.getItem(key));
}
this.itemsStorageMode = mode;
if(newStorage !== this.storage) {
// Only clear if this.storage isn't the same reference as newStorage
this.storage.clear();
}
this.storage = newStorage;
if(mode == StorageManager.FixedEncrypted) {
this.writeEncryptedStorageToDisk();
} else if(mode == StorageManager.Fixed) {
// Remove encrypted storage
this.removeItem("encryptedStorage", StorageManager.Fixed);
}
}
}
getVault(vaultKey) {
if(vaultKey) {
if(vaultKey == StorageManager.Ephemeral || vaultKey == StorageManager.FixedEncrypted) {
return this.memoryStorage;
} else {
return localStorage;
}
} else {
return this.storage;
}
}
async setItem(key, value, vaultKey) {
var storage = this.getVault(vaultKey);
try {
storage.setItem(key, value);
} catch (e) {
console.error("Exception while trying to setItem in StorageManager:", e);
this.alertManager.alert({text: "The application's local storage is out of space. If you have Session History save-to-disk enabled, please disable it, and try again."});
}
if(vaultKey === StorageManager.FixedEncrypted || (!vaultKey && this.itemsStorageMode === StorageManager.FixedEncrypted)) {
return this.writeEncryptedStorageToDisk();
}
}
async getItem(key, vault) {
return this.getItemSync(key, vault);
}
getItemSync(key, vault) {
var storage = this.getVault(vault);
return storage.getItem(key);
}
async removeItem(key, vault) {
var storage = this.getVault(vault);
return storage.removeItem(key);
}
async clear() {
this.memoryStorage.clear();
localStorage.clear();
}
storageAsHash() {
var hash = {};
var length = this.storage.length;
for(var i = 0; i < length; i++) {
var key = this.storage.key(i);
hash[key] = this.storage.getItem(key);
}
return hash;
}
setKeys(keys, authParams) {
this.encryptedStorageKeys = keys;
this.encryptedStorageAuthParams = authParams;
}
async writeEncryptedStorageToDisk() {
var encryptedStorage = new SNEncryptedStorage();
// Copy over totality of current storage
encryptedStorage.content.storage = this.storageAsHash();
// Save new encrypted storage in Fixed storage
var params = new SFItemParams(encryptedStorage, this.encryptedStorageKeys, this.encryptedStorageAuthParams);
const syncParams = await params.paramsForSync();
this.setItem("encryptedStorage", JSON.stringify(syncParams), StorageManager.Fixed);
}
async decryptStorage() {
var stored = JSON.parse(this.getItemSync("encryptedStorage", StorageManager.Fixed));
await protocolManager.decryptItem(stored, this.encryptedStorageKeys);
var encryptedStorage = new SNEncryptedStorage(stored);
for(var key of Object.keys(encryptedStorage.content.storage)) {
this.setItem(key, encryptedStorage.storage[key]);
}
}
hasPasscode() {
return this.getItemSync("encryptedStorage", StorageManager.Fixed) !== null;
}
bestStorageMode() {
return this.hasPasscode() ? StorageManager.FixedEncrypted : StorageManager.Fixed;
}
/*
Model Storage
If using ephemeral storage, we don't need to write it to anything as references will be held already by controllers
and the global modelManager service.
*/
setModelStorageMode(mode) {
if(mode == this.modelStorageMode) {
return;
}
if(mode == StorageManager.Ephemeral) {
// Clear IndexedDB
this.dbManager.clearAllModels(null);
} else {
// Fixed
}
this.modelStorageMode = mode;
}
async getAllModels() {
if(this.modelStorageMode == StorageManager.Fixed) {
return this.dbManager.getAllModels();
}
}
async saveModel(item) {
return this.saveModels([item]);
}
async saveModels(items, onsuccess, onerror) {
if(this.modelStorageMode == StorageManager.Fixed) {
return this.dbManager.saveModels(items);
}
}
async deleteModel(item) {
if(this.modelStorageMode == StorageManager.Fixed) {
return this.dbManager.deleteModel(item);
}
}
async clearAllModels() {
return this.dbManager.clearAllModels();
}
}
StorageManager.FixedEncrypted = "FixedEncrypted"; // encrypted memoryStorage + localStorage persistence
StorageManager.Ephemeral = "Ephemeral"; // memoryStorage
StorageManager.Fixed = "Fixed"; // localStorage

View File

@@ -1,30 +0,0 @@
import angular from 'angular';
import { SFSyncManager } from 'snjs';
export class SyncManager extends SFSyncManager {
/* @ngInject */
constructor(
modelManager,
storageManager,
httpManager,
$timeout,
$interval,
$compile,
$rootScope
) {
super(modelManager, storageManager, httpManager, $timeout, $interval);
this.$rootScope = $rootScope;
this.$compile = $compile;
// this.loggingEnabled = true;
}
presentConflictResolutionModal(items, callback) {
var scope = this.$rootScope.$new(true);
scope.item1 = items[0];
scope.item2 = items[1];
scope.callback = callback;
var el = this.$compile( "<conflict-resolution-modal item1='item1' item2='item2' callback='callback' class='sk-modal'></conflict-resolution-modal>" )(scope);
angular.element(document.body).append(el);
}
}

View File

@@ -1,150 +0,0 @@
import _ from 'lodash';
import angular from 'angular';
import { SNTheme, SFItemParams } from 'snjs';
import { StorageManager } from './storageManager';
import {
APP_STATE_EVENT_DESKTOP_EXTS_READY
} from '@/state';
export class ThemeManager {
/* @ngInject */
constructor(
componentManager,
desktopManager,
storageManager,
passcodeManager,
appState
) {
this.componentManager = componentManager;
this.storageManager = storageManager;
this.desktopManager = desktopManager;
this.activeThemes = [];
ThemeManager.CachedThemesKey = "cachedThemes";
this.registerObservers();
// When a passcode is added, all local storage will be encrypted (it doesn't know what was
// originally saved as Fixed or FixedEncrypted). We want to rewrite cached themes here to Fixed
// so that it's readable without authentication.
passcodeManager.addPasscodeChangeObserver(() => {
this.cacheThemes();
});
if (desktopManager.isDesktop) {
appState.addObserver((eventName, data) => {
if (eventName === APP_STATE_EVENT_DESKTOP_EXTS_READY) {
this.activateCachedThemes();
}
});
} else {
this.activateCachedThemes();
}
}
activateCachedThemes() {
const cachedThemes = this.getCachedThemes();
const writeToCache = false;
for (const theme of cachedThemes) {
this.activateTheme(theme, writeToCache);
}
}
registerObservers() {
this.desktopManager.registerUpdateObserver((component) => {
// Reload theme if active
if (component.active && component.isTheme()) {
this.deactivateTheme(component);
setTimeout(() => {
this.activateTheme(component);
}, 10);
}
});
this.componentManager.registerHandler({
identifier: "themeManager",
areas: ["themes"],
activationHandler: (component) => {
if (component.active) {
this.activateTheme(component);
} else {
this.deactivateTheme(component);
}
}
});
}
hasActiveTheme() {
return this.componentManager.getActiveThemes().length > 0;
}
deactivateAllThemes() {
var activeThemes = this.componentManager.getActiveThemes();
for (var theme of activeThemes) {
if (theme) {
this.componentManager.deactivateComponent(theme);
}
}
this.decacheThemes();
}
activateTheme(theme, writeToCache = true) {
if (_.find(this.activeThemes, { uuid: theme.uuid })) {
return;
}
this.activeThemes.push(theme);
var url = this.componentManager.urlForComponent(theme);
var link = document.createElement("link");
link.href = url;
link.type = "text/css";
link.rel = "stylesheet";
link.media = "screen,print";
link.id = theme.uuid;
document.getElementsByTagName("head")[0].appendChild(link);
if (writeToCache) {
this.cacheThemes();
}
}
deactivateTheme(theme) {
var element = document.getElementById(theme.uuid);
if (element) {
element.disabled = true;
element.parentNode.removeChild(element);
}
_.remove(this.activeThemes, { uuid: theme.uuid });
this.cacheThemes();
}
async cacheThemes() {
const mapped = await Promise.all(this.activeThemes.map(async (theme) => {
const transformer = new SFItemParams(theme);
const params = await transformer.paramsForLocalStorage();
return params;
}));
const data = JSON.stringify(mapped);
return this.storageManager.setItem(ThemeManager.CachedThemesKey, data, StorageManager.Fixed);
}
async decacheThemes() {
return this.storageManager.removeItem(ThemeManager.CachedThemesKey, StorageManager.Fixed);
}
getCachedThemes() {
const cachedThemes = this.storageManager.getItemSync(ThemeManager.CachedThemesKey, StorageManager.Fixed);
if (cachedThemes) {
const parsed = JSON.parse(cachedThemes);
return parsed.map((theme) => {
return new SNTheme(theme);
});
} else {
return [];
}
}
}

View File

@@ -0,0 +1,179 @@
import { WebApplication } from '@/ui_models/application';
import _ from 'lodash';
import {
StorageValueModes,
EncryptionIntent,
ApplicationService,
SNTheme,
ComponentArea,
removeFromArray,
ApplicationEvent
} from 'snjs';
import { AppStateEvent } from '@/ui_models/app_state';
const CACHED_THEMES_KEY = 'cachedThemes';
export class ThemeManager extends ApplicationService {
private activeThemes: string[] = []
private unsubState?: () => void
private unregisterDesktop!: () => void
private unregisterComponent!: () => void
/** @override */
async onAppLaunch() {
super.onAppLaunch();
this.unsubState = this.webApplication.getAppState().addObserver(
async (eventName) => {
if (eventName === AppStateEvent.DesktopExtsReady) {
this.activateCachedThemes();
}
}
);
}
onAppEvent(event: ApplicationEvent) {
super.onAppEvent(event);
if (event === ApplicationEvent.SignedOut) {
this.deactivateAllThemes();
}
}
get webApplication() {
return this.application as WebApplication;
}
deinit() {
this.unsubState?.();
(this.unsubState as any) = undefined;
this.activeThemes.length = 0;
this.unregisterDesktop();
this.unregisterComponent();
(this.unregisterDesktop as any) = undefined;
(this.unregisterComponent as any) = undefined;
super.deinit();
}
/** @override */
async onAppStart() {
super.onAppStart();
this.registerObservers();
if (!this.webApplication.getDesktopService().isDesktop) {
this.activateCachedThemes();
}
}
private async activateCachedThemes() {
const cachedThemes = await this.getCachedThemes();
const writeToCache = false;
for (const theme of cachedThemes) {
this.activateTheme(theme, writeToCache);
}
}
private registerObservers() {
this.unregisterDesktop = this.webApplication.getDesktopService()
.registerUpdateObserver((component) => {
if (component.active && component.isTheme()) {
this.deactivateTheme(component.uuid);
setTimeout(() => {
this.activateTheme(component as SNTheme);
}, 10);
}
});
this.unregisterComponent = this.application!.componentManager!.registerHandler({
identifier: 'themeManager',
areas: [ComponentArea.Themes],
activationHandler: (uuid, component) => {
if (component?.active) {
this.activateTheme(component as SNTheme);
} else {
this.deactivateTheme(uuid);
}
}
});
}
private deactivateAllThemes() {
for (const uuid of this.activeThemes) {
this.deactivateTheme(uuid, false);
}
this.activeThemes = [];
this.decacheThemes();
}
private activateTheme(theme: SNTheme, writeToCache = true) {
if (this.activeThemes.find((uuid) => uuid === theme.uuid)) {
return;
}
this.activeThemes.push(theme.uuid);
const url = this.application!.componentManager!.urlForComponent(theme)!;
const link = document.createElement('link');
link.href = url;
link.type = 'text/css';
link.rel = 'stylesheet';
link.media = 'screen,print';
link.id = theme.uuid;
document.getElementsByTagName('head')[0].appendChild(link);
if (writeToCache) {
this.cacheThemes();
}
}
private deactivateTheme(uuid: string, recache = true) {
const element = document.getElementById(uuid) as HTMLLinkElement;
if (element) {
element.disabled = true;
element.parentNode!.removeChild(element);
}
removeFromArray(this.activeThemes, uuid);
if (recache) {
this.cacheThemes();
}
}
private async cacheThemes() {
const themes = this.application!.getAll(this.activeThemes) as SNTheme[];
const mapped = await Promise.all(themes.map(async (theme) => {
const payload = theme.payloadRepresentation();
const processedPayload = await this.application!.protocolService!.payloadByEncryptingPayload(
payload,
EncryptionIntent.LocalStorageDecrypted
);
return processedPayload;
}));
return this.application!.setValue(
CACHED_THEMES_KEY,
mapped,
StorageValueModes.Nonwrapped
);
}
private async decacheThemes() {
if (this.application) {
return this.application.removeValue(
CACHED_THEMES_KEY,
StorageValueModes.Nonwrapped
);
}
}
private async getCachedThemes() {
const cachedThemes = await this.application!.getValue(
CACHED_THEMES_KEY,
StorageValueModes.Nonwrapped
);
if (cachedThemes) {
const themes = [];
for (const cachedTheme of cachedThemes) {
const payload = this.application!.createPayloadFromObject(cachedTheme);
const theme = this.application!.createItemFromPayload(payload) as SNTheme;
themes.push(theme);
}
return themes;
} else {
return [];
}
}
}

View File

@@ -1,132 +0,0 @@
import { PrivilegesManager } from '@/services/privilegesManager';
export const APP_STATE_EVENT_TAG_CHANGED = 1;
export const APP_STATE_EVENT_NOTE_CHANGED = 2;
export const APP_STATE_EVENT_PREFERENCES_CHANGED = 3;
export const APP_STATE_EVENT_PANEL_RESIZED = 4;
export const APP_STATE_EVENT_EDITOR_FOCUSED = 5;
export const APP_STATE_EVENT_BEGAN_BACKUP_DOWNLOAD = 6;
export const APP_STATE_EVENT_ENDED_BACKUP_DOWNLOAD = 7;
export const APP_STATE_EVENT_DESKTOP_EXTS_READY = 8;
export const EVENT_SOURCE_USER_INTERACTION = 1;
export const EVENT_SOURCE_SCRIPT = 2;
export class AppState {
/* @ngInject */
constructor($timeout, privilegesManager) {
this.$timeout = $timeout;
this.privilegesManager = privilegesManager;
this.observers = [];
}
addObserver(callback) {
this.observers.push(callback);
return callback;
}
async notifyEvent(eventName, data) {
/**
* Timeout is particullary important so we can give all initial
* controllers a chance to construct before propogting any events *
*/
return new Promise((resolve) => {
this.$timeout(async () => {
for(const callback of this.observers) {
await callback(eventName, data);
}
resolve();
});
});
}
setSelectedTag(tag) {
if(this.selectedTag === tag) {
return;
}
const previousTag = this.selectedTag;
this.selectedTag = tag;
this.notifyEvent(
APP_STATE_EVENT_TAG_CHANGED,
{previousTag: previousTag}
);
}
async setSelectedNote(note) {
const run = async () => {
const previousNote = this.selectedNote;
this.selectedNote = note;
await this.notifyEvent(
APP_STATE_EVENT_NOTE_CHANGED,
{ previousNote: previousNote }
);
};
if (note && note.content.protected &&
await this.privilegesManager.actionRequiresPrivilege(
PrivilegesManager.ActionViewProtectedNotes
)) {
this.privilegesManager.presentPrivilegesModal(
PrivilegesManager.ActionViewProtectedNotes,
run
);
} else {
run();
}
}
getSelectedTag() {
return this.selectedTag;
}
getSelectedNote() {
return this.selectedNote;
}
setUserPreferences(preferences) {
this.userPreferences = preferences;
this.notifyEvent(
APP_STATE_EVENT_PREFERENCES_CHANGED
);
}
panelDidResize({name, collapsed}) {
this.notifyEvent(
APP_STATE_EVENT_PANEL_RESIZED,
{
panel: name,
collapsed: collapsed
}
);
}
editorDidFocus(eventSource) {
this.notifyEvent(
APP_STATE_EVENT_EDITOR_FOCUSED,
{eventSource: eventSource}
);
}
beganBackupDownload() {
this.notifyEvent(
APP_STATE_EVENT_BEGAN_BACKUP_DOWNLOAD
);
}
endedBackupDownload({success}) {
this.notifyEvent(
APP_STATE_EVENT_ENDED_BACKUP_DOWNLOAD,
{success: success}
);
}
/**
* When the desktop appplication extension server is ready.
*/
desktopExtensionsReady() {
this.notifyEvent(
APP_STATE_EVENT_DESKTOP_EXTS_READY
);
}
}

View File

@@ -1,51 +0,0 @@
/** @generic */
export const STRING_SESSION_EXPIRED = "Your session has expired. New changes will not be pulled in. Please sign out and sign back in to refresh your session.";
export const STRING_DEFAULT_FILE_ERROR = "Please use FileSafe or the Bold Editor to attach images and files. Learn more at standardnotes.org/filesafe.";
export const STRING_GENERIC_SYNC_ERROR = "There was an error syncing. Please try again. If all else fails, try signing out and signing back in.";
export function StringSyncException(data) {
return `There was an error while trying to save your items. Please contact support and share this message: ${data}.`;
}
/** @footer */
export const STRING_NEW_UPDATE_READY = "A new update is ready to install. Please use the top-level 'Updates' menu to manage installation.";
/** @tags */
export const STRING_DELETE_TAG = "Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.";
/** @editor */
export const STRING_DELETED_NOTE = "The note you are attempting to edit has been deleted, and is awaiting sync. Changes you make will be disregarded.";
export const STRING_INVALID_NOTE = "The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note.";
export const STRING_ELLIPSES = "...";
export const STRING_GENERIC_SAVE_ERROR = "There was an error saving your note. Please try again.";
export const STRING_DELETE_PLACEHOLDER_ATTEMPT = "This note is a placeholder and cannot be deleted. To remove from your list, simply navigate to a different note.";
export const STRING_DELETE_LOCKED_ATTEMPT = "This note is locked. If you'd like to delete it, unlock it, and try again.";
export function StringDeleteNote({title, permanently}) {
return permanently
? `Are you sure you want to permanently delete ${title}?`
: `Are you sure you want to move ${title} to the trash?`;
}
export function StringEmptyTrash({count}) {
return `Are you sure you want to permanently delete ${count} note(s)?`;
}
/** @account */
export const STRING_ACCOUNT_MENU_UNCHECK_MERGE = "Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?";
export const STRING_SIGN_OUT_CONFIRMATION = "Are you sure you want to end your session? This will delete all local items and extensions.";
export const STRING_ERROR_DECRYPTING_IMPORT = "There was an error decrypting your items. Make sure the password you entered is correct and try again.";
export const STRING_E2E_ENABLED = "End-to-end encryption is enabled. Your data is encrypted on your device first, then synced to your private cloud.";
export const STRING_LOCAL_ENC_ENABLED = "Encryption is enabled. Your data is encrypted using your passcode before it is saved to your device storage.";
export const STRING_ENC_NOT_ENABLED = "Encryption is not enabled. Sign in, register, or add a passcode lock to enable encryption.";
export const STRING_IMPORT_SUCCESS = "Your data has been successfully imported.";
export const STRING_REMOVE_PASSCODE_CONFIRMATION = "Are you sure you want to remove your local passcode?";
export const STRING_REMOVE_PASSCODE_OFFLINE_ADDENDUM = " This will remove encryption from your local data.";
export const STRING_NON_MATCHING_PASSCODES = "The two passcodes you entered do not match. Please try again.";
export const STRING_NON_MATCHING_PASSWORDS = "The two passwords you entered do not match. Please try again.";
export const STRING_GENERATING_LOGIN_KEYS = "Generating Login Keys...";
export const STRING_GENERATING_REGISTER_KEYS = "Generating Account Keys...";
export const STRING_INVALID_IMPORT_FILE = "Unable to open file. Ensure it is a proper JSON file and try again.";
export function StringImportError({errorCount}) {
return `Import complete. ${errorCount} items were not imported because there was an error decrypting them. Make sure the password is correct and try again.`;
}
/** @password_change */
export const STRING_FAILED_PASSWORD_CHANGE = "There was an error re-encrypting your items. Your password was changed, but not all your items were properly re-encrypted and synced. You should try syncing again. If all else fails, you should restore your notes from backup.";

View File

@@ -0,0 +1,60 @@
/** @generic */
export const STRING_SESSION_EXPIRED = "Your session has expired. New changes will not be pulled in. Please sign out and sign back in to refresh your session.";
export const STRING_DEFAULT_FILE_ERROR = "Please use FileSafe or the Bold Editor to attach images and files. Learn more at standardnotes.org/filesafe.";
export const STRING_GENERIC_SYNC_ERROR = "There was an error syncing. Please try again. If all else fails, try signing out and signing back in.";
export function StringSyncException(data: any) {
return `There was an error while trying to save your items. Please contact support and share this message: ${data}.`;
}
/** @footer */
export const STRING_NEW_UPDATE_READY = "A new update is ready to install. Please use the top-level 'Updates' menu to manage installation.";
/** @tags */
export const STRING_DELETE_TAG = "Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.";
/** @editor */
export const STRING_DELETED_NOTE = "The note you are attempting to edit has been deleted, and is awaiting sync. Changes you make will be disregarded.";
export const STRING_INVALID_NOTE = "The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note.";
export const STRING_ELLIPSES = "...";
export const STRING_GENERIC_SAVE_ERROR = "There was an error saving your note. Please try again.";
export const STRING_DELETE_PLACEHOLDER_ATTEMPT = "This note is a placeholder and cannot be deleted. To remove from your list, simply navigate to a different note.";
export const STRING_DELETE_LOCKED_ATTEMPT = "This note is locked. If you'd like to delete it, unlock it, and try again.";
export function StringDeleteNote(title: string, permanently: boolean) {
return permanently
? `Are you sure you want to permanently delete ${title}?`
: `Are you sure you want to move ${title} to the trash?`;
}
export function StringEmptyTrash(count: number) {
return `Are you sure you want to permanently delete ${count} note(s)?`;
}
/** @account */
export const STRING_ACCOUNT_MENU_UNCHECK_MERGE = "Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?";
export const STRING_SIGN_OUT_CONFIRMATION = "Are you sure you want to end your session? This will delete all local items and extensions.";
export const STRING_ERROR_DECRYPTING_IMPORT = "There was an error decrypting your items. Make sure the password you entered is correct and try again.";
export const STRING_E2E_ENABLED = "End-to-end encryption is enabled. Your data is encrypted on your device first, then synced to your private cloud.";
export const STRING_LOCAL_ENC_ENABLED = "Encryption is enabled. Your data is encrypted using your passcode before it is saved to your device storage.";
export const STRING_ENC_NOT_ENABLED = "Encryption is not enabled. Sign in, register, or add a passcode lock to enable encryption.";
export const STRING_IMPORT_SUCCESS = "Your data has been successfully imported.";
export const STRING_REMOVE_PASSCODE_CONFIRMATION = "Are you sure you want to remove your application passcode?";
export const STRING_REMOVE_PASSCODE_OFFLINE_ADDENDUM = " This will remove encryption from your local data.";
export const STRING_NON_MATCHING_PASSCODES = "The two passcodes you entered do not match. Please try again.";
export const STRING_NON_MATCHING_PASSWORDS = "The two passwords you entered do not match. Please try again.";
export const STRING_GENERATING_LOGIN_KEYS = "Generating Login Keys...";
export const STRING_GENERATING_REGISTER_KEYS = "Generating Account Keys...";
export const STRING_INVALID_IMPORT_FILE = "Unable to open file. Ensure it is a proper JSON file and try again.";
export function StringImportError(errorCount: number) {
return `Import complete. ${errorCount} items were not imported because there was an error decrypting them. Make sure the password is correct and try again.`;
}
export const STRING_ENTER_ACCOUNT_PASSCODE = 'Enter your application passcode';
export const STRING_ENTER_ACCOUNT_PASSWORD = 'Enter your account password';
export const STRING_ENTER_PASSCODE_FOR_MIGRATION = 'Your application passcode is required to perform an upgrade of your local data storage structure.';
export const STRING_STORAGE_UPDATE = 'Storage Update';
export const STRING_AUTHENTICATION_REQUIRED = 'Authentication Required';
/** @password_change */
export const STRING_FAILED_PASSWORD_CHANGE = "There was an error re-encrypting your items. Your password was changed, but not all your items were properly re-encrypted and synced. You should try syncing again. If all else fails, you should restore your notes from backup.";
export const STRING_CONFIRM_APP_QUIT_DURING_UPGRADE =
"The encryption upgrade is in progress. You may lose data if you quit the app. " +
"Are you sure you want to quit?"

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"moduleResolution": "node",
"allowJs": true,
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"declaration": true,
"emitDeclarationOnly": true,
"newLine": "lf",
"declarationDir": "../../../dist/@types",
"baseUrl": ".",
"paths": {
"%/*": ["../templates/*"],
"@/*": ["./*"],
"@Controllers/*": ["./controllers/*"],
"@Views/*": ["./views/*"],
"@Services/*": ["./services/*"],
"@node_modules/*": ["../../../node_modules/*"],
}
}
}

View File

@@ -0,0 +1,41 @@
import { SNComponent } from 'snjs';
export class WebDirective implements ng.IDirective {
controller?: string | ng.Injectable<ng.IControllerConstructor>;
controllerAs?: string;
bindToController?: boolean | { [boundProperty: string]: string };
restrict?: string;
replace?: boolean
scope?: boolean | { [boundProperty: string]: string };
template?: string | ((tElement: any, tAttrs: any) => string)
transclude?: boolean
}
export enum PasswordWizardType {
ChangePassword = 1,
AccountUpgrade = 2
}
export interface PasswordWizardScope extends Partial<ng.IScope> {
type: PasswordWizardType,
application: any
}
export interface PermissionsModalScope extends Partial<ng.IScope> {
application: any
component: SNComponent
permissionsString: string
callback: (approved: boolean) => void
}
export type PanelPuppet = {
onReady?: () => void
ready?: boolean
setWidth?: (width: number) => void
setLeft?: (left: number) => void
isCollapsed?: () => boolean
flash?: () => void
}
export type FooterStatus = {
string: string
}

View File

@@ -0,0 +1,5 @@
declare module "*.pug" {
import { compileTemplate } from 'pug'
const content: compileTemplate;
export default content;
}

View File

@@ -0,0 +1 @@
declare module "sn-stylekit";

View File

@@ -0,0 +1,321 @@
import { isDesktopApplication } from '@/utils';
import pull from 'lodash/pull';
import {
ProtectedAction,
ApplicationEvent,
SNTag,
SNNote,
SNUserPrefs,
ContentType,
SNSmartTag,
PayloadSource
} from 'snjs';
import { WebApplication } from '@/ui_models/application';
import { Editor } from '@/ui_models/editor';
export enum AppStateEvent {
TagChanged = 1,
ActiveEditorChanged = 2,
PreferencesChanged = 3,
PanelResized = 4,
EditorFocused = 5,
BeganBackupDownload = 6,
EndedBackupDownload = 7,
DesktopExtsReady = 8,
WindowDidFocus = 9,
WindowDidBlur = 10,
};
export enum EventSource {
UserInteraction = 1,
Script = 2
};
type ObserverCallback = (event: AppStateEvent, data?: any) => Promise<void>
export class AppState {
$rootScope: ng.IRootScopeService
$timeout: ng.ITimeoutService
application: WebApplication
observers: ObserverCallback[] = []
locked = true
unsubApp: any
rootScopeCleanup1: any
rootScopeCleanup2: any
onVisibilityChange: any
selectedTag?: SNTag
userPreferences?: SNUserPrefs
multiEditorEnabled = false
/* @ngInject */
constructor(
$rootScope: ng.IRootScopeService,
$timeout: ng.ITimeoutService,
application: WebApplication
) {
this.$timeout = $timeout;
this.$rootScope = $rootScope;
this.application = application;
this.registerVisibilityObservers();
this.addAppEventObserver();
this.streamNotesAndTags();
const onVisibilityChange = () => {
const visible = document.visibilityState === "visible";
const event = visible
? AppStateEvent.WindowDidFocus
: AppStateEvent.WindowDidBlur;
this.notifyEvent(event);
}
this.onVisibilityChange = onVisibilityChange.bind(this);
}
deinit() {
this.unsubApp();
this.unsubApp = undefined;
this.observers.length = 0;
if (this.rootScopeCleanup1) {
this.rootScopeCleanup1();
this.rootScopeCleanup2();
this.rootScopeCleanup1 = undefined;
this.rootScopeCleanup2 = undefined;
}
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.onVisibilityChange = undefined;
}
/**
* Creates a new editor if one doesn't exist. If one does, we'll replace the
* editor's note with an empty one.
*/
createEditor(title?: string) {
const activeEditor = this.getActiveEditor();
if (!activeEditor || this.multiEditorEnabled) {
this.application.editorGroup.createEditor(undefined, title);
} else {
activeEditor.reset(title);
}
}
async openEditor(noteUuid: string) {
const note = this.application.findItem(noteUuid) as SNNote;
const run = async () => {
const activeEditor = this.getActiveEditor();
if (!activeEditor || this.multiEditorEnabled) {
this.application.editorGroup.createEditor(noteUuid);
} else {
activeEditor.setNote(note);
}
await this.notifyEvent(AppStateEvent.ActiveEditorChanged);
};
if (note && note.safeContent.protected &&
await this.application.privilegesService!.actionRequiresPrivilege(
ProtectedAction.ViewProtectedNotes
)) {
return new Promise((resolve) => {
this.application.presentPrivilegesModal(
ProtectedAction.ViewProtectedNotes,
() => {
run().then(resolve);
}
);
});
} else {
return run();
}
}
getActiveEditor() {
return this.application.editorGroup.editors[0];
}
getEditors() {
return this.application.editorGroup.editors;
}
closeEditor(editor: Editor) {
this.application.editorGroup.closeEditor(editor);
}
closeActiveEditor() {
this.application.editorGroup.closeActiveEditor();
}
closeAllEditors() {
this.application.editorGroup.closeAllEditors();
}
editorForNote(note: SNNote) {
for (const editor of this.getEditors()) {
if (editor.note.uuid === note.uuid) {
return editor;
}
}
}
streamNotesAndTags() {
this.application!.streamItems(
[ContentType.Note, ContentType.Tag],
async (items, source) => {
/** Close any editors for deleted/trashed/archived notes */
if (source === PayloadSource.PreSyncSave) {
const notes = items.filter((candidate) =>
candidate.content_type === ContentType.Note
) as SNNote[];
for (const note of notes) {
const editor = this.editorForNote(note);
if (!editor) {
continue;
}
if (note.deleted) {
this.closeEditor(editor);
} else if (note.trashed && !this.selectedTag?.isTrashTag) {
this.closeEditor(editor);
} else if (note.archived && !this.selectedTag?.isArchiveTag) {
this.closeEditor(editor);
}
}
}
if (this.selectedTag) {
const matchingTag = items.find((candidate) => candidate.uuid === this.selectedTag!.uuid);
if (matchingTag) {
this.selectedTag = matchingTag as SNTag;
}
}
}
);
}
addAppEventObserver() {
this.unsubApp = this.application.addEventObserver(async (eventName) => {
if (eventName === ApplicationEvent.Started) {
this.locked = true;
} else if (eventName === ApplicationEvent.Launched) {
this.locked = false;
}
});
}
isLocked() {
return this.locked;
}
registerVisibilityObservers() {
if (isDesktopApplication()) {
this.rootScopeCleanup1 = this.$rootScope.$on('window-lost-focus', () => {
this.notifyEvent(AppStateEvent.WindowDidBlur);
});
this.rootScopeCleanup2 = this.$rootScope.$on('window-gained-focus', () => {
this.notifyEvent(AppStateEvent.WindowDidFocus);
});
} else {
/* Tab visibility listener, web only */
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
}
/** @returns A function that unregisters this observer */
addObserver(callback: ObserverCallback) {
this.observers.push(callback);
return () => {
pull(this.observers, callback);
};
}
async notifyEvent(eventName: AppStateEvent, data?: any) {
/**
* Timeout is particullary important so we can give all initial
* controllers a chance to construct before propogting any events *
*/
return new Promise((resolve) => {
this.$timeout(async () => {
for (const callback of this.observers) {
await callback(eventName, data);
}
resolve();
});
});
}
setSelectedTag(tag: SNTag) {
if (this.selectedTag === tag) {
return;
}
const previousTag = this.selectedTag;
this.selectedTag = tag;
this.notifyEvent(
AppStateEvent.TagChanged,
{
tag: tag,
previousTag: previousTag
}
);
}
/** Returns the tags that are referncing this note */
public getNoteTags(note: SNNote) {
return this.application.referencingForItem(note).filter((ref) => {
return ref.content_type === ContentType.Tag;
}) as SNTag[]
}
/** Returns the notes this tag references */
public getTagNotes(tag: SNTag) {
if (tag.isSmartTag()) {
return this.application.notesMatchingSmartTag(tag as SNSmartTag);
} else {
return this.application.referencesForItem(tag).filter((ref) => {
return ref.content_type === ContentType.Note;
}) as SNNote[]
}
}
public getSelectedTag() {
return this.selectedTag;
}
setUserPreferences(preferences: SNUserPrefs) {
this.userPreferences = preferences;
this.notifyEvent(
AppStateEvent.PreferencesChanged
);
}
panelDidResize(name: string, collapsed: boolean) {
this.notifyEvent(
AppStateEvent.PanelResized,
{
panel: name,
collapsed: collapsed
}
);
}
editorDidFocus(eventSource: EventSource) {
this.notifyEvent(
AppStateEvent.EditorFocused,
{ eventSource: eventSource }
);
}
beganBackupDownload() {
this.notifyEvent(
AppStateEvent.BeganBackupDownload
);
}
endedBackupDownload(success: boolean) {
this.notifyEvent(
AppStateEvent.EndedBackupDownload,
{ success: success }
);
}
/**
* When the desktop appplication extension server is ready.
*/
desktopExtensionsReady() {
this.notifyEvent(
AppStateEvent.DesktopExtsReady
);
}
}

View File

@@ -0,0 +1,254 @@
import { ComponentGroup } from './component_group';
import { EditorGroup } from '@/ui_models/editor_group';
import { InputModalScope } from '@/directives/views/inputModal';
import { PasswordWizardType, PasswordWizardScope } from '@/types';
import {
Environment,
SNApplication,
SNAlertService,
platformFromString,
Challenge,
ProtectedAction
} from 'snjs';
import angular from 'angular';
import { getPlatformString } from '@/utils';
import { AlertService } from '@/services/alertService';
import { WebDeviceInterface } from '@/interface';
import {
DesktopManager,
LockManager,
ArchiveManager,
NativeExtManager,
StatusManager,
ThemeManager,
PreferencesManager,
KeyboardManager
} from '@/services';
import { AppState } from '@/ui_models/app_state';
import { SNWebCrypto } from 'sncrypto/dist/sncrypto-web';
type WebServices = {
appState: AppState
desktopService: DesktopManager
lockService: LockManager
archiveService: ArchiveManager
nativeExtService: NativeExtManager
statusService: StatusManager
themeService: ThemeManager
prefsService: PreferencesManager
keyboardService: KeyboardManager
}
export class WebApplication extends SNApplication {
private $compile?: ng.ICompileService
private scope?: ng.IScope
private onDeinit?: (app: WebApplication) => void
private webServices!: WebServices
private currentAuthenticationElement?: JQLite
public editorGroup: EditorGroup
public componentGroup: ComponentGroup
/* @ngInject */
constructor(
$compile: ng.ICompileService,
$timeout: ng.ITimeoutService,
scope: ng.IScope,
onDeinit: (app: WebApplication) => void
) {
const namespace = '';
const deviceInterface = new WebDeviceInterface(namespace, $timeout);
super(
Environment.Web,
platformFromString(getPlatformString()),
deviceInterface,
new SNWebCrypto(),
new AlertService(),
namespace,
undefined,
undefined,
);
this.$compile = $compile;
this.scope = scope;
this.onDeinit = onDeinit;
deviceInterface.setApplication(this);
this.editorGroup = new EditorGroup(this);
this.componentGroup = new ComponentGroup(this);
}
/** @override */
deinit() {
for (const key of Object.keys(this.webServices)) {
const service = (this.webServices as any)[key];
if (service.deinit) {
service.deinit();
}
service.application = undefined;
}
this.webServices = {} as WebServices;
this.onDeinit!(this);
this.onDeinit = undefined;
this.$compile = undefined;
this.editorGroup.deinit();
this.componentGroup.deinit();
(this.scope! as any).application = undefined;
this.scope!.$destroy();
this.scope = undefined;
/** Allow our Angular directives to be destroyed and any pending digest cycles
* to complete before destroying the global application instance and all its services */
setImmediate(() => {
super.deinit();
})
}
setWebServices(services: WebServices) {
this.webServices = services;
}
public getAppState() {
return this.webServices.appState;
}
public getDesktopService() {
return this.webServices.desktopService;
}
public getLockService() {
return this.webServices.lockService;
}
public getArchiveService() {
return this.webServices.archiveService;
}
public getNativeExtService() {
return this.webServices.nativeExtService;
}
public getStatusService() {
return this.webServices.statusService;
}
public getThemeService() {
return this.webServices.themeService;
}
public getPrefsService() {
return this.webServices.prefsService;
}
public getKeyboardService() {
return this.webServices.keyboardService;
}
async checkForSecurityUpdate() {
return this.protocolUpgradeAvailable();
}
presentPasswordWizard(type: PasswordWizardType) {
const scope = this.scope!.$new(true) as PasswordWizardScope;
scope.type = type;
scope.application = this;
const el = this.$compile!(
"<password-wizard application='application' type='type'></password-wizard>"
)(scope as any);
angular.element(document.body).append(el);
}
promptForChallenge(challenge: Challenge) {
const scope: any = this.scope!.$new(true);
scope.challenge = challenge;
scope.application = this;
const el = this.$compile!(
"<challenge-modal " +
"class='sk-modal' application='application' challenge='challenge'>" +
"</challenge-modal>"
)(scope);
angular.element(document.body).append(el);
}
async performProtocolUpgrade() {
const result = await this.upgradeProtocolVersion();
if (result.success) {
this.alertService!.alert(
"Success! Your encryption version has been upgraded." +
" You'll be asked to enter your credentials again on other devices you're signed into."
);
} else if (result.error) {
console.error(result.error);
this.alertService!.alert(
"Unable to upgrade encryption version. Please try again."
);
}
}
async presentPrivilegesModal(
action: ProtectedAction,
onSuccess?: any,
onCancel?: any
) {
if (this.authenticationInProgress()) {
onCancel && onCancel();
return;
}
const customSuccess = async () => {
onSuccess && await onSuccess();
this.currentAuthenticationElement = undefined;
};
const customCancel = async () => {
onCancel && await onCancel();
this.currentAuthenticationElement = undefined;
};
const scope: any = this.scope!.$new(true);
scope.action = action;
scope.onSuccess = customSuccess;
scope.onCancel = customCancel;
scope.application = this;
const el = this.$compile!(`
<privileges-auth-modal application='application' 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() {
const scope: any = this.scope!.$new(true);
scope.application = this;
const el = this.$compile!("<privileges-management-modal application='application' class='sk-modal'></privileges-management-modal>")(scope);
angular.element(document.body).append(el);
}
authenticationInProgress() {
return this.currentAuthenticationElement != null;
}
presentPasswordModal(callback: () => void) {
const scope = this.scope!.$new(true) as InputModalScope;
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 as any);
angular.element(document.body).append(el);
}
presentRevisionPreviewModal(uuid: string, content: any) {
const scope: any = this.scope!.$new(true);
scope.uuid = uuid;
scope.content = content;
scope.application = this;
const el = this.$compile!(
`<revision-preview-modal application='application' uuid='uuid' content='content'
class='sk-modal'></revision-preview-modal>`
)(scope);
angular.element(document.body).append(el);
}
}

View File

@@ -0,0 +1,136 @@
import { WebApplication } from './application';
import { removeFromArray } from 'snjs';
import {
ArchiveManager,
DesktopManager,
KeyboardManager,
LockManager,
NativeExtManager,
PreferencesManager,
StatusManager,
ThemeManager
} from '@/services';
import { AppState } from '@/ui_models/app_state';
type AppManagerChangeCallback = () => void
export class ApplicationGroup {
$compile: ng.ICompileService
$rootScope: ng.IRootScopeService
$timeout: ng.ITimeoutService
applications: WebApplication[] = []
changeObservers: AppManagerChangeCallback[] = []
activeApplication?: WebApplication
/* @ngInject */
constructor(
$compile: ng.ICompileService,
$rootScope: ng.IRootScopeService,
$timeout: ng.ITimeoutService
) {
this.$compile = $compile;
this.$timeout = $timeout;
this.$rootScope = $rootScope;
this.onApplicationDeinit = this.onApplicationDeinit.bind(this);
this.createDefaultApplication();
}
private createDefaultApplication() {
this.activeApplication = this.createNewApplication();
this.applications.push(this.activeApplication!);
this.notifyObserversOfAppChange();
}
/** @callback */
onApplicationDeinit(application: WebApplication) {
removeFromArray(this.applications, application);
if (this.activeApplication === application) {
this.activeApplication = undefined;
}
if (this.applications.length === 0) {
this.createDefaultApplication();
} else {
this.notifyObserversOfAppChange();
}
}
private createNewApplication() {
const scope = this.$rootScope.$new(true);
const application = new WebApplication(
this.$compile,
this.$timeout,
scope,
this.onApplicationDeinit
);
const appState = new AppState(
this.$rootScope,
this.$timeout,
application
);
const archiveService = new ArchiveManager(
application
);
const desktopService = new DesktopManager(
this.$rootScope,
this.$timeout,
application
);
const keyboardService = new KeyboardManager();
const lockService = new LockManager(
application
);
const nativeExtService = new NativeExtManager(
application
);
const prefsService = new PreferencesManager(
application
);
const statusService = new StatusManager();
const themeService = new ThemeManager(
application,
);
application.setWebServices({
appState,
archiveService,
desktopService,
keyboardService,
lockService,
nativeExtService,
prefsService,
statusService,
themeService
});
return application;
}
get application() {
return this.activeApplication;
}
public getApplications() {
return this.applications.slice();
}
/**
* Notifies observer when the active application has changed.
* Any application which is no longer active is destroyed, and
* must be removed from the interface.
*/
public addApplicationChangeObserver(callback: AppManagerChangeCallback) {
this.changeObservers.push(callback);
if (this.application) {
callback();
}
return () => {
removeFromArray(this.changeObservers, callback);
}
}
private notifyObserversOfAppChange() {
for (const observer of this.changeObservers) {
observer();
}
}
}

Some files were not shown because too many files have changed in this diff Show More