feat: add snjs package

This commit is contained in:
Karol Sójko 2022-07-06 14:04:18 +02:00
parent 321a055bae
commit 0e40469e2f
No known key found for this signature in database
GPG key ID: A50543BF560BDEB0
296 changed files with 46109 additions and 187 deletions

1
.gitignore vendored
View file

@ -26,6 +26,7 @@ packages/api/dist
packages/responses/dist packages/responses/dist
packages/sncrypto-common/dist packages/sncrypto-common/dist
packages/sncrypto-web/dist packages/sncrypto-web/dist
packages/snjs/dist
**/.pnp.* **/.pnp.*
**/.yarn/* **/.yarn/*

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -28,6 +28,7 @@
"build:desktop": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/desktop --exclude @standardnotes/components-meta run build", "build:desktop": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/desktop --exclude @standardnotes/components-meta run build",
"build:mobile": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/mobile --exclude @standardnotes/components-meta run build", "build:mobile": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/mobile --exclude @standardnotes/components-meta run build",
"build:web-server": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/web-server --exclude @standardnotes/components-meta run build", "build:web-server": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/web-server --exclude @standardnotes/components-meta run build",
"build:snjs": "yarn workspaces foreach -pt --verbose -R --from @standardnotes/snjs --exclude @standardnotes/components-meta run build",
"start:server:web": "lerna run start --scope=@standardnotes/web-server", "start:server:web": "lerna run start --scope=@standardnotes/web-server",
"start:server:web:localhost": "lerna run start:no-binding --scope=@standardnotes/web-server", "start:server:web:localhost": "lerna run start:no-binding --scope=@standardnotes/web-server",
"prepare": "husky install", "prepare": "husky install",

View file

@ -36,7 +36,7 @@
}, },
"dependencies": { "dependencies": {
"@standardnotes/sncrypto-web": "workspace:*", "@standardnotes/sncrypto-web": "workspace:*",
"@standardnotes/snjs": "^2.61.3", "@standardnotes/snjs": "workspace:*",
"regenerator-runtime": "^0.13.9" "regenerator-runtime": "^0.13.9"
} }
} }

View file

@ -30,7 +30,8 @@ module.exports = (async () => {
'../services', '../services',
'../files', '../files',
'../utils', '../utils',
'../sncrypto-common' '../sncrypto-common',
'../snjs',
], ],
transformer: { transformer: {
getTransformOptions: async () => ({ getTransformOptions: async () => ({

View file

@ -45,7 +45,7 @@
"@standardnotes/react-native-textview": "1.1.0", "@standardnotes/react-native-textview": "1.1.0",
"@standardnotes/react-native-utils": "1.0.1", "@standardnotes/react-native-utils": "1.0.1",
"@standardnotes/sncrypto-common": "workspace:*", "@standardnotes/sncrypto-common": "workspace:*",
"@standardnotes/snjs": "^2.118.3", "@standardnotes/snjs": "workspace:*",
"@standardnotes/stylekit": "5.29.3", "@standardnotes/stylekit": "5.29.3",
"@standardnotes/utils": "workspace:*", "@standardnotes/utils": "workspace:*",
"@standardnotes/web": "workspace:*", "@standardnotes/web": "workspace:*",

View file

@ -0,0 +1,7 @@
Edge 16
Firefox 53
Chrome 57
Safari 11
Opera 44
ios 11
ChromeAndroid 84

View file

@ -0,0 +1,5 @@
node_modules
dist
test
*.config.js
mocha/**/*

9
packages/snjs/.eslintrc Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "../../.eslintrc",
"parserOptions": {
"project": "./linter.tsconfig.json"
},
"rules": {
"@typescript-eslint/no-explicit-any": ["warn", { "ignoreRestArgs": true }]
}
}

2181
packages/snjs/CHANGELOG.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache.forever();
return {
presets: ['@babel/preset-env'],
};
};

View file

@ -0,0 +1,2 @@
//@ts-ignore
global['__VERSION__'] = global['SnjsVersion'] = require('./package.json').version

View file

@ -0,0 +1,37 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const base = require('../../node_modules/@standardnotes/config/src/jest.json');
module.exports = {
...base,
moduleNameMapper: {
'@Lib/(.*)': '<rootDir>/lib/$1',
'@Services/(.*)': '<rootDir>/lib/Services/$1',
},
globals: {
'ts-jest': {
tsconfig: '<rootDir>/lib/tsconfig.json',
isolatedModules: true,
babelConfig: 'babel.config.js',
},
},
clearMocks: true,
collectCoverageFrom: ['lib/**/{!(index),}.ts'],
coverageDirectory: 'coverage',
coverageReporters: ['json', 'text', 'html'],
resetMocks: true,
resetModules: true,
roots: ['<rootDir>/lib'],
setupFiles: ['<rootDir>/jest-global.ts'],
setupFilesAfterEnv: [],
transform: {
'^.+\\.(ts|js)?$': 'ts-jest',
},
coverageThreshold: {
global: {
branches: 13,
functions: 22,
lines: 27,
statements: 28,
},
},
}

22
packages/snjs/jsdoc.json Normal file
View file

@ -0,0 +1,22 @@
{
"source": {
"includePattern": ".+\\.js(doc|x)?$",
"include": ["lib"],
"exclude": ["node_modules"]
},
"recurseDepth": 10,
"opts": {
"destination": "./docs/",
"recurse": true,
"template": "node_modules/docdash"
},
"tags": {
"allowUnknownTags": true,
"dictionaries": ["jsdoc", "closure"]
},
"docdash": {
"meta": {
"title": "SNJS Documentation"
}
}
}

View file

@ -0,0 +1,151 @@
import { SNLog } from './../Log'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import {
AlertService,
DeviceInterface,
Environment,
namespacedKey,
Platform,
RawStorageKey,
} from '@standardnotes/services'
import { SNApplication } from './Application'
describe('application', () => {
// eslint-disable-next-line no-console
SNLog.onLog = console.log
SNLog.onError = console.error
let application: SNApplication
let device: DeviceInterface
let crypto: PureCryptoInterface
beforeEach(async () => {
const identifier = '123'
crypto = {} as jest.Mocked<PureCryptoInterface>
crypto.initialize = jest.fn()
device = {} as jest.Mocked<DeviceInterface>
device.openDatabase = jest.fn().mockResolvedValue(true)
device.getAllRawDatabasePayloads = jest.fn().mockReturnValue([])
device.setRawStorageValue = jest.fn()
device.getRawStorageValue = jest.fn().mockImplementation((key) => {
if (key === namespacedKey(identifier, RawStorageKey.SnjsVersion)) {
return '10.0.0'
}
return undefined
})
device.getDatabaseKeys = async () => {
return Promise.resolve(['1', '2', '3'])
}
application = new SNApplication({
environment: Environment.Mobile,
platform: Platform.Ios,
deviceInterface: device,
crypto: crypto,
alertService: {} as jest.Mocked<AlertService>,
identifier: identifier,
defaultHost: 'localhost',
appVersion: '1.0',
})
await application.prepareForLaunch({ receiveChallenge: jest.fn() })
})
it('diagnostics', async () => {
const diagnostics = await application.getDiagnostics()
expect(diagnostics).toEqual(
expect.objectContaining({
application: expect.objectContaining({
appVersion: '1.0',
environment: 3,
platform: 1,
}),
payloads: {
integrityPayloads: [],
nonDeletedItemCount: 0,
invalidPayloadsCount: 0,
},
items: { allIds: [] },
storage: {
storagePersistable: false,
persistencePolicy: 'Default',
encryptionPolicy: 'Default',
needsPersist: false,
currentPersistPromise: false,
isStorageWrapped: false,
allRawPayloadsCount: 0,
databaseKeys: ['1', '2', '3'],
},
encryption: expect.objectContaining({
getLatestVersion: '004',
hasAccount: false,
getUserVersion: undefined,
upgradeAvailable: false,
accountUpgradeAvailable: false,
passcodeUpgradeAvailable: false,
hasPasscode: false,
isPasscodeLocked: false,
itemsEncryption: expect.objectContaining({
itemsKeysIds: [],
}),
rootKeyEncryption: expect.objectContaining({
hasRootKey: false,
keyMode: 'RootKeyNone',
hasRootKeyWrapper: false,
hasAccount: false,
hasPasscode: false,
}),
}),
api: {
hasSession: false,
user: undefined,
registering: false,
authenticating: false,
changing: false,
refreshingSession: false,
filesHost: undefined,
host: 'localhost',
},
session: {
isSessionRenewChallengePresented: false,
online: false,
offline: true,
isSignedIn: false,
isSignedIntoFirstPartyServer: false,
},
sync: {
syncToken: undefined,
cursorToken: undefined,
lastSyncDate: undefined,
outOfSync: false,
completedOnlineDownloadFirstSync: false,
clientLocked: false,
databaseLoaded: false,
syncLock: false,
dealloced: false,
itemsNeedingSync: [],
itemsNeedingSyncCount: 0,
pendingRequestCount: 0,
},
protections: expect.objectContaining({
getLastSessionLength: undefined,
hasProtectionSources: false,
hasUnprotectedAccessSession: true,
hasBiometricsEnabled: false,
}),
keyRecovery: { queueLength: 0, isProcessingQueue: false },
features: {
roles: [],
features: [],
enabledExperimentalFeatures: [],
needsInitialFeaturesUpdate: true,
completedSuccessfulFeaturesRetrieval: false,
},
migrations: { activeMigrations: [] },
}),
)
})
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,90 @@
import { SyncEvent } from '@standardnotes/services'
export { SyncEvent }
export enum ApplicationEvent {
SignedIn = 2,
SignedOut = 3,
/** When a full, potentially multi-page sync completes */
CompletedFullSync = 5,
FailedSync = 6,
HighLatencySync = 7,
EnteredOutOfSync = 8,
ExitedOutOfSync = 9,
/**
* The application has finished it `prepareForLaunch` state and is now ready for unlock
* Called when the application has initialized and is ready for launch, but before
* the application has been unlocked, if applicable. Use this to do pre-launch
* configuration, but do not attempt to access user data like notes or tags.
*/
Started = 10,
/**
* The applicaiton is fully unlocked and ready for i/o
* Called when the application has been fully decrypted and unlocked. Use this to
* to begin streaming data like notes and tags.
*/
Launched = 11,
LocalDataLoaded = 12,
/**
* When the root key or root key wrapper changes. Includes events like account state
* changes (registering, signing in, changing pw, logging out) and passcode state
* changes (adding, removing, changing).
*/
KeyStatusChanged = 13,
MajorDataChange = 14,
CompletedRestart = 15,
LocalDataIncrementalLoad = 16,
SyncStatusChanged = 17,
WillSync = 18,
InvalidSyncSession = 19,
LocalDatabaseReadError = 20,
LocalDatabaseWriteError = 21,
/** When a single roundtrip completes with sync, in a potentially multi-page sync request.
* If just a single roundtrip, this event will be triggered, along with CompletedFullSync */
CompletedIncrementalSync = 22,
/**
* The application has loaded all pending migrations (but not run any, except for the base one),
* and consumers may now call `hasPendingMigrations`
*/
MigrationsLoaded = 23,
/** When StorageService is ready to start servicing read/write requests */
StorageReady = 24,
PreferencesChanged = 25,
UnprotectedSessionBegan = 26,
UserRolesChanged = 27,
FeaturesUpdated = 28,
UnprotectedSessionExpired = 29,
/** Called when the app first launches and after first sync request made after sign in */
CompletedInitialSync = 30,
}
export function applicationEventForSyncEvent(syncEvent: SyncEvent) {
return (
{
[SyncEvent.SyncCompletedWithAllItemsUploaded]: ApplicationEvent.CompletedFullSync,
[SyncEvent.SingleRoundTripSyncCompleted]: ApplicationEvent.CompletedIncrementalSync,
[SyncEvent.SyncError]: ApplicationEvent.FailedSync,
[SyncEvent.SyncTakingTooLong]: ApplicationEvent.HighLatencySync,
[SyncEvent.EnterOutOfSync]: ApplicationEvent.EnteredOutOfSync,
[SyncEvent.ExitOutOfSync]: ApplicationEvent.ExitedOutOfSync,
[SyncEvent.LocalDataLoaded]: ApplicationEvent.LocalDataLoaded,
[SyncEvent.MajorDataChange]: ApplicationEvent.MajorDataChange,
[SyncEvent.LocalDataIncrementalLoad]: ApplicationEvent.LocalDataIncrementalLoad,
[SyncEvent.StatusChanged]: ApplicationEvent.SyncStatusChanged,
[SyncEvent.SyncWillBegin]: ApplicationEvent.WillSync,
[SyncEvent.InvalidSession]: ApplicationEvent.InvalidSyncSession,
[SyncEvent.DatabaseReadError]: ApplicationEvent.LocalDatabaseReadError,
[SyncEvent.DatabaseWriteError]: ApplicationEvent.LocalDatabaseWriteError,
[SyncEvent.DownloadFirstSyncCompleted]: ApplicationEvent.CompletedInitialSync,
} as any
)[syncEvent]
}

View file

@ -0,0 +1,34 @@
import { DecryptedItemInterface } from '@standardnotes/models'
import { SNApplication } from './Application'
/** Keeps an item reference up to date with changes */
export class LiveItem<T extends DecryptedItemInterface> {
public item: T
private removeObserver: () => void
constructor(uuid: string, application: SNApplication, onChange?: (item: T) => void) {
this.item = application.items.findSureItem(uuid)
onChange && onChange(this.item)
this.removeObserver = application.streamItems(this.item.content_type, ({ changed, inserted }) => {
const matchingItem = [...changed, ...inserted].find((item) => {
return item.uuid === uuid
})
if (matchingItem) {
this.item = matchingItem as T
onChange && onChange(this.item)
}
})
}
public deinit() {
if (!this.removeObserver) {
console.error('A LiveItem is attempting to be deinited more than once.')
} else {
this.removeObserver()
;(this.removeObserver as unknown) = undefined
}
}
}

View file

@ -0,0 +1,16 @@
import { ApplicationOptionsWhichHaveDefaults } from './Defaults'
import {
ApplicationDisplayOptions,
ApplicationOptionalConfiguratioOptions,
ApplicationSyncOptions,
} from './OptionalOptions'
import { RequiredApplicationOptions } from './RequiredOptions'
export type ApplicationConstructorOptions = RequiredApplicationOptions &
Partial<ApplicationSyncOptions & ApplicationDisplayOptions & ApplicationOptionalConfiguratioOptions>
export type FullyResolvedApplicationOptions = RequiredApplicationOptions &
ApplicationSyncOptions &
ApplicationDisplayOptions &
ApplicationOptionalConfiguratioOptions &
ApplicationOptionsWhichHaveDefaults

View file

@ -0,0 +1,11 @@
import { ApplicationDisplayOptions, ApplicationSyncOptions } from './OptionalOptions'
export interface ApplicationOptionsWhichHaveDefaults {
loadBatchSize: ApplicationSyncOptions['loadBatchSize']
supportsFileNavigation: ApplicationDisplayOptions['supportsFileNavigation']
}
export const ApplicationOptionsDefaults: ApplicationOptionsWhichHaveDefaults = {
loadBatchSize: 700,
supportsFileNavigation: false,
}

View file

@ -0,0 +1,25 @@
export interface ApplicationSyncOptions {
/**
* The size of the item batch to decrypt and render upon application load.
*/
loadBatchSize: number
}
export interface ApplicationDisplayOptions {
supportsFileNavigation: boolean
}
export interface ApplicationOptionalConfiguratioOptions {
/**
* Gives consumers the ability to provide their own custom
* subclass for a service. swapClasses should be an array of key/value pairs
* consisting of keys 'swap' and 'with'. 'swap' is the base class you wish to replace,
* and 'with' is the custom subclass to use.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
swapClasses?: { swap: any; with: any }[]
/**
* URL for WebSocket providing permissions and roles information.
*/
webSocketUrl?: string
}

View file

@ -0,0 +1,42 @@
import { ApplicationIdentifier } from '@standardnotes/common'
import { AlertService, DeviceInterface, Environment, Platform } from '@standardnotes/services'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
export interface RequiredApplicationOptions {
/**
* The Environment that identifies your application.
*/
environment: Environment
/**
* The Platform that identifies your application.
*/
platform: Platform
/**
* The device interface that provides platform specific
* utilities that are used to read/write raw values from/to the database or value storage.
*/
deviceInterface: DeviceInterface
/**
* The platform-dependent implementation of SNPureCrypto to use.
* Web uses SNWebCrypto, mobile uses SNReactNativeCrypto.
*/
crypto: PureCryptoInterface
/**
* The platform-dependent implementation of alert service.
*/
alertService: AlertService
/**
* A unique persistent identifier to namespace storage and other
* persistent properties. For an ephemeral runtime identifier, use ephemeralIdentifier.
*/
identifier: ApplicationIdentifier
/**
* Default host to use in ApiService.
*/
defaultHost: string
/**
* Version of client application.
*/
appVersion: string
}

View file

@ -0,0 +1,55 @@
import { Environment, Platform } from '@standardnotes/services'
export function platformFromString(string: string) {
const map: Record<string, Platform> = {
'mac-web': Platform.MacWeb,
'mac-desktop': Platform.MacDesktop,
'linux-web': Platform.LinuxWeb,
'linux-desktop': Platform.LinuxDesktop,
'windows-web': Platform.WindowsWeb,
'windows-desktop': Platform.WindowsDesktop,
ios: Platform.Ios,
android: Platform.Android,
}
return map[string]
}
export function platformToString(platform: Platform) {
const map = {
[Platform.MacWeb]: 'mac-web',
[Platform.MacDesktop]: 'mac-desktop',
[Platform.LinuxWeb]: 'linux-web',
[Platform.LinuxDesktop]: 'linux-desktop',
[Platform.WindowsWeb]: 'windows-web',
[Platform.WindowsDesktop]: 'windows-desktop',
[Platform.Ios]: 'ios',
[Platform.Android]: 'android',
}
return map[platform]
}
export function environmentFromString(string: string) {
const map: Record<string, Environment> = {
web: Environment.Web,
desktop: Environment.Desktop,
mobile: Environment.Mobile,
}
return map[string]
}
export function environmentToString(environment: Environment) {
const map = {
[Environment.Web]: 'web',
[Environment.Desktop]: 'desktop',
[Environment.Mobile]: 'mobile',
}
return map[environment]
}
export function isEnvironmentWebOrDesktop(environment: Environment) {
return environment === Environment.Web || environment === Environment.Desktop
}
export function isEnvironmentMobile(environment: Environment) {
return environment === Environment.Mobile
}

View file

@ -0,0 +1,4 @@
export * from './Application'
export * from './Event'
export * from './LiveItem'
export * from './Platforms'

View file

@ -0,0 +1,6 @@
import { AppGroupManagedApplication, DeviceInterface } from '@standardnotes/services'
import { ApplicationDescriptor } from './ApplicationDescriptor'
export type AppGroupCallback<D extends DeviceInterface = DeviceInterface> = {
applicationCreator: (descriptor: ApplicationDescriptor, deviceInterface: D) => Promise<AppGroupManagedApplication>
}

View file

@ -0,0 +1,7 @@
import { ApplicationIdentifier } from '@standardnotes/common'
export type ApplicationDescriptor = {
identifier: ApplicationIdentifier
label: string
primary: boolean
}

View file

@ -0,0 +1,247 @@
import {
AbstractService,
AppGroupManagedApplication,
DeinitSource,
DeinitCallback,
DeviceInterface,
DeinitMode,
InternalEventBus,
InternalEventBusInterface,
RawStorageKey,
} from '@standardnotes/services'
import { UuidGenerator } from '@standardnotes/utils'
import { AppGroupCallback } from './AppGroupCallback'
import { ApplicationGroupEvent, ApplicationGroupEventData } from './ApplicationGroupEvent'
import { DescriptorRecord } from './DescriptorRecord'
import { ApplicationDescriptor } from './ApplicationDescriptor'
export class SNApplicationGroup<D extends DeviceInterface = DeviceInterface> extends AbstractService<
ApplicationGroupEvent,
| ApplicationGroupEventData[ApplicationGroupEvent.PrimaryApplicationSet]
| ApplicationGroupEventData[ApplicationGroupEvent.DeviceWillRestart]
| ApplicationGroupEventData[ApplicationGroupEvent.DescriptorsDataChanged]
> {
public primaryApplication!: AppGroupManagedApplication
private descriptorRecord!: DescriptorRecord
callback!: AppGroupCallback<D>
constructor(public device: D, internalEventBus?: InternalEventBusInterface) {
if (internalEventBus === undefined) {
internalEventBus = new InternalEventBus()
}
super(internalEventBus)
}
override deinit() {
super.deinit()
this.device.deinit()
;(this.device as unknown) = undefined
;(this.callback as unknown) = undefined
;(this.primaryApplication as unknown) = undefined
;(this.onApplicationDeinit as unknown) = undefined
}
public async initialize(callback: AppGroupCallback<D>): Promise<void> {
if (this.device.isDeviceDestroyed()) {
throw 'Attempting to initialize new application while device is destroyed.'
}
this.callback = callback
this.descriptorRecord = (await this.device.getJsonParsedRawStorageValue(
RawStorageKey.DescriptorRecord,
)) as DescriptorRecord
if (!this.descriptorRecord) {
await this.createNewDescriptorRecord()
}
let primaryDescriptor = this.findPrimaryDescriptor()
if (!primaryDescriptor) {
console.error('No primary application descriptor found. Ensure migrations have been run.')
primaryDescriptor = this.getDescriptors()[0]
this.setDescriptorAsPrimary(primaryDescriptor)
await this.persistDescriptors()
}
const application = await this.buildApplication(primaryDescriptor)
this.primaryApplication = application
await this.notifyEvent(ApplicationGroupEvent.PrimaryApplicationSet, { application: application })
}
private async createNewDescriptorRecord() {
/**
* The identifier 'standardnotes' is used because this was the
* database name of Standard Notes web/desktop
* */
const identifier = 'standardnotes'
const descriptorRecord: DescriptorRecord = {
[identifier]: {
identifier: identifier,
label: 'Main Workspace',
primary: true,
},
}
void this.device.setRawStorageValue(RawStorageKey.DescriptorRecord, JSON.stringify(descriptorRecord))
this.descriptorRecord = descriptorRecord
await this.persistDescriptors()
}
public getDescriptors() {
return Object.values(this.descriptorRecord)
}
private findPrimaryDescriptor() {
for (const descriptor of this.getDescriptors()) {
if (descriptor.primary) {
return descriptor
}
}
return undefined
}
async signOutAllWorkspaces() {
await this.primaryApplication.user.signOut(false, DeinitSource.SignOutAll)
}
onApplicationDeinit: DeinitCallback = (
application: AppGroupManagedApplication,
mode: DeinitMode,
source: DeinitSource,
) => {
if (this.primaryApplication === application) {
;(this.primaryApplication as unknown) = undefined
}
const performSyncronously = async () => {
if (source === DeinitSource.SignOut) {
void this.removeDescriptor(this.descriptorForApplication(application))
}
const descriptors = this.getDescriptors()
if (descriptors.length === 0 || source === DeinitSource.SignOutAll) {
const identifiers = descriptors.map((d) => d.identifier)
this.descriptorRecord = {}
const { killsApplication } = await this.device.clearAllDataFromDevice(identifiers)
if (killsApplication) {
return
}
}
const device = this.device
void this.notifyEvent(ApplicationGroupEvent.DeviceWillRestart, { source, mode })
this.deinit()
if (mode === DeinitMode.Hard) {
device.performHardReset()
} else {
device.performSoftReset()
}
}
void performSyncronously()
}
public setDescriptorAsPrimary(primaryDescriptor: ApplicationDescriptor) {
for (const descriptor of this.getDescriptors()) {
descriptor.primary = descriptor === primaryDescriptor
}
}
private async persistDescriptors() {
await this.device.setRawStorageValue(RawStorageKey.DescriptorRecord, JSON.stringify(this.descriptorRecord))
void this.notifyEvent(ApplicationGroupEvent.DescriptorsDataChanged, { descriptors: this.descriptorRecord })
}
public renameDescriptor(descriptor: ApplicationDescriptor, label: string) {
descriptor.label = label
void this.persistDescriptors()
}
public removeDescriptor(descriptor: ApplicationDescriptor) {
delete this.descriptorRecord[descriptor.identifier]
const descriptors = this.getDescriptors()
if (descriptor.primary && descriptors.length > 0) {
this.setDescriptorAsPrimary(descriptors[0])
}
return this.persistDescriptors()
}
public removeAllDescriptors() {
this.descriptorRecord = {}
return this.persistDescriptors()
}
private descriptorForApplication(application: AppGroupManagedApplication) {
return this.descriptorRecord[application.identifier]
}
private createNewApplicationDescriptor(label?: string) {
const identifier = UuidGenerator.GenerateUuid()
const index = this.getDescriptors().length + 1
const descriptor: ApplicationDescriptor = {
identifier: identifier,
label: label || `Workspace ${index}`,
primary: false,
}
return descriptor
}
private async createNewPrimaryDescriptor(label?: string): Promise<void> {
const descriptor = this.createNewApplicationDescriptor(label)
this.descriptorRecord[descriptor.identifier] = descriptor
this.setDescriptorAsPrimary(descriptor)
await this.persistDescriptors()
}
public async unloadCurrentAndCreateNewDescriptor(label?: string): Promise<void> {
await this.createNewPrimaryDescriptor(label)
if (this.primaryApplication) {
this.primaryApplication.deinit(this.primaryApplication.getDeinitMode(), DeinitSource.SwitchWorkspace)
}
}
public async unloadCurrentAndActivateDescriptor(descriptor: ApplicationDescriptor) {
this.setDescriptorAsPrimary(descriptor)
await this.persistDescriptors()
if (this.primaryApplication) {
this.primaryApplication.deinit(this.primaryApplication.getDeinitMode(), DeinitSource.SwitchWorkspace)
}
}
private async buildApplication(descriptor: ApplicationDescriptor) {
const application = await this.callback.applicationCreator(descriptor, this.device)
application.setOnDeinit(this.onApplicationDeinit)
return application
}
}

View file

@ -0,0 +1,21 @@
import { ApplicationInterface, DeinitMode, DeinitSource } from '@standardnotes/services'
import { DescriptorRecord } from './DescriptorRecord'
export enum ApplicationGroupEvent {
PrimaryApplicationSet = 'PrimaryApplicationSet',
DescriptorsDataChanged = 'DescriptorsDataChanged',
DeviceWillRestart = 'DeviceWillRestart',
}
export interface ApplicationGroupEventData {
[ApplicationGroupEvent.PrimaryApplicationSet]: {
application: ApplicationInterface
}
[ApplicationGroupEvent.DeviceWillRestart]: {
source: DeinitSource
mode: DeinitMode
}
[ApplicationGroupEvent.DescriptorsDataChanged]: {
descriptors: DescriptorRecord
}
}

View file

@ -0,0 +1,3 @@
import { AppGroupManagedApplication, DeinitSource, DeinitMode } from '@standardnotes/services'
export type DeinitCallback = (application: AppGroupManagedApplication, mode: DeinitMode, source: DeinitSource) => void

View file

@ -0,0 +1,3 @@
import { ApplicationDescriptor } from './ApplicationDescriptor'
export type DescriptorRecord = Record<string, ApplicationDescriptor>

View file

@ -0,0 +1,5 @@
export * from './AppGroupCallback'
export * from './ApplicationDescriptor'
export * from './ApplicationGroup'
export * from './ApplicationGroupEvent'
export * from './DescriptorRecord'

View file

@ -0,0 +1,41 @@
import { FileItem } from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { SNApplication } from '../Application/Application'
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
export class FileViewController implements ItemViewControllerInterface {
public dealloced = false
private removeStreamObserver?: () => void
constructor(private application: SNApplication, public item: FileItem) {}
deinit() {
this.dealloced = true
this.removeStreamObserver?.()
;(this.removeStreamObserver as unknown) = undefined
;(this.application as unknown) = undefined
;(this.item as unknown) = undefined
}
async initialize() {
this.streamItems()
}
private streamItems() {
this.removeStreamObserver = this.application.streamItems<FileItem>(ContentType.File, ({ changed, inserted }) => {
if (this.dealloced) {
return
}
const files = changed.concat(inserted)
const matchingFile = files.find((item) => {
return item.uuid === this.item.uuid
})
if (matchingFile) {
this.item = matchingFile
}
})
}
}

View file

@ -0,0 +1,69 @@
import { IconsController } from './IconsController'
describe('IconsController', () => {
let iconsController: IconsController
beforeEach(() => {
iconsController = new IconsController()
})
describe('getIconForFileType', () => {
it('should return correct icon type for supported mimetypes', () => {
const iconTypeForPdf = iconsController.getIconForFileType('application/pdf')
expect(iconTypeForPdf).toBe('file-pdf')
const iconTypeForDoc = iconsController.getIconForFileType('application/msword')
const iconTypeForDocx = iconsController.getIconForFileType(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
expect(iconTypeForDoc).toBe('file-doc')
expect(iconTypeForDocx).toBe('file-doc')
const iconTypeForPpt = iconsController.getIconForFileType('application/vnd.ms-powerpoint')
const iconTypeForPptx = iconsController.getIconForFileType(
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
)
expect(iconTypeForPpt).toBe('file-ppt')
expect(iconTypeForPptx).toBe('file-ppt')
const iconTypeForXls = iconsController.getIconForFileType('application/vnd.ms-excel')
const iconTypeForXlsx = iconsController.getIconForFileType(
'application/vnd.openxmlformats-officedocument.spreadsheetml.spreadsheet',
)
expect(iconTypeForXls).toBe('file-xls')
expect(iconTypeForXlsx).toBe('file-xls')
const iconTypeForJpg = iconsController.getIconForFileType('image/jpeg')
const iconTypeForPng = iconsController.getIconForFileType('image/png')
expect(iconTypeForJpg).toBe('file-image')
expect(iconTypeForPng).toBe('file-image')
const iconTypeForMpeg = iconsController.getIconForFileType('video/mpeg')
const iconTypeForMp4 = iconsController.getIconForFileType('video/mp4')
expect(iconTypeForMpeg).toBe('file-mov')
expect(iconTypeForMp4).toBe('file-mov')
const iconTypeForWav = iconsController.getIconForFileType('audio/wav')
const iconTypeForMp3 = iconsController.getIconForFileType('audio/mp3')
expect(iconTypeForWav).toBe('file-music')
expect(iconTypeForMp3).toBe('file-music')
const iconTypeForZip = iconsController.getIconForFileType('application/zip')
const iconTypeForRar = iconsController.getIconForFileType('application/vnd.rar')
const iconTypeForTar = iconsController.getIconForFileType('application/x-tar')
const iconTypeFor7z = iconsController.getIconForFileType('application/x-7z-compressed')
expect(iconTypeForZip).toBe('file-zip')
expect(iconTypeForRar).toBe('file-zip')
expect(iconTypeForTar).toBe('file-zip')
expect(iconTypeFor7z).toBe('file-zip')
})
it('should return fallback icon type for unsupported mimetypes', () => {
const iconForBin = iconsController.getIconForFileType('application/octet-stream')
expect(iconForBin).toBe('file-other')
const iconForNoType = iconsController.getIconForFileType('')
expect(iconForNoType).toBe('file-other')
})
})
})

View file

@ -0,0 +1,61 @@
import { NoteType } from '@standardnotes/features'
import { IconType } from '@Lib/Types/IconType'
export class IconsController {
getIconForFileType(type: string): IconType {
let iconType: IconType = 'file-other'
if (type === 'application/pdf') {
iconType = 'file-pdf'
}
if (/word/.test(type)) {
iconType = 'file-doc'
}
if (/powerpoint|presentation/.test(type)) {
iconType = 'file-ppt'
}
if (/excel|spreadsheet/.test(type)) {
iconType = 'file-xls'
}
if (/^image\//.test(type)) {
iconType = 'file-image'
}
if (/^video\//.test(type)) {
iconType = 'file-mov'
}
if (/^audio\//.test(type)) {
iconType = 'file-music'
}
if (/(zip)|([tr]ar)|(7z)/.test(type)) {
iconType = 'file-zip'
}
return iconType
}
getIconAndTintForNoteType(noteType?: NoteType): [IconType, number] {
switch (noteType) {
case NoteType.RichText:
return ['rich-text', 1]
case NoteType.Markdown:
return ['markdown', 2]
case NoteType.Authentication:
return ['authenticator', 6]
case NoteType.Spreadsheet:
return ['spreadsheets', 5]
case NoteType.Task:
return ['tasks', 3]
case NoteType.Code:
return ['code', 4]
default:
return ['plain-text', 1]
}
}
}

View file

@ -0,0 +1,125 @@
import { ApplicationEvent } from '../Application/Event'
import { FileItem, PrefKey, SNNote } from '@standardnotes/models'
import { removeFromArray } from '@standardnotes/utils'
import { SNApplication } from '../Application/Application'
import { NoteViewController } from './NoteViewController'
import { FileViewController } from './FileViewController'
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
type ItemControllerGroupChangeCallback = (activeController: NoteViewController | FileViewController | undefined) => void
type CreateItemControllerOptions = FileItem | SNNote | TemplateNoteViewControllerOptions
export class ItemGroupController {
public itemControllers: (NoteViewController | FileViewController)[] = []
private addTagHierarchy: boolean
changeObservers: ItemControllerGroupChangeCallback[] = []
eventObservers: (() => void)[] = []
constructor(private application: SNApplication) {
this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true)
this.eventObservers.push(
application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true)
}),
)
}
public deinit(): void {
;(this.application as unknown) = undefined
this.eventObservers.forEach((removeObserver) => {
removeObserver()
})
this.changeObservers.length = 0
for (const controller of this.itemControllers) {
this.closeItemController(controller, { notify: false })
}
this.itemControllers.length = 0
}
async createItemController(options: CreateItemControllerOptions): Promise<NoteViewController | FileViewController> {
if (this.activeItemViewController) {
this.closeItemController(this.activeItemViewController, { notify: false })
}
let controller!: NoteViewController | FileViewController
if (options instanceof FileItem) {
const file = options
controller = new FileViewController(this.application, file)
} else if (options instanceof SNNote) {
const note = options
controller = new NoteViewController(this.application, note)
} else {
controller = new NoteViewController(this.application, undefined, options)
}
this.itemControllers.push(controller)
await controller.initialize(this.addTagHierarchy)
this.notifyObservers()
return controller
}
public closeItemController(
controller: NoteViewController | FileViewController,
{ notify = true }: { notify: boolean } = { notify: true },
): void {
controller.deinit()
removeFromArray(this.itemControllers, controller)
if (notify) {
this.notifyObservers()
}
}
closeActiveItemController(): void {
const activeController = this.activeItemViewController
if (activeController) {
this.closeItemController(activeController, { notify: true })
}
}
closeAllItemControllers(): void {
for (const controller of this.itemControllers) {
this.closeItemController(controller, { notify: false })
}
this.notifyObservers()
}
get activeItemViewController(): NoteViewController | FileViewController | undefined {
return this.itemControllers[0]
}
/**
* Notifies observer when the active controller has changed.
*/
public addActiveControllerChangeObserver(callback: ItemControllerGroupChangeCallback): () => void {
this.changeObservers.push(callback)
if (this.activeItemViewController) {
callback(this.activeItemViewController)
}
const thislessChangeObservers = this.changeObservers
return () => {
removeFromArray(thislessChangeObservers, callback)
}
}
private notifyObservers(): void {
for (const observer of this.changeObservers) {
observer(this.activeItemViewController)
}
}
}

View file

@ -0,0 +1,8 @@
import { SNNote, FileItem } from '@standardnotes/models'
export interface ItemViewControllerInterface {
item: SNNote | FileItem
deinit: () => void
initialize(addTagHierarchy?: boolean): Promise<void>
}

View file

@ -0,0 +1,208 @@
import {
NoteMutator,
SNNote,
SNTag,
NoteContent,
DecryptedItemInterface,
PayloadEmitSource,
} from '@standardnotes/models'
import { removeFromArray } from '@standardnotes/utils'
import { ContentType } from '@standardnotes/common'
import { UuidString } from '@Lib/Types/UuidString'
import { SNApplication } from '../Application/Application'
import {
STRING_SAVING_WHILE_DOCUMENT_HIDDEN,
STRING_INVALID_NOTE,
NOTE_PREVIEW_CHAR_LIMIT,
STRING_ELLIPSES,
SAVE_TIMEOUT_NO_DEBOUNCE,
SAVE_TIMEOUT_DEBOUNCE,
} from './Types'
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
export type EditorValues = {
title: string
text: string
}
export class NoteViewController implements ItemViewControllerInterface {
public item!: SNNote
public dealloced = false
private innerValueChangeObservers: ((note: SNNote, source: PayloadEmitSource) => void)[] = []
private removeStreamObserver?: () => void
public isTemplateNote = false
private saveTimeout?: ReturnType<typeof setTimeout>
private defaultTitle: string | undefined
private defaultTag: UuidString | undefined
constructor(
private application: SNApplication,
item?: SNNote,
templateNoteOptions?: TemplateNoteViewControllerOptions,
) {
if (item) {
this.item = item
}
if (templateNoteOptions) {
this.defaultTitle = templateNoteOptions.title
this.defaultTag = templateNoteOptions.tag
}
}
deinit(): void {
this.dealloced = true
this.removeStreamObserver?.()
;(this.removeStreamObserver as unknown) = undefined
;(this.application as unknown) = undefined
;(this.item as unknown) = undefined
this.innerValueChangeObservers.length = 0
this.saveTimeout = undefined
}
async initialize(addTagHierarchy: boolean): Promise<void> {
if (!this.item) {
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
text: '',
title: this.defaultTitle || '',
references: [],
})
this.isTemplateNote = true
this.item = note
if (this.defaultTag) {
const tag = this.application.items.findItem(this.defaultTag) as SNTag
await this.application.items.addTagToNote(note, tag, addTagHierarchy)
}
this.notifyObservers(this.item, PayloadEmitSource.InitialObserverRegistrationPush)
}
this.streamItems()
}
private notifyObservers(note: SNNote, source: PayloadEmitSource): void {
for (const observer of this.innerValueChangeObservers) {
observer(note, source)
}
}
private streamItems() {
this.removeStreamObserver = this.application.streamItems<SNNote>(
ContentType.Note,
({ changed, inserted, source }) => {
if (this.dealloced) {
return
}
const notes = changed.concat(inserted)
const matchingNote = notes.find((item) => {
return item.uuid === this.item.uuid
})
if (matchingNote) {
this.isTemplateNote = false
this.item = matchingNote
this.notifyObservers(matchingNote, source)
}
},
)
}
public insertTemplatedNote(): Promise<DecryptedItemInterface> {
this.isTemplateNote = false
return this.application.mutator.insertItem(this.item)
}
/**
* Register to be notified when the controller's note's inner values change
* (and thus a new object reference is created)
*/
public addNoteInnerValueChangeObserver(callback: (note: SNNote, source: PayloadEmitSource) => void): () => void {
this.innerValueChangeObservers.push(callback)
if (this.item) {
callback(this.item, PayloadEmitSource.InitialObserverRegistrationPush)
}
const thislessChangeObservers = this.innerValueChangeObservers
return () => {
removeFromArray(thislessChangeObservers, callback)
}
}
/**
* @param bypassDebouncer Calling save will debounce by default. You can pass true to save
* immediately.
* @param isUserModified This field determines if the item will be saved as a user
* modification, thus updating the user modified date displayed in the UI
* @param dontUpdatePreviews Whether this change should update the note's plain and HTML
* preview.
* @param customMutate A custom mutator function.
*/
public async save(dto: {
editorValues: EditorValues
bypassDebouncer?: boolean
isUserModified?: boolean
dontUpdatePreviews?: boolean
customMutate?: (mutator: NoteMutator) => void
}): Promise<void> {
const title = dto.editorValues.title
const text = dto.editorValues.text
const isTemplate = this.isTemplateNote
if (typeof document !== 'undefined' && document.hidden) {
void this.application.alertService.alert(STRING_SAVING_WHILE_DOCUMENT_HIDDEN)
return
}
if (isTemplate) {
await this.insertTemplatedNote()
}
if (!this.application.items.findItem(this.item.uuid)) {
void this.application.alertService.alert(STRING_INVALID_NOTE)
return
}
await this.application.mutator.changeItem(
this.item,
(mutator) => {
const noteMutator = mutator as NoteMutator
if (dto.customMutate) {
dto.customMutate(noteMutator)
}
noteMutator.title = title
noteMutator.text = text
if (!dto.dontUpdatePreviews) {
const noteText = text || ''
const truncate = noteText.length > NOTE_PREVIEW_CHAR_LIMIT
const substring = noteText.substring(0, NOTE_PREVIEW_CHAR_LIMIT)
const previewPlain = substring + (truncate ? STRING_ELLIPSES : '')
// eslint-disable-next-line camelcase
noteMutator.preview_plain = previewPlain
// eslint-disable-next-line camelcase
noteMutator.preview_html = undefined
}
},
dto.isUserModified,
)
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
const noDebounce = dto.bypassDebouncer || this.application.noAccount()
const syncDebouceMs = noDebounce ? SAVE_TIMEOUT_NO_DEBOUNCE : SAVE_TIMEOUT_DEBOUNCE
this.saveTimeout = setTimeout(() => {
void this.application.sync.sync()
}, syncDebouceMs)
}
}

View file

@ -0,0 +1,6 @@
import { UuidString } from '@Lib/Types/UuidString'
export type TemplateNoteViewControllerOptions = {
title?: string
tag?: UuidString
}

View file

@ -0,0 +1,8 @@
export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN =
'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.'
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 NOTE_PREVIEW_CHAR_LIMIT = 80
export const SAVE_TIMEOUT_DEBOUNCE = 350
export const SAVE_TIMEOUT_NO_DEBOUNCE = 100

View file

@ -0,0 +1,4 @@
export * from './IconsController'
export * from './NoteViewController'
export * from './FileViewController'
export * from './ItemGroupController'

View file

@ -0,0 +1,40 @@
export const APPLICATION_DEFAULT_HOSTS = [
'api.standardnotes.com',
'api-dev.standardnotes.com',
'sync.standardnotes.org',
'syncing-server-demo.standardnotes.com',
]
export const FILES_DEFAULT_HOSTS = ['files.standardnotes.com', 'files-dev.standardnotes.com']
export const TRUSTED_FEATURE_HOSTS = [
'api-dev.standardnotes.com',
'api.standardnotes.com',
'extensions.standardnotes.com',
'extensions.standardnotes.org',
'extensions-server-dev.standardnotes.org',
'extensions-server-dev.standardnotes.com',
'features.standardnotes.com',
]
export enum ExtensionsServerURL {
Dev = 'https://extensions-server-dev.standardnotes.org',
Prod = 'https://extensions.standardnotes.org',
}
const LocalHost = 'localhost'
export function isUrlFirstParty(url: string): boolean {
try {
const { host } = new URL(url)
return host.startsWith(LocalHost) || APPLICATION_DEFAULT_HOSTS.includes(host) || FILES_DEFAULT_HOSTS.includes(host)
} catch (_err) {
return false
}
}
export const PROD_OFFLINE_FEATURES_URL = 'https://api.standardnotes.com/v1/offline/features'
export const LEGACY_PROD_EXT_ORIGIN = 'https://extensions.standardnotes.org'
export const TRUSTED_CUSTOM_EXTENSIONS_HOSTS = ['listed.to']

13
packages/snjs/lib/Log.ts Normal file
View file

@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
export class SNLog {
static log(...message: any): void {
this.onLog(...message)
}
static error<T extends Error>(error: T): T {
this.onError(error)
return error
}
static onLog: (...message: any) => void
static onError: (error: Error) => void
}

View file

@ -0,0 +1,124 @@
import { ItemManager } from '@Lib/Services'
import { TagsToFoldersMigrationApplicator } from './TagsToFolders'
const itemManagerMock = (tagTitles: string[]) => {
const mockTag = (title: string) => ({
title,
uuid: title,
parentId: undefined,
})
const mock = {
getItems: jest.fn().mockReturnValue(tagTitles.map(mockTag)),
findOrCreateTagParentChain: jest.fn(),
changeItem: jest.fn(),
}
return mock
}
describe('folders component to hierarchy', () => {
it('should produce a valid hierarchy in the simple case', async () => {
const titles = ['a', 'a.b', 'a.b.c']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a'])
expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['a', 'b'])
expect(changeItemCalls.length).toEqual(2)
expect(changeItemCalls[0][0].uuid).toEqual('a.b')
expect(changeItemCalls[1][0].uuid).toEqual('a.b.c')
})
it('should not touch flat hierarchies', async () => {
const titles = ['a', 'x', 'y', 'z']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(0)
expect(changeItemCalls.length).toEqual(0)
})
it('should work despite cloned tags', async () => {
const titles = ['a.b', 'c', 'a.b']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a'])
expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['a'])
expect(changeItemCalls.length).toEqual(2)
expect(changeItemCalls[0][0].uuid).toEqual('a.b')
expect(changeItemCalls[0][0].uuid).toEqual('a.b')
})
it('should produce a valid hierarchy cases with missing intermediate tags or unordered', async () => {
const titles = ['y.2', 'w.3', 'y']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['w'])
expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['y'])
expect(changeItemCalls.length).toEqual(2)
expect(changeItemCalls[0][0].uuid).toEqual('w.3')
expect(changeItemCalls[1][0].uuid).toEqual('y.2')
})
it('skip prefixed names', async () => {
const titles = ['.something', '.something...something']
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(0)
expect(changeItemCalls.length).toEqual(0)
})
it('skip not-supported names', async () => {
const titles = [
'something.',
'something..',
'something..another.thing',
'a.b.c',
'a',
'something..another.thing..anyway',
]
const itemManager = itemManagerMock(titles)
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
const changeItemCalls = itemManager.changeItem.mock.calls
expect(findOrCreateTagParentChainCalls.length).toEqual(1)
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a', 'b'])
expect(changeItemCalls.length).toEqual(1)
expect(changeItemCalls[0][0].uuid).toEqual('a.b.c')
})
})

View file

@ -0,0 +1,50 @@
import { SNTag, TagMutator, TagFolderDelimitter } from '@standardnotes/models'
import { ItemManager } from '@Lib/Services'
import { lastElement, sortByKey, withoutLastElement } from '@standardnotes/utils'
import { ContentType } from '@standardnotes/common'
export class TagsToFoldersMigrationApplicator {
public static isApplicableToCurrentData(itemManager: ItemManager): boolean {
const tags = itemManager.getItems<SNTag>(ContentType.Tag)
for (const tag of tags) {
if (tag.title.includes(TagFolderDelimitter) && !tag.parentId) {
return true
}
}
return false
}
public static async run(itemManager: ItemManager): Promise<void> {
const tags = itemManager.getItems(ContentType.Tag) as SNTag[]
const sortedTags = sortByKey(tags, 'title')
for (const tag of sortedTags) {
const hierarchy = tag.title.split(TagFolderDelimitter)
const hasSimpleTitle = hierarchy.length === 1
const hasParent = !!tag.parentId
const hasUnsupportedTitle = hierarchy.some((title) => title.length === 0)
if (hasParent || hasSimpleTitle || hasUnsupportedTitle) {
continue
}
const parents = withoutLastElement(hierarchy)
const newTitle = lastElement(hierarchy)
if (!newTitle) {
return
}
const parent = await itemManager.findOrCreateTagParentChain(parents)
await itemManager.changeItem(tag, (mutator: TagMutator) => {
mutator.title = newTitle
if (parent) {
mutator.makeChildOf(parent)
}
})
}
}
}

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